From 80b4e063f106886e23dfa666f1d596ae723fccc0 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 08:42:31 -0700 Subject: [PATCH 01/15] Run Discord bridgev2 on mautrix-go main --- Dockerfile | 2 +- build.sh | 2 +- cmd/mautrix-discord/main.go | 2 ++ go.mod | 22 ++++++++-------- go.sum | 44 +++++++++++++++---------------- pkg/connector/config.go | 10 +++++++ pkg/connector/directmedia_test.go | 16 +++++------ 7 files changed, 54 insertions(+), 44 deletions(-) diff --git a/Dockerfile b/Dockerfile index c5f7e329..f6bea77d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev COPY . /build WORKDIR /build -RUN go build -o /usr/bin/mautrix-discord +RUN ./build.sh -o /usr/bin/mautrix-discord FROM alpine:3.22 diff --git a/build.sh b/build.sh index aa6d009f..25883bd7 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ #!/bin/sh MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@" +go build -ldflags="-s -w $GO_LDFLAGS" "$@" ./cmd/mautrix-discord diff --git a/cmd/mautrix-discord/main.go b/cmd/mautrix-discord/main.go index 53918015..001ffbba 100644 --- a/cmd/mautrix-discord/main.go +++ b/cmd/mautrix-discord/main.go @@ -17,6 +17,7 @@ package main import ( + "maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/matrix/mxmain" "go.mau.fi/mautrix-discord/pkg/connector" @@ -40,6 +41,7 @@ var m = mxmain.BridgeMain{ } func main() { + bridgeconfig.HackyMigrateLegacyNetworkConfig = connector.MigrateLegacyConfig m.PostInit = func() { m.CheckLegacyDB(24, "v0.7.6", "v26.03", m.LegacyMigrateWithAnotherUpgrader( diff --git a/go.mod b/go.mod index c93390d7..29b27b3e 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,17 @@ toolchain go1.26.2 require ( github.com/bwmarrin/discordgo v0.27.0 - github.com/coder/websocket v1.8.14 + github.com/coder/websocket v1.8.15 github.com/google/uuid v1.6.0 github.com/imroc/req/v3 v3.57.0 github.com/refraction-networking/utls v1.8.1 github.com/rs/zerolog v1.35.1 github.com/yuin/goldmark v1.8.2 - go.mau.fi/util v0.9.9 - golang.org/x/net v0.55.0 - golang.org/x/term v0.43.0 + go.mau.fi/util v0.9.10 + golang.org/x/net v0.56.0 + golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.28.1-0.20260519145316-34b5f49408a3 + maunium.net/go/mautrix v0.28.2-0.20260702123919-bf2dbb2aa895 ) require ( @@ -30,7 +30,7 @@ require ( github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.21 // indirect - github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/mattn/go-sqlite3 v1.14.45 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect @@ -41,11 +41,11 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 377345dc..0000463a 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/beeper/discordgo v0.0.0-20260620030032-507542c8bda5 h1:RS1pVR7FUf0nD3 github.com/beeper/discordgo v0.0.0-20260620030032-507542c8bda5/go.mod h1:s8aRVTzFZnyujER0GEVscqvjH6RwlPIzeDjOtWsrReM= github.com/beeper/req/v3 v3.0.0-20260114152409-4c060b237f73 h1:stqCPKTpnUY5JFn6wShL4RoMnebtR+MNC9fcLvOCH1Y= github.com/beeper/req/v3 v3.0.0-20260114152409-4c060b237f73/go.mod h1:pH5qbsMNeaNpCOlozJoLRuLfSxEYGp9uWEG5j6zogNQ= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -36,8 +36,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -73,26 +73,26 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= +go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -107,5 +107,5 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 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/mautrix v0.28.1-0.20260519145316-34b5f49408a3 h1:l86igJY8Te5JEBNPL9NSpZaGv6eHsxtCrAabXv8KPJo= -maunium.net/go/mautrix v0.28.1-0.20260519145316-34b5f49408a3/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= +maunium.net/go/mautrix v0.28.2-0.20260702123919-bf2dbb2aa895 h1:oe87/mP1vo4LnA6CJDEfyg5ZiAOar2quJNypfO8eS4Y= +maunium.net/go/mautrix v0.28.2-0.20260702123919-bf2dbb2aa895/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY= diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 0afc158e..1092339a 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -24,6 +24,7 @@ import ( "github.com/bwmarrin/discordgo" up "go.mau.fi/util/configupgrade" "gopkg.in/yaml.v3" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" ) //go:embed example-config.yaml @@ -147,6 +148,15 @@ func upgradeConfig(helper up.Helper) { helper.Copy(up.Bool, "proxy_login_remoteauth") } +func MigrateLegacyConfig(helper up.Helper) { + bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "channel_name_template"}, []string{"network", "channel_name_template"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "custom_emoji_reactions"}, []string{"network", "custom_emoji_reactions"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "guild_avatars_in_rooms"}, []string{"network", "guilds", "guild_avatars_in_rooms"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "forbid_dming_strangers"}, []string{"network", "forbid_dming_strangers"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "log_when_dropping_messages"}, []string{"network", "log_when_dropping_messages"}) + bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "proxy"}, []string{"network", "proxy"}) +} + func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) { return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig) } diff --git a/pkg/connector/directmedia_test.go b/pkg/connector/directmedia_test.go index e4d09b86..6d7b3165 100644 --- a/pkg/connector/directmedia_test.go +++ b/pkg/connector/directmedia_test.go @@ -1,21 +1,19 @@ package connector import ( + "encoding/binary" + "encoding/hex" "testing" "time" ) func TestParseAttachmentExpiryParam(t *testing.T) { - losAngeles, err := time.LoadLocation("America/Los_Angeles") - if err != nil { - t.Fatalf("failed to load test timezone: %v", err) - } - - expiry := parseAttachmentExpiryParam("69be6214").In(losAngeles) - got := expiry.String() - want := "2026-03-21 02:17:08 -0700 PDT" + want := time.Now().Add(24 * time.Hour).Truncate(time.Second) + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], uint32(want.Unix())) - if got != want { + got := parseAttachmentExpiryParam(hex.EncodeToString(encoded[:])) + if !got.Equal(want) { t.Fatalf("unexpected parsed expiry: got %q, want %q", got, want) } } From 46234a78def5ce8fdd7075e4f561d4efc6dc68b2 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 09:36:20 -0700 Subject: [PATCH 02/15] Allow relay reactions on bridgev2 --- go.mod | 2 + mautrix-patched/.editorconfig | 15 + mautrix-patched/.github/workflows/go.yml | 102 + mautrix-patched/.github/workflows/stale.yml | 29 + mautrix-patched/.gitignore | 6 + mautrix-patched/.pre-commit-config.yaml | 29 + mautrix-patched/CHANGELOG.md | 1555 +++++ mautrix-patched/LICENSE | 373 ++ mautrix-patched/README.md | 24 + mautrix-patched/appservice/appservice.go | 434 ++ mautrix-patched/appservice/appservice_test.go | 42 + mautrix-patched/appservice/eventprocessor.go | 209 + mautrix-patched/appservice/http.go | 296 + mautrix-patched/appservice/intent.go | 562 ++ mautrix-patched/appservice/ping.go | 68 + mautrix-patched/appservice/protocol.go | 103 + mautrix-patched/appservice/registration.go | 101 + mautrix-patched/appservice/txnid.go | 43 + mautrix-patched/appservice/websocket.go | 455 ++ mautrix-patched/appservice/wshttp.go | 98 + mautrix-patched/beeperstream/crypto.go | 110 + mautrix-patched/beeperstream/helper.go | 462 ++ mautrix-patched/beeperstream/helper_test.go | 824 +++ mautrix-patched/beeperstream/publisher.go | 429 ++ mautrix-patched/beeperstream/receiver.go | 172 + mautrix-patched/bridgev2/backfillqueue.go | 360 ++ mautrix-patched/bridgev2/bridge.go | 470 ++ .../bridgev2/bridgeconfig/appservice.go | 142 + .../bridgev2/bridgeconfig/backfill.go | 45 + .../bridgev2/bridgeconfig/config.go | 211 + .../bridgev2/bridgeconfig/encryption.go | 51 + .../bridgev2/bridgeconfig/homeserver.go | 40 + .../bridgev2/bridgeconfig/legacymigrate.go | 176 + .../bridgev2/bridgeconfig/permissions.go | 124 + .../bridgev2/bridgeconfig/relay.go | 111 + .../bridgev2/bridgeconfig/upgrade.go | 240 + mautrix-patched/bridgev2/bridgestate.go | 333 + mautrix-patched/bridgev2/commands/cleanup.go | 97 + mautrix-patched/bridgev2/commands/debug.go | 125 + mautrix-patched/bridgev2/commands/event.go | 105 + mautrix-patched/bridgev2/commands/handler.go | 118 + mautrix-patched/bridgev2/commands/help.go | 142 + .../bridgev2/commands/imagepack.go | 51 + mautrix-patched/bridgev2/commands/login.go | 667 ++ .../bridgev2/commands/managechat.go | 358 ++ .../bridgev2/commands/processor.go | 208 + mautrix-patched/bridgev2/commands/relay.go | 170 + .../bridgev2/commands/startchat.go | 366 ++ mautrix-patched/bridgev2/commands/sudo.go | 108 + .../bridgev2/database/backfillqueue.go | 192 + mautrix-patched/bridgev2/database/database.go | 154 + .../bridgev2/database/disappear.go | 142 + mautrix-patched/bridgev2/database/ghost.go | 175 + mautrix-patched/bridgev2/database/kvstore.go | 59 + mautrix-patched/bridgev2/database/message.go | 334 + mautrix-patched/bridgev2/database/portal.go | 296 + .../bridgev2/database/publicmedia.go | 72 + mautrix-patched/bridgev2/database/reaction.go | 120 + .../bridgev2/database/upgrades/00-latest.sql | 234 + .../upgrades/02-disappearing-messages.sql | 11 + .../upgrades/03-portal-relay-postgres.sql | 13 + .../upgrades/04-portal-relay-sqlite.sql | 100 + .../05-message-receiver-pkey-postgres.sql | 10 + .../06-message-receiver-pkey-sqlite.sql | 75 + .../07-message-relation-without-fkey.sql | 4 + .../08-drop-message-relates-to.postgres.sql | 3 + .../08-drop-message-relates-to.sqlite.sql | 41 + .../upgrades/09-remove-standard-metadata.sql | 45 + ...10-fix-signal-portal-revision.postgres.sql | 4 + .../10-fix-signal-portal-revision.sqlite.sql | 4 + .../database/upgrades/11-room-fkey-idx.sql | 5 + .../upgrades/12-dm-portal-other-user.sql | 2 + .../database/upgrades/13-backfill-queue.sql | 20 + .../upgrades/14-portal-name-custom.sql | 2 + .../upgrades/15-reaction-sender-mxid.sql | 2 + .../upgrades/16-user-login-profile.sql | 2 + .../upgrades/17-message-mxid-unique.sql | 8 + .../database/upgrades/18-kv-store.sql | 8 + .../19-add-double-puppeted-to-message.sql | 2 + .../upgrades/20-portal-capabilities.sql | 2 + .../21-disappearing-message-fkey.postgres.sql | 8 + .../21-disappearing-message-fkey.sqlite.sql | 24 + .../upgrades/22-message-send-txn-id.sql | 6 + .../upgrades/23-disappearing-timer-ts.sql | 2 + .../database/upgrades/24-public-media.sql | 11 + .../database/upgrades/25-message-requests.sql | 2 + .../26-disappearing-message-portal-index.sql | 3 + .../upgrades/27-ghost-extra-profile.sql | 2 + .../upgrades/28-backfill-queue-done-flag.sql | 3 + ...ear-incorrect-personal-filtering-space.sql | 2 + .../bridgev2/database/upgrades/upgrades.go | 22 + mautrix-patched/bridgev2/database/user.go | 74 + .../bridgev2/database/userlogin.go | 123 + .../bridgev2/database/userportal.go | 155 + mautrix-patched/bridgev2/disappear.go | 153 + mautrix-patched/bridgev2/errors.go | 137 + mautrix-patched/bridgev2/ghost.go | 405 ++ mautrix-patched/bridgev2/login.go | 318 + mautrix-patched/bridgev2/matrix/analytics.go | 62 + mautrix-patched/bridgev2/matrix/cmdadmin.go | 79 + .../bridgev2/matrix/cmddoublepuppet.go | 90 + mautrix-patched/bridgev2/matrix/connector.go | 786 +++ mautrix-patched/bridgev2/matrix/crypto.go | 607 ++ .../bridgev2/matrix/cryptoerror.go | 94 + .../bridgev2/matrix/cryptostore.go | 63 + .../bridgev2/matrix/directmedia.go | 107 + .../bridgev2/matrix/doublepuppet.go | 131 + mautrix-patched/bridgev2/matrix/exiterror.go | 24 + mautrix-patched/bridgev2/matrix/intent.go | 791 +++ mautrix-patched/bridgev2/matrix/matrix.go | 251 + .../bridgev2/matrix/mxmain/config.go | 36 + .../bridgev2/matrix/mxmain/dberror.go | 79 + .../bridgev2/matrix/mxmain/envconfig.go | 191 + .../matrix/mxmain/example-config.yaml | 513 ++ .../bridgev2/matrix/mxmain/legacymigrate.go | 270 + .../bridgev2/matrix/mxmain/main.go | 454 ++ .../bridgev2/matrix/mxmain/main_test.go | 40 + mautrix-patched/bridgev2/matrix/no-crypto.go | 26 + .../bridgev2/matrix/provisioning.go | 704 +++ .../bridgev2/matrix/provisioninglogin.go | 295 + .../bridgev2/matrix/publicmedia.go | 278 + mautrix-patched/bridgev2/matrix/websocket.go | 169 + mautrix-patched/bridgev2/matrixinterface.go | 243 + mautrix-patched/bridgev2/matrixinvite.go | 294 + mautrix-patched/bridgev2/messagestatus.go | 243 + .../bridgev2/networkid/bridgeid.go | 146 + mautrix-patched/bridgev2/networkinterface.go | 1501 +++++ mautrix-patched/bridgev2/portal.go | 5557 +++++++++++++++++ mautrix-patched/bridgev2/portalbackfill.go | 646 ++ mautrix-patched/bridgev2/portalinternal.go | 418 ++ .../bridgev2/portalinternal_generate.go | 173 + mautrix-patched/bridgev2/portalreid.go | 161 + .../bridgev2/provisionutil/creategroup.go | 161 + .../bridgev2/provisionutil/imagepack.go | 217 + .../bridgev2/provisionutil/listcontacts.go | 98 + .../provisionutil/resolveidentifier.go | 125 + mautrix-patched/bridgev2/queue.go | 275 + mautrix-patched/bridgev2/simpleremoteevent.go | 133 + mautrix-patched/bridgev2/simplevent/chat.go | 108 + .../bridgev2/simplevent/message.go | 121 + mautrix-patched/bridgev2/simplevent/meta.go | 163 + .../bridgev2/simplevent/reaction.go | 67 + .../bridgev2/simplevent/receipt.go | 77 + mautrix-patched/bridgev2/space.go | 211 + .../bridgev2/status/bridgestate.go | 216 + .../bridgev2/status/localbridgestate.go | 23 + .../bridgev2/status/messagecheckpoint.go | 212 + .../bridgev2/unorganized-docs/FEATURES.md | 49 + .../bridgev2/unorganized-docs/README.md | 66 + .../incoming-matrix-message.uml | 23 + .../incoming-remote-message.uml | 22 + .../unorganized-docs/login-step.schema.json | 155 + .../bridgev2/unorganized-docs/login-steps.uml | 43 + mautrix-patched/bridgev2/user.go | 275 + mautrix-patched/bridgev2/userlogin.go | 577 ++ mautrix-patched/client.go | 2999 +++++++++ mautrix-patched/client_retry_test.go | 491 ++ mautrix-patched/commands/container.go | 133 + mautrix-patched/commands/event.go | 237 + mautrix-patched/commands/handler.go | 105 + mautrix-patched/commands/prevalidate.go | 84 + mautrix-patched/commands/processor.go | 152 + mautrix-patched/commands/reactions.go | 143 + mautrix-patched/crypto/account.go | 128 + mautrix-patched/crypto/aescbc/aes_cbc.go | 59 + mautrix-patched/crypto/aescbc/aes_cbc_test.go | 63 + mautrix-patched/crypto/aescbc/errors.go | 15 + .../crypto/attachment/attachments.go | 317 + .../crypto/attachment/attachments_test.go | 85 + .../crypto/backup/encryptedsessiondata.go | 134 + .../backup/encryptedsessiondata_test.go | 108 + mautrix-patched/crypto/backup/ephemeralkey.go | 41 + .../crypto/backup/ephemeralkey_test.go | 57 + mautrix-patched/crypto/backup/megolmbackup.go | 39 + .../crypto/backup/megolmbackupkey.go | 34 + .../crypto/canonicaljson/README.md | 6 + mautrix-patched/crypto/canonicaljson/json.go | 281 + .../crypto/canonicaljson/json_test.go | 110 + .../crypto/canonicaljson/jsonv2.go | 244 + .../crypto/canonicaljson/jsonv2_test.go | 364 ++ mautrix-patched/crypto/cross_sign_key.go | 161 + mautrix-patched/crypto/cross_sign_pubkey.go | 138 + mautrix-patched/crypto/cross_sign_signing.go | 204 + mautrix-patched/crypto/cross_sign_ssss.go | 176 + mautrix-patched/crypto/cross_sign_store.go | 110 + mautrix-patched/crypto/cross_sign_test.go | 188 + .../crypto/cross_sign_validation.go | 158 + .../crypto/cryptohelper/cryptohelper.go | 440 ++ mautrix-patched/crypto/decryptmegolm.go | 353 ++ mautrix-patched/crypto/decryptolm.go | 438 ++ mautrix-patched/crypto/devicelist.go | 386 ++ mautrix-patched/crypto/ed25519/ed25519.go | 302 + .../crypto/ed25519/ed25519_test.go | 20 + mautrix-patched/crypto/encryptmegolm.go | 463 ++ mautrix-patched/crypto/encryptolm.go | 202 + mautrix-patched/crypto/goolm/README.md | 5 + .../crypto/goolm/account/account.go | 423 ++ .../crypto/goolm/account/account_data_test.go | 267 + .../crypto/goolm/account/account_test.go | 366 ++ .../crypto/goolm/account/register.go | 23 + .../crypto/goolm/aessha2/aessha2.go | 63 + .../crypto/goolm/aessha2/aessha2_test.go | 36 + .../crypto/goolm/crypto/curve25519.go | 127 + .../crypto/goolm/crypto/curve25519_test.go | 125 + mautrix-patched/crypto/goolm/crypto/doc.go | 2 + .../crypto/goolm/crypto/ed25519.go | 164 + .../crypto/goolm/crypto/ed25519_test.go | 89 + .../crypto/goolm/crypto/one_time_key.go | 46 + .../crypto/goolm/goolmbase64/base64.go | 22 + .../crypto/goolm/libolmpickle/encoder.go | 40 + .../crypto/goolm/libolmpickle/encoder_test.go | 99 + .../crypto/goolm/libolmpickle/pickle.go | 53 + .../crypto/goolm/libolmpickle/pickle_test.go | 26 + .../crypto/goolm/libolmpickle/unpickle.go | 58 + .../goolm/libolmpickle/unpickle_test.go | 83 + mautrix-patched/crypto/goolm/main.go | 6 + mautrix-patched/crypto/goolm/megolm/megolm.go | 209 + .../crypto/goolm/megolm/megolm_test.go | 105 + .../crypto/goolm/message/decoder.go | 33 + .../crypto/goolm/message/encoder.go | 24 + .../crypto/goolm/message/encoder_test.go | 59 + .../crypto/goolm/message/group_message.go | 109 + .../goolm/message/group_message_test.go | 62 + .../crypto/goolm/message/message.go | 103 + .../crypto/goolm/message/message_test.go | 42 + .../crypto/goolm/message/prekey_message.go | 114 + .../goolm/message/prekey_message_test.go | 43 + .../crypto/goolm/message/session_export.go | 47 + .../crypto/goolm/message/session_sharing.go | 50 + mautrix-patched/crypto/goolm/pk/pk_test.go | 54 + mautrix-patched/crypto/goolm/pk/register.go | 18 + mautrix-patched/crypto/goolm/pk/signing.go | 71 + mautrix-patched/crypto/goolm/ratchet/chain.go | 178 + mautrix-patched/crypto/goolm/ratchet/olm.go | 393 ++ .../crypto/goolm/ratchet/olm_test.go | 126 + .../crypto/goolm/ratchet/skipped_message.go | 27 + mautrix-patched/crypto/goolm/register.go | 29 + mautrix-patched/crypto/goolm/session/doc.go | 3 + .../goolm/session/megolm_inbound_session.go | 250 + .../goolm/session/megolm_outbound_session.go | 135 + .../goolm/session/megolm_session_test.go | 186 + .../crypto/goolm/session/olm_session.go | 404 ++ .../crypto/goolm/session/olm_session_test.go | 123 + .../crypto/goolm/session/register.go | 61 + mautrix-patched/crypto/keybackup.go | 242 + mautrix-patched/crypto/keyexport.go | 204 + mautrix-patched/crypto/keyexport_test.go | 35 + mautrix-patched/crypto/keyimport.go | 167 + mautrix-patched/crypto/keysharing.go | 423 ++ mautrix-patched/crypto/libolm/account.go | 419 ++ mautrix-patched/crypto/libolm/error.go | 37 + .../crypto/libolm/inboundgroupsession.go | 327 + mautrix-patched/crypto/libolm/libolm.go | 10 + .../crypto/libolm/outboundgroupsession.go | 245 + mautrix-patched/crypto/libolm/pk.go | 149 + mautrix-patched/crypto/libolm/register.go | 72 + mautrix-patched/crypto/libolm/session.go | 401 ++ mautrix-patched/crypto/machine.go | 839 +++ mautrix-patched/crypto/machine_bench_test.go | 67 + mautrix-patched/crypto/machine_test.go | 151 + mautrix-patched/crypto/olm/README.md | 4 + mautrix-patched/crypto/olm/account.go | 116 + mautrix-patched/crypto/olm/account_test.go | 124 + mautrix-patched/crypto/olm/errors.go | 76 + .../crypto/olm/groupsession_test.go | 50 + .../crypto/olm/inboundgroupsession.go | 80 + mautrix-patched/crypto/olm/olm.go | 20 + .../crypto/olm/outboundgroupsession.go | 57 + .../crypto/olm/outboundgroupsession_test.go | 135 + mautrix-patched/crypto/olm/pk.go | 41 + mautrix-patched/crypto/olm/pk_test.go | 46 + mautrix-patched/crypto/olm/session.go | 83 + mautrix-patched/crypto/olm/session_test.go | 143 + mautrix-patched/crypto/registergoolm.go | 11 + mautrix-patched/crypto/registerlibolm.go | 9 + mautrix-patched/crypto/sessions.go | 290 + mautrix-patched/crypto/sharing.go | 190 + .../crypto/signatures/signatures.go | 101 + mautrix-patched/crypto/sql_store.go | 983 +++ .../sql_store_upgrade/00-latest-revision.sql | 126 + .../04-cross-signing-keys.sql | 16 + .../sql_store_upgrade/05-varchar-to-text.sql | 31 + .../06-olm-session-last-used-split.sql | 6 + .../07-trust-state-value-change.sql | 4 + .../08-cs-key-expired-field.sql | 5 + .../sql_store_upgrade/09-max-age-ms.sql | 2 + .../10-mark-ratchetable-keys.sql | 6 + .../sql_store_upgrade/11-outdated-devices.sql | 2 + .../crypto/sql_store_upgrade/12-secrets.sql | 5 + .../13-megolm-session-sharing.sql | 9 + .../14-account-key-backup-version.sql | 4 + .../sql_store_upgrade/15-fix-secrets.sql | 21 + .../16-crypto-olm-sessions-index.sql | 2 + .../17-decrypted-olm-messages.sql | 11 + ...18-megolm-inbound-session-backup-index.sql | 2 + .../19-megolm-session-source.sql | 2 + .../sql_store_upgrade/20-message-key-index.go | 55 + .../crypto/sql_store_upgrade/upgrade.go | 29 + mautrix-patched/crypto/ssss/client.go | 125 + mautrix-patched/crypto/ssss/key.go | 131 + mautrix-patched/crypto/ssss/key_test.go | 89 + mautrix-patched/crypto/ssss/meta.go | 158 + mautrix-patched/crypto/ssss/meta_test.go | 174 + mautrix-patched/crypto/ssss/types.go | 81 + mautrix-patched/crypto/store.go | 755 +++ mautrix-patched/crypto/store_test.go | 299 + mautrix-patched/crypto/utils/utils.go | 132 + mautrix-patched/crypto/utils/utils_test.go | 79 + .../verificationhelper/callbacks_test.go | 167 + .../crypto/verificationhelper/doc.go | 11 + .../crypto/verificationhelper/ecdhkeys.go | 57 + .../verificationhelper/ecdhkeys_test.go | 48 + .../crypto/verificationhelper/qrcode.go | 121 + .../crypto/verificationhelper/qrcode_test.go | 90 + .../crypto/verificationhelper/reciprocate.go | 353 ++ .../crypto/verificationhelper/sas.go | 821 +++ .../crypto/verificationhelper/sas_test.go | 28 + .../verificationhelper/verificationhelper.go | 932 +++ .../verificationhelper_qr_crosssign_test.go | 153 + .../verificationhelper_qr_self_test.go | 370 ++ .../verificationhelper_sas_test.go | 360 ++ .../verificationhelper_test.go | 517 ++ .../verificationhelper/verificationstore.go | 159 + .../verificationstore_test.go | 85 + mautrix-patched/error.go | 258 + mautrix-patched/event/accountdata.go | 119 + mautrix-patched/event/audio.go | 21 + mautrix-patched/event/beeper.go | 383 ++ mautrix-patched/event/capabilities.d.ts | 231 + mautrix-patched/event/capabilities.go | 416 ++ mautrix-patched/event/cmdschema/content.go | 78 + mautrix-patched/event/cmdschema/parameter.go | 286 + mautrix-patched/event/cmdschema/parse.go | 478 ++ mautrix-patched/event/cmdschema/parse_test.go | 118 + mautrix-patched/event/cmdschema/roomid.go | 135 + mautrix-patched/event/cmdschema/stringify.go | 122 + .../cmdschema/testdata/commands.schema.json | 281 + .../cmdschema/testdata/commands/flags.json | 126 + .../testdata/commands/room_id_or_alias.json | 85 + .../commands/room_reference_list.json | 106 + .../cmdschema/testdata/commands/simple.json | 46 + .../cmdschema/testdata/commands/tail.json | 60 + .../event/cmdschema/testdata/data.go | 14 + .../event/cmdschema/testdata/parse_quote.json | 30 + .../testdata/parse_quote.schema.json | 46 + mautrix-patched/event/content.go | 613 ++ mautrix-patched/event/delayed.go | 70 + mautrix-patched/event/encryption.go | 211 + mautrix-patched/event/ephemeral.go | 140 + mautrix-patched/event/events.go | 188 + mautrix-patched/event/eventsource.go | 73 + mautrix-patched/event/imagepack.go | 65 + mautrix-patched/event/member.go | 69 + mautrix-patched/event/message.go | 501 ++ mautrix-patched/event/message_test.go | 187 + mautrix-patched/event/poll.go | 64 + mautrix-patched/event/powerlevels.go | 235 + mautrix-patched/event/relations.go | 240 + mautrix-patched/event/reply.go | 74 + mautrix-patched/event/state.go | 357 ++ mautrix-patched/event/type.go | 312 + mautrix-patched/event/verification.go | 308 + mautrix-patched/event/voip.go | 116 + mautrix-patched/event/voip_test.go | 175 + mautrix-patched/example/main.go | 155 + mautrix-patched/federation/cache.go | 153 + mautrix-patched/federation/client.go | 629 ++ mautrix-patched/federation/client_test.go | 25 + mautrix-patched/federation/context.go | 42 + .../federation/eventauth/eventauth.go | 851 +++ .../eventauth/eventauth_internal_test.go | 66 + .../federation/eventauth/eventauth_test.go | 85 + .../eventauth/testroom-v12-success.jsonl | 21 + mautrix-patched/federation/httpclient.go | 116 + mautrix-patched/federation/keyserver.go | 205 + mautrix-patched/federation/media.go | 125 + mautrix-patched/federation/pdu/auth.go | 71 + mautrix-patched/federation/pdu/hash.go | 119 + mautrix-patched/federation/pdu/hash_test.go | 55 + mautrix-patched/federation/pdu/pdu.go | 141 + mautrix-patched/federation/pdu/pdu_test.go | 193 + mautrix-patched/federation/pdu/redact.go | 111 + mautrix-patched/federation/pdu/signature.go | 67 + .../federation/pdu/signature_test.go | 102 + mautrix-patched/federation/pdu/v1.go | 278 + mautrix-patched/federation/pdu/v1_test.go | 86 + mautrix-patched/federation/resolution.go | 198 + mautrix-patched/federation/resolution_test.go | 115 + mautrix-patched/federation/serverauth.go | 264 + mautrix-patched/federation/serverauth_test.go | 42 + mautrix-patched/federation/servername.go | 95 + mautrix-patched/federation/servername_test.go | 64 + mautrix-patched/federation/signingkey.go | 168 + mautrix-patched/federation/signutil/verify.go | 120 + mautrix-patched/filter.go | 95 + mautrix-patched/format/doc.go | 5 + mautrix-patched/format/htmlparser.go | 520 ++ mautrix-patched/format/markdown.go | 145 + mautrix-patched/format/markdown_test.go | 213 + mautrix-patched/format/mdext/customemoji.go | 73 + .../format/mdext/discordunderline.go | 137 + .../format/mdext/filteredparser.go | 48 + .../format/mdext/indentableparagraph.go | 28 + mautrix-patched/format/mdext/math.go | 240 + mautrix-patched/format/mdext/nohtml.go | 61 + mautrix-patched/format/mdext/shortemphasis.go | 96 + mautrix-patched/format/mdext/shortstrike.go | 76 + mautrix-patched/format/mdext/simplespoiler.go | 62 + mautrix-patched/format/mdext/spoiler.go | 168 + mautrix-patched/go.mod | 42 + mautrix-patched/go.sum | 74 + mautrix-patched/id/contenturi.go | 183 + mautrix-patched/id/crypto.go | 232 + mautrix-patched/id/matrixuri.go | 309 + mautrix-patched/id/matrixuri_test.go | 163 + mautrix-patched/id/opaque.go | 101 + mautrix-patched/id/roomversion.go | 265 + mautrix-patched/id/servername.go | 58 + mautrix-patched/id/trust.go | 97 + mautrix-patched/id/userid.go | 254 + mautrix-patched/id/userid_test.go | 86 + mautrix-patched/mediaproxy/mediaproxy.go | 534 ++ mautrix-patched/mockserver/mockserver.go | 307 + mautrix-patched/pushrules/action.go | 123 + mautrix-patched/pushrules/action_test.go | 180 + mautrix-patched/pushrules/condition.go | 359 ++ .../pushrules/condition_displayname_test.go | 43 + .../pushrules/condition_eventmatch_test.go | 91 + .../condition_eventpropertyis_test.go | 91 + .../pushrules/condition_membercount_test.go | 61 + mautrix-patched/pushrules/condition_test.go | 159 + mautrix-patched/pushrules/doc.go | 2 + .../pushrules/pushgateway/gateway.go | 195 + mautrix-patched/pushrules/pushrules.go | 35 + mautrix-patched/pushrules/pushrules_test.go | 239 + mautrix-patched/pushrules/rule.go | 181 + mautrix-patched/pushrules/rule_array_test.go | 285 + mautrix-patched/pushrules/rule_test.go | 312 + mautrix-patched/pushrules/ruleset.go | 100 + mautrix-patched/requests.go | 711 +++ mautrix-patched/responses.go | 810 +++ mautrix-patched/responses_test.go | 111 + mautrix-patched/room.go | 52 + mautrix-patched/sqlstatestore/statestore.go | 495 ++ .../sqlstatestore/v00-latest-revision.sql | 32 + .../sqlstatestore/v02-membership-enum.sql | 6 + mautrix-patched/sqlstatestore/v03-no-null.sql | 10 + .../sqlstatestore/v04-encryption-info.sql | 2 + .../v05-mark-encryption-state-resync.go | 30 + .../v06-displayname-disambiguation.go | 55 + .../sqlstatestore/v07-full-member-flag.sql | 2 + .../sqlstatestore/v08-create-event.sql | 2 + .../v09-clear-empty-room-ids.sql | 3 + .../sqlstatestore/v10-join-rules.sql | 2 + mautrix-patched/statestore.go | 392 ++ mautrix-patched/synapseadmin/client.go | 22 + mautrix-patched/synapseadmin/register.go | 101 + mautrix-patched/synapseadmin/roomapi.go | 250 + mautrix-patched/synapseadmin/userapi.go | 214 + mautrix-patched/sync.go | 287 + mautrix-patched/syncstore.go | 175 + mautrix-patched/url.go | 127 + mautrix-patched/url_test.go | 65 + mautrix-patched/version.go | 41 + mautrix-patched/versions.go | 216 + mautrix-patched/versions_test.go | 102 + 466 files changed, 85862 insertions(+) create mode 100644 mautrix-patched/.editorconfig create mode 100644 mautrix-patched/.github/workflows/go.yml create mode 100644 mautrix-patched/.github/workflows/stale.yml create mode 100644 mautrix-patched/.gitignore create mode 100644 mautrix-patched/.pre-commit-config.yaml create mode 100644 mautrix-patched/CHANGELOG.md create mode 100644 mautrix-patched/LICENSE create mode 100644 mautrix-patched/README.md create mode 100644 mautrix-patched/appservice/appservice.go create mode 100644 mautrix-patched/appservice/appservice_test.go create mode 100644 mautrix-patched/appservice/eventprocessor.go create mode 100644 mautrix-patched/appservice/http.go create mode 100644 mautrix-patched/appservice/intent.go create mode 100644 mautrix-patched/appservice/ping.go create mode 100644 mautrix-patched/appservice/protocol.go create mode 100644 mautrix-patched/appservice/registration.go create mode 100644 mautrix-patched/appservice/txnid.go create mode 100644 mautrix-patched/appservice/websocket.go create mode 100644 mautrix-patched/appservice/wshttp.go create mode 100644 mautrix-patched/beeperstream/crypto.go create mode 100644 mautrix-patched/beeperstream/helper.go create mode 100644 mautrix-patched/beeperstream/helper_test.go create mode 100644 mautrix-patched/beeperstream/publisher.go create mode 100644 mautrix-patched/beeperstream/receiver.go create mode 100644 mautrix-patched/bridgev2/backfillqueue.go create mode 100644 mautrix-patched/bridgev2/bridge.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/appservice.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/backfill.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/config.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/encryption.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/homeserver.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/permissions.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/relay.go create mode 100644 mautrix-patched/bridgev2/bridgeconfig/upgrade.go create mode 100644 mautrix-patched/bridgev2/bridgestate.go create mode 100644 mautrix-patched/bridgev2/commands/cleanup.go create mode 100644 mautrix-patched/bridgev2/commands/debug.go create mode 100644 mautrix-patched/bridgev2/commands/event.go create mode 100644 mautrix-patched/bridgev2/commands/handler.go create mode 100644 mautrix-patched/bridgev2/commands/help.go create mode 100644 mautrix-patched/bridgev2/commands/imagepack.go create mode 100644 mautrix-patched/bridgev2/commands/login.go create mode 100644 mautrix-patched/bridgev2/commands/managechat.go create mode 100644 mautrix-patched/bridgev2/commands/processor.go create mode 100644 mautrix-patched/bridgev2/commands/relay.go create mode 100644 mautrix-patched/bridgev2/commands/startchat.go create mode 100644 mautrix-patched/bridgev2/commands/sudo.go create mode 100644 mautrix-patched/bridgev2/database/backfillqueue.go create mode 100644 mautrix-patched/bridgev2/database/database.go create mode 100644 mautrix-patched/bridgev2/database/disappear.go create mode 100644 mautrix-patched/bridgev2/database/ghost.go create mode 100644 mautrix-patched/bridgev2/database/kvstore.go create mode 100644 mautrix-patched/bridgev2/database/message.go create mode 100644 mautrix-patched/bridgev2/database/portal.go create mode 100644 mautrix-patched/bridgev2/database/publicmedia.go create mode 100644 mautrix-patched/bridgev2/database/reaction.go create mode 100644 mautrix-patched/bridgev2/database/upgrades/00-latest.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/24-public-media.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql create mode 100644 mautrix-patched/bridgev2/database/upgrades/upgrades.go create mode 100644 mautrix-patched/bridgev2/database/user.go create mode 100644 mautrix-patched/bridgev2/database/userlogin.go create mode 100644 mautrix-patched/bridgev2/database/userportal.go create mode 100644 mautrix-patched/bridgev2/disappear.go create mode 100644 mautrix-patched/bridgev2/errors.go create mode 100644 mautrix-patched/bridgev2/ghost.go create mode 100644 mautrix-patched/bridgev2/login.go create mode 100644 mautrix-patched/bridgev2/matrix/analytics.go create mode 100644 mautrix-patched/bridgev2/matrix/cmdadmin.go create mode 100644 mautrix-patched/bridgev2/matrix/cmddoublepuppet.go create mode 100644 mautrix-patched/bridgev2/matrix/connector.go create mode 100644 mautrix-patched/bridgev2/matrix/crypto.go create mode 100644 mautrix-patched/bridgev2/matrix/cryptoerror.go create mode 100644 mautrix-patched/bridgev2/matrix/cryptostore.go create mode 100644 mautrix-patched/bridgev2/matrix/directmedia.go create mode 100644 mautrix-patched/bridgev2/matrix/doublepuppet.go create mode 100644 mautrix-patched/bridgev2/matrix/exiterror.go create mode 100644 mautrix-patched/bridgev2/matrix/intent.go create mode 100644 mautrix-patched/bridgev2/matrix/matrix.go create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/config.go create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/dberror.go create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/envconfig.go create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/main.go create mode 100644 mautrix-patched/bridgev2/matrix/mxmain/main_test.go create mode 100644 mautrix-patched/bridgev2/matrix/no-crypto.go create mode 100644 mautrix-patched/bridgev2/matrix/provisioning.go create mode 100644 mautrix-patched/bridgev2/matrix/provisioninglogin.go create mode 100644 mautrix-patched/bridgev2/matrix/publicmedia.go create mode 100644 mautrix-patched/bridgev2/matrix/websocket.go create mode 100644 mautrix-patched/bridgev2/matrixinterface.go create mode 100644 mautrix-patched/bridgev2/matrixinvite.go create mode 100644 mautrix-patched/bridgev2/messagestatus.go create mode 100644 mautrix-patched/bridgev2/networkid/bridgeid.go create mode 100644 mautrix-patched/bridgev2/networkinterface.go create mode 100644 mautrix-patched/bridgev2/portal.go create mode 100644 mautrix-patched/bridgev2/portalbackfill.go create mode 100644 mautrix-patched/bridgev2/portalinternal.go create mode 100644 mautrix-patched/bridgev2/portalinternal_generate.go create mode 100644 mautrix-patched/bridgev2/portalreid.go create mode 100644 mautrix-patched/bridgev2/provisionutil/creategroup.go create mode 100644 mautrix-patched/bridgev2/provisionutil/imagepack.go create mode 100644 mautrix-patched/bridgev2/provisionutil/listcontacts.go create mode 100644 mautrix-patched/bridgev2/provisionutil/resolveidentifier.go create mode 100644 mautrix-patched/bridgev2/queue.go create mode 100644 mautrix-patched/bridgev2/simpleremoteevent.go create mode 100644 mautrix-patched/bridgev2/simplevent/chat.go create mode 100644 mautrix-patched/bridgev2/simplevent/message.go create mode 100644 mautrix-patched/bridgev2/simplevent/meta.go create mode 100644 mautrix-patched/bridgev2/simplevent/reaction.go create mode 100644 mautrix-patched/bridgev2/simplevent/receipt.go create mode 100644 mautrix-patched/bridgev2/space.go create mode 100644 mautrix-patched/bridgev2/status/bridgestate.go create mode 100644 mautrix-patched/bridgev2/status/localbridgestate.go create mode 100644 mautrix-patched/bridgev2/status/messagecheckpoint.go create mode 100644 mautrix-patched/bridgev2/unorganized-docs/FEATURES.md create mode 100644 mautrix-patched/bridgev2/unorganized-docs/README.md create mode 100644 mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml create mode 100644 mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml create mode 100644 mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json create mode 100644 mautrix-patched/bridgev2/unorganized-docs/login-steps.uml create mode 100644 mautrix-patched/bridgev2/user.go create mode 100644 mautrix-patched/bridgev2/userlogin.go create mode 100644 mautrix-patched/client.go create mode 100644 mautrix-patched/client_retry_test.go create mode 100644 mautrix-patched/commands/container.go create mode 100644 mautrix-patched/commands/event.go create mode 100644 mautrix-patched/commands/handler.go create mode 100644 mautrix-patched/commands/prevalidate.go create mode 100644 mautrix-patched/commands/processor.go create mode 100644 mautrix-patched/commands/reactions.go create mode 100644 mautrix-patched/crypto/account.go create mode 100644 mautrix-patched/crypto/aescbc/aes_cbc.go create mode 100644 mautrix-patched/crypto/aescbc/aes_cbc_test.go create mode 100644 mautrix-patched/crypto/aescbc/errors.go create mode 100644 mautrix-patched/crypto/attachment/attachments.go create mode 100644 mautrix-patched/crypto/attachment/attachments_test.go create mode 100644 mautrix-patched/crypto/backup/encryptedsessiondata.go create mode 100644 mautrix-patched/crypto/backup/encryptedsessiondata_test.go create mode 100644 mautrix-patched/crypto/backup/ephemeralkey.go create mode 100644 mautrix-patched/crypto/backup/ephemeralkey_test.go create mode 100644 mautrix-patched/crypto/backup/megolmbackup.go create mode 100644 mautrix-patched/crypto/backup/megolmbackupkey.go create mode 100644 mautrix-patched/crypto/canonicaljson/README.md create mode 100644 mautrix-patched/crypto/canonicaljson/json.go create mode 100644 mautrix-patched/crypto/canonicaljson/json_test.go create mode 100644 mautrix-patched/crypto/canonicaljson/jsonv2.go create mode 100644 mautrix-patched/crypto/canonicaljson/jsonv2_test.go create mode 100644 mautrix-patched/crypto/cross_sign_key.go create mode 100644 mautrix-patched/crypto/cross_sign_pubkey.go create mode 100644 mautrix-patched/crypto/cross_sign_signing.go create mode 100644 mautrix-patched/crypto/cross_sign_ssss.go create mode 100644 mautrix-patched/crypto/cross_sign_store.go create mode 100644 mautrix-patched/crypto/cross_sign_test.go create mode 100644 mautrix-patched/crypto/cross_sign_validation.go create mode 100644 mautrix-patched/crypto/cryptohelper/cryptohelper.go create mode 100644 mautrix-patched/crypto/decryptmegolm.go create mode 100644 mautrix-patched/crypto/decryptolm.go create mode 100644 mautrix-patched/crypto/devicelist.go create mode 100644 mautrix-patched/crypto/ed25519/ed25519.go create mode 100644 mautrix-patched/crypto/ed25519/ed25519_test.go create mode 100644 mautrix-patched/crypto/encryptmegolm.go create mode 100644 mautrix-patched/crypto/encryptolm.go create mode 100644 mautrix-patched/crypto/goolm/README.md create mode 100644 mautrix-patched/crypto/goolm/account/account.go create mode 100644 mautrix-patched/crypto/goolm/account/account_data_test.go create mode 100644 mautrix-patched/crypto/goolm/account/account_test.go create mode 100644 mautrix-patched/crypto/goolm/account/register.go create mode 100644 mautrix-patched/crypto/goolm/aessha2/aessha2.go create mode 100644 mautrix-patched/crypto/goolm/aessha2/aessha2_test.go create mode 100644 mautrix-patched/crypto/goolm/crypto/curve25519.go create mode 100644 mautrix-patched/crypto/goolm/crypto/curve25519_test.go create mode 100644 mautrix-patched/crypto/goolm/crypto/doc.go create mode 100644 mautrix-patched/crypto/goolm/crypto/ed25519.go create mode 100644 mautrix-patched/crypto/goolm/crypto/ed25519_test.go create mode 100644 mautrix-patched/crypto/goolm/crypto/one_time_key.go create mode 100644 mautrix-patched/crypto/goolm/goolmbase64/base64.go create mode 100644 mautrix-patched/crypto/goolm/libolmpickle/encoder.go create mode 100644 mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go create mode 100644 mautrix-patched/crypto/goolm/libolmpickle/pickle.go create mode 100644 mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go create mode 100644 mautrix-patched/crypto/goolm/libolmpickle/unpickle.go create mode 100644 mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go create mode 100644 mautrix-patched/crypto/goolm/main.go create mode 100644 mautrix-patched/crypto/goolm/megolm/megolm.go create mode 100644 mautrix-patched/crypto/goolm/megolm/megolm_test.go create mode 100644 mautrix-patched/crypto/goolm/message/decoder.go create mode 100644 mautrix-patched/crypto/goolm/message/encoder.go create mode 100644 mautrix-patched/crypto/goolm/message/encoder_test.go create mode 100644 mautrix-patched/crypto/goolm/message/group_message.go create mode 100644 mautrix-patched/crypto/goolm/message/group_message_test.go create mode 100644 mautrix-patched/crypto/goolm/message/message.go create mode 100644 mautrix-patched/crypto/goolm/message/message_test.go create mode 100644 mautrix-patched/crypto/goolm/message/prekey_message.go create mode 100644 mautrix-patched/crypto/goolm/message/prekey_message_test.go create mode 100644 mautrix-patched/crypto/goolm/message/session_export.go create mode 100644 mautrix-patched/crypto/goolm/message/session_sharing.go create mode 100644 mautrix-patched/crypto/goolm/pk/pk_test.go create mode 100644 mautrix-patched/crypto/goolm/pk/register.go create mode 100644 mautrix-patched/crypto/goolm/pk/signing.go create mode 100644 mautrix-patched/crypto/goolm/ratchet/chain.go create mode 100644 mautrix-patched/crypto/goolm/ratchet/olm.go create mode 100644 mautrix-patched/crypto/goolm/ratchet/olm_test.go create mode 100644 mautrix-patched/crypto/goolm/ratchet/skipped_message.go create mode 100644 mautrix-patched/crypto/goolm/register.go create mode 100644 mautrix-patched/crypto/goolm/session/doc.go create mode 100644 mautrix-patched/crypto/goolm/session/megolm_inbound_session.go create mode 100644 mautrix-patched/crypto/goolm/session/megolm_outbound_session.go create mode 100644 mautrix-patched/crypto/goolm/session/megolm_session_test.go create mode 100644 mautrix-patched/crypto/goolm/session/olm_session.go create mode 100644 mautrix-patched/crypto/goolm/session/olm_session_test.go create mode 100644 mautrix-patched/crypto/goolm/session/register.go create mode 100644 mautrix-patched/crypto/keybackup.go create mode 100644 mautrix-patched/crypto/keyexport.go create mode 100644 mautrix-patched/crypto/keyexport_test.go create mode 100644 mautrix-patched/crypto/keyimport.go create mode 100644 mautrix-patched/crypto/keysharing.go create mode 100644 mautrix-patched/crypto/libolm/account.go create mode 100644 mautrix-patched/crypto/libolm/error.go create mode 100644 mautrix-patched/crypto/libolm/inboundgroupsession.go create mode 100644 mautrix-patched/crypto/libolm/libolm.go create mode 100644 mautrix-patched/crypto/libolm/outboundgroupsession.go create mode 100644 mautrix-patched/crypto/libolm/pk.go create mode 100644 mautrix-patched/crypto/libolm/register.go create mode 100644 mautrix-patched/crypto/libolm/session.go create mode 100644 mautrix-patched/crypto/machine.go create mode 100644 mautrix-patched/crypto/machine_bench_test.go create mode 100644 mautrix-patched/crypto/machine_test.go create mode 100644 mautrix-patched/crypto/olm/README.md create mode 100644 mautrix-patched/crypto/olm/account.go create mode 100644 mautrix-patched/crypto/olm/account_test.go create mode 100644 mautrix-patched/crypto/olm/errors.go create mode 100644 mautrix-patched/crypto/olm/groupsession_test.go create mode 100644 mautrix-patched/crypto/olm/inboundgroupsession.go create mode 100644 mautrix-patched/crypto/olm/olm.go create mode 100644 mautrix-patched/crypto/olm/outboundgroupsession.go create mode 100644 mautrix-patched/crypto/olm/outboundgroupsession_test.go create mode 100644 mautrix-patched/crypto/olm/pk.go create mode 100644 mautrix-patched/crypto/olm/pk_test.go create mode 100644 mautrix-patched/crypto/olm/session.go create mode 100644 mautrix-patched/crypto/olm/session_test.go create mode 100644 mautrix-patched/crypto/registergoolm.go create mode 100644 mautrix-patched/crypto/registerlibolm.go create mode 100644 mautrix-patched/crypto/sessions.go create mode 100644 mautrix-patched/crypto/sharing.go create mode 100644 mautrix-patched/crypto/signatures/signatures.go create mode 100644 mautrix-patched/crypto/sql_store.go create mode 100644 mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql create mode 100644 mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go create mode 100644 mautrix-patched/crypto/sql_store_upgrade/upgrade.go create mode 100644 mautrix-patched/crypto/ssss/client.go create mode 100644 mautrix-patched/crypto/ssss/key.go create mode 100644 mautrix-patched/crypto/ssss/key_test.go create mode 100644 mautrix-patched/crypto/ssss/meta.go create mode 100644 mautrix-patched/crypto/ssss/meta_test.go create mode 100644 mautrix-patched/crypto/ssss/types.go create mode 100644 mautrix-patched/crypto/store.go create mode 100644 mautrix-patched/crypto/store_test.go create mode 100644 mautrix-patched/crypto/utils/utils.go create mode 100644 mautrix-patched/crypto/utils/utils_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/callbacks_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/doc.go create mode 100644 mautrix-patched/crypto/verificationhelper/ecdhkeys.go create mode 100644 mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/qrcode.go create mode 100644 mautrix-patched/crypto/verificationhelper/qrcode_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/reciprocate.go create mode 100644 mautrix-patched/crypto/verificationhelper/sas.go create mode 100644 mautrix-patched/crypto/verificationhelper/sas_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_test.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationstore.go create mode 100644 mautrix-patched/crypto/verificationhelper/verificationstore_test.go create mode 100644 mautrix-patched/error.go create mode 100644 mautrix-patched/event/accountdata.go create mode 100644 mautrix-patched/event/audio.go create mode 100644 mautrix-patched/event/beeper.go create mode 100644 mautrix-patched/event/capabilities.d.ts create mode 100644 mautrix-patched/event/capabilities.go create mode 100644 mautrix-patched/event/cmdschema/content.go create mode 100644 mautrix-patched/event/cmdschema/parameter.go create mode 100644 mautrix-patched/event/cmdschema/parse.go create mode 100644 mautrix-patched/event/cmdschema/parse_test.go create mode 100644 mautrix-patched/event/cmdschema/roomid.go create mode 100644 mautrix-patched/event/cmdschema/stringify.go create mode 100644 mautrix-patched/event/cmdschema/testdata/commands.schema.json create mode 100644 mautrix-patched/event/cmdschema/testdata/commands/flags.json create mode 100644 mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json create mode 100644 mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json create mode 100644 mautrix-patched/event/cmdschema/testdata/commands/simple.json create mode 100644 mautrix-patched/event/cmdschema/testdata/commands/tail.json create mode 100644 mautrix-patched/event/cmdschema/testdata/data.go create mode 100644 mautrix-patched/event/cmdschema/testdata/parse_quote.json create mode 100644 mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json create mode 100644 mautrix-patched/event/content.go create mode 100644 mautrix-patched/event/delayed.go create mode 100644 mautrix-patched/event/encryption.go create mode 100644 mautrix-patched/event/ephemeral.go create mode 100644 mautrix-patched/event/events.go create mode 100644 mautrix-patched/event/eventsource.go create mode 100644 mautrix-patched/event/imagepack.go create mode 100644 mautrix-patched/event/member.go create mode 100644 mautrix-patched/event/message.go create mode 100644 mautrix-patched/event/message_test.go create mode 100644 mautrix-patched/event/poll.go create mode 100644 mautrix-patched/event/powerlevels.go create mode 100644 mautrix-patched/event/relations.go create mode 100644 mautrix-patched/event/reply.go create mode 100644 mautrix-patched/event/state.go create mode 100644 mautrix-patched/event/type.go create mode 100644 mautrix-patched/event/verification.go create mode 100644 mautrix-patched/event/voip.go create mode 100644 mautrix-patched/event/voip_test.go create mode 100644 mautrix-patched/example/main.go create mode 100644 mautrix-patched/federation/cache.go create mode 100644 mautrix-patched/federation/client.go create mode 100644 mautrix-patched/federation/client_test.go create mode 100644 mautrix-patched/federation/context.go create mode 100644 mautrix-patched/federation/eventauth/eventauth.go create mode 100644 mautrix-patched/federation/eventauth/eventauth_internal_test.go create mode 100644 mautrix-patched/federation/eventauth/eventauth_test.go create mode 100644 mautrix-patched/federation/eventauth/testroom-v12-success.jsonl create mode 100644 mautrix-patched/federation/httpclient.go create mode 100644 mautrix-patched/federation/keyserver.go create mode 100644 mautrix-patched/federation/media.go create mode 100644 mautrix-patched/federation/pdu/auth.go create mode 100644 mautrix-patched/federation/pdu/hash.go create mode 100644 mautrix-patched/federation/pdu/hash_test.go create mode 100644 mautrix-patched/federation/pdu/pdu.go create mode 100644 mautrix-patched/federation/pdu/pdu_test.go create mode 100644 mautrix-patched/federation/pdu/redact.go create mode 100644 mautrix-patched/federation/pdu/signature.go create mode 100644 mautrix-patched/federation/pdu/signature_test.go create mode 100644 mautrix-patched/federation/pdu/v1.go create mode 100644 mautrix-patched/federation/pdu/v1_test.go create mode 100644 mautrix-patched/federation/resolution.go create mode 100644 mautrix-patched/federation/resolution_test.go create mode 100644 mautrix-patched/federation/serverauth.go create mode 100644 mautrix-patched/federation/serverauth_test.go create mode 100644 mautrix-patched/federation/servername.go create mode 100644 mautrix-patched/federation/servername_test.go create mode 100644 mautrix-patched/federation/signingkey.go create mode 100644 mautrix-patched/federation/signutil/verify.go create mode 100644 mautrix-patched/filter.go create mode 100644 mautrix-patched/format/doc.go create mode 100644 mautrix-patched/format/htmlparser.go create mode 100644 mautrix-patched/format/markdown.go create mode 100644 mautrix-patched/format/markdown_test.go create mode 100644 mautrix-patched/format/mdext/customemoji.go create mode 100644 mautrix-patched/format/mdext/discordunderline.go create mode 100644 mautrix-patched/format/mdext/filteredparser.go create mode 100644 mautrix-patched/format/mdext/indentableparagraph.go create mode 100644 mautrix-patched/format/mdext/math.go create mode 100644 mautrix-patched/format/mdext/nohtml.go create mode 100644 mautrix-patched/format/mdext/shortemphasis.go create mode 100644 mautrix-patched/format/mdext/shortstrike.go create mode 100644 mautrix-patched/format/mdext/simplespoiler.go create mode 100644 mautrix-patched/format/mdext/spoiler.go create mode 100644 mautrix-patched/go.mod create mode 100644 mautrix-patched/go.sum create mode 100644 mautrix-patched/id/contenturi.go create mode 100644 mautrix-patched/id/crypto.go create mode 100644 mautrix-patched/id/matrixuri.go create mode 100644 mautrix-patched/id/matrixuri_test.go create mode 100644 mautrix-patched/id/opaque.go create mode 100644 mautrix-patched/id/roomversion.go create mode 100644 mautrix-patched/id/servername.go create mode 100644 mautrix-patched/id/trust.go create mode 100644 mautrix-patched/id/userid.go create mode 100644 mautrix-patched/id/userid_test.go create mode 100644 mautrix-patched/mediaproxy/mediaproxy.go create mode 100644 mautrix-patched/mockserver/mockserver.go create mode 100644 mautrix-patched/pushrules/action.go create mode 100644 mautrix-patched/pushrules/action_test.go create mode 100644 mautrix-patched/pushrules/condition.go create mode 100644 mautrix-patched/pushrules/condition_displayname_test.go create mode 100644 mautrix-patched/pushrules/condition_eventmatch_test.go create mode 100644 mautrix-patched/pushrules/condition_eventpropertyis_test.go create mode 100644 mautrix-patched/pushrules/condition_membercount_test.go create mode 100644 mautrix-patched/pushrules/condition_test.go create mode 100644 mautrix-patched/pushrules/doc.go create mode 100644 mautrix-patched/pushrules/pushgateway/gateway.go create mode 100644 mautrix-patched/pushrules/pushrules.go create mode 100644 mautrix-patched/pushrules/pushrules_test.go create mode 100644 mautrix-patched/pushrules/rule.go create mode 100644 mautrix-patched/pushrules/rule_array_test.go create mode 100644 mautrix-patched/pushrules/rule_test.go create mode 100644 mautrix-patched/pushrules/ruleset.go create mode 100644 mautrix-patched/requests.go create mode 100644 mautrix-patched/responses.go create mode 100644 mautrix-patched/responses_test.go create mode 100644 mautrix-patched/room.go create mode 100644 mautrix-patched/sqlstatestore/statestore.go create mode 100644 mautrix-patched/sqlstatestore/v00-latest-revision.sql create mode 100644 mautrix-patched/sqlstatestore/v02-membership-enum.sql create mode 100644 mautrix-patched/sqlstatestore/v03-no-null.sql create mode 100644 mautrix-patched/sqlstatestore/v04-encryption-info.sql create mode 100644 mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go create mode 100644 mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go create mode 100644 mautrix-patched/sqlstatestore/v07-full-member-flag.sql create mode 100644 mautrix-patched/sqlstatestore/v08-create-event.sql create mode 100644 mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql create mode 100644 mautrix-patched/sqlstatestore/v10-join-rules.sql create mode 100644 mautrix-patched/statestore.go create mode 100644 mautrix-patched/synapseadmin/client.go create mode 100644 mautrix-patched/synapseadmin/register.go create mode 100644 mautrix-patched/synapseadmin/roomapi.go create mode 100644 mautrix-patched/synapseadmin/userapi.go create mode 100644 mautrix-patched/sync.go create mode 100644 mautrix-patched/syncstore.go create mode 100644 mautrix-patched/url.go create mode 100644 mautrix-patched/url_test.go create mode 100644 mautrix-patched/version.go create mode 100644 mautrix-patched/versions.go create mode 100644 mautrix-patched/versions_test.go diff --git a/go.mod b/go.mod index 29b27b3e..a074fa69 100644 --- a/go.mod +++ b/go.mod @@ -53,3 +53,5 @@ require ( replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260620030032-507542c8bda5 replace github.com/imroc/req/v3 => github.com/beeper/req/v3 v3.0.0-20260114152409-4c060b237f73 + +replace maunium.net/go/mautrix => ./mautrix-patched diff --git a/mautrix-patched/.editorconfig b/mautrix-patched/.editorconfig new file mode 100644 index 00000000..1a167e7e --- /dev/null +++ b/mautrix-patched/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{yaml,yml}] +indent_style = space + +[provisioning.yaml] +indent_size = 2 diff --git a/mautrix-patched/.github/workflows/go.yml b/mautrix-patched/.github/workflows/go.yml new file mode 100644 index 00000000..47a6aab8 --- /dev/null +++ b/mautrix-patched/.github/workflows/go.yml @@ -0,0 +1,102 @@ +name: Go + +on: [push, pull_request] + +env: + GOTOOLCHAIN: local + +jobs: + lint: + runs-on: ubuntu-latest + name: Lint (latest) + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + + - name: Install libolm + run: sudo apt-get install libolm-dev libolm3 + + - name: Install goimports + run: | + go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest + export PATH="$HOME/go/bin:$PATH" + + - name: Run pre-commit + uses: pre-commit/action@v3.0.1 + + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.25", "1.26"] + name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, libolm) + + steps: + - uses: actions/checkout@v6 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Set up gotestfmt + uses: GoTestTools/gotestfmt-action@eba2ac97f623e866e7b1b117c99f18b43cd36d18 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install libolm + run: sudo apt-get install libolm-dev libolm3 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -json -v ./... 2>&1 | gotestfmt + + - name: Build (jsonv2) + env: + GOEXPERIMENT: jsonv2 + run: go build -v ./... + + - name: Test (jsonv2) + env: + GOEXPERIMENT: jsonv2 + run: go test -json -v ./... 2>&1 | gotestfmt + + build-goolm: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.25", "1.26"] + name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, goolm) + + steps: + - uses: actions/checkout@v6 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Set up gotestfmt + uses: GoTestTools/gotestfmt-action@eba2ac97f623e866e7b1b117c99f18b43cd36d18 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + run: | + rm -rf crypto/libolm + go build -tags=goolm -v ./... + + - name: Test + run: go test -tags=goolm -json -v ./... 2>&1 | gotestfmt diff --git a/mautrix-patched/.github/workflows/stale.yml b/mautrix-patched/.github/workflows/stale.yml new file mode 100644 index 00000000..9a9e7375 --- /dev/null +++ b/mautrix-patched/.github/workflows/stale.yml @@ -0,0 +1,29 @@ +name: 'Lock old issues' + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + issues: write +# pull-requests: write +# discussions: write + +concurrency: + group: lock-threads + +jobs: + lock-stale: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v6 + id: lock + with: + issue-inactive-days: 90 + process-only: issues + - name: Log processed threads + run: | + if [ '${{ steps.lock.outputs.issues }}' ]; then + echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"' + fi diff --git a/mautrix-patched/.gitignore b/mautrix-patched/.gitignore new file mode 100644 index 00000000..0f69cfc6 --- /dev/null +++ b/mautrix-patched/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.vscode/ +*.db* +*.log +.cursor/plans/ +.claude/settings.local.json diff --git a/mautrix-patched/.pre-commit-config.yaml b/mautrix-patched/.pre-commit-config.yaml new file mode 100644 index 00000000..616fccb2 --- /dev/null +++ b/mautrix-patched/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + exclude_types: [markdown] + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.4 + hooks: + - id: go-imports-repo + args: + - "-local" + - "maunium.net/go/mautrix" + - "-w" + - id: go-vet-repo-mod + - id: go-mod-tidy + - id: go-staticcheck-repo-mod + + - repo: https://github.com/beeper/pre-commit-go + rev: v0.4.2 + hooks: + - id: prevent-literal-http-methods + - id: zerolog-ban-global-log + - id: zerolog-ban-msgf + - id: zerolog-use-stringer diff --git a/mautrix-patched/CHANGELOG.md b/mautrix-patched/CHANGELOG.md new file mode 100644 index 00000000..05150a8e --- /dev/null +++ b/mautrix-patched/CHANGELOG.md @@ -0,0 +1,1555 @@ +## v0.28.1 (2026-06-16) + +* *(pushrules)* Removed deprecated `NotifySpecified` field in `Should()`. +* *(pushrules)* Deprecated `ActionDontNotify` and `ActionCoalesce` constants. +* *(client)* Added wrapper for `/search` endpoint. +* *(client)* Added wrappers for the `/enabled` and `/actions` sub-endpoints + for modifying push rules. +* *(bridgev2/commands)* Added warning when using the login command in a + non-management room. +* *(bridgev2/matrix)* Added `ParseContentURI` method for parsing a `mxc://` URI + previously generated for direct media. +* *(bridgev2/provisioning)* Changed request handling to allow retrying requests. + * Requests can be retried as long as the next step hasn't been completed. + * Cancellation is now done with a separate API call. +* *(bridgev2/provisioning)* Changed group creation to ignore self in participants + list instead of rejecting the request. +* *(bridgev2/provisioning)* Changed error responses to include internal error in + a separate field. +* *(bridgev2)* Fixed `m.bridge` event in child portals not being updated when + the parent portal name or avatar changes. +* *(client)* Fixed request retry trigger not working correctly in some cases. + +## v0.28.0 (2026-05-16) + +* **Breaking change *(federation)*** Changed `NewClient` to take HTTP client + settings as an extra parameter. +* *(federation)* Changed client to block requests to private IPs by default. + * The `AllowIP` method can be changed to adjust the blocking behavior. +* *(federation)* Added `DownloadMedia` method. +* *(event)* Added `Extra` field to `FileInfo` to allow easily adding custom + fields. +* *(event)* Added sticker source info to events (both [MSC4459] and a custom + format for bridges). +* *(client)* Added support for `server` parameter in `/publicRooms` + (thanks to [@zluudg] in [#497]). +* *(client)* Added `RequestRetryTrigger` event, which can be used to force all + in-flight requests to be interrupted and retried (e.g. in case the network + connection changed). +* *(crypto)* Added support for bundled device keys in Olm messages introduced + in Matrix v1.15. +* *(crypto/canonicaljson)* Added jsonv2-based implementation, which replaces + the old gjson/sjson-based implementation when jsonv2 is enabled. +* *(bridgev2)* Added interface for importing image packs from remote networks. +* *(bridgev2)* Added more detail to "not logged in" error messages. +* *(bridgev2)* Expanded `bridge_matrix_leave` option to cover invite rejections + in addition to actual leaves. +* *(bridgev2)* Changed group creation error messages to be clearer when there + aren't enough members to create a group. +* *(bridgev2/matrix)* Changed message sending to never send unencrypted messages + if `encryption.require` is set `true` even if the room is unencrypted. +* *(crypto)* Changed trust resolution to not trust own cross-signing master key + unless the private key is available or the public key is signed by the device + key. +* *(bridgev2)* Fixed event power levels being set incorrectly in some cases if + `events_default` or `state_default` is changed in the same event. +* *(bridgev2)* Fixed portal deletion always failing due to context cancellation. +* *(bridgev2)* Fixed per-message profile fallbacks being added for events that + shouldn't have it, like stickers. +* *(bridgev2)* Fixed some cases where backfill would start from a non-latest + message. +* *(crypto)* Fixed dehydrated devices not passing device key validation. +* *(federation/pdu)* Fixed canonicalizing JSON which contains keys with code + points between `\uF000` and `\uFFFF` by switching to the crypto/canonicaljson + package instead of jsonv2's standard RFC 8785 canonicalization. +* *(federation/eventauth)* Fixed restricted join checks + (thanks to [@timedoutuk] in [#491]). +* *(federation/eventauth)* Fixed creator join check in v10 rooms + (thanks to [@timedoutuk] in [#496]). +* *(crypto/goolm)* Fixed various small issues. + +[MSC445]: https://github.com/matrix-org/matrix-spec-proposals/pull/4459 +[#491]: https://github.com/mautrix/go/pull/491 +[#496]: https://github.com/mautrix/go/pull/496 +[#497]: https://github.com/mautrix/go/pull/497 +[@zluudg]: https://github.com/zluudg + +## v0.27.0 (2026-04-16) + +### Slightly breaking changes +* *(crypto)* Changed `GetOwnCrossSigningPublicKeys` to return errors instead of + only logging them and returning nil. +* *(event)* Removed automatic registrations of content structs to encoding/gob. + If you use mautrix types with gob, you'll have to register the structs yourself. +* *(crypto)* Removed unused Olm PK encryption/decryption interface. +* *(crypto/goolm)* Removed unused JSON pickling methods. + +### New features and non-breaking changes +* *(client)* Added support for [MSC4446] for moving `m.fully_read` backwards. +* *(appservice)* Added support for escaped paths in HTTP over websocket proxy. +* *(synapseadmin)* Added wrapper for redacting all events from a specific user + (thanks to [@timedoutuk] in [#466]). +* *(event)* Added types for [MSC2545] image packs. +* *(bridgev2)* Added option to block automatic portal creation for specific + chats and/or users. +* *(bridgev2)* Added commands to bridge existing groups to existing rooms and + to create new portal rooms for existing groups. +* *(bridgev2)* Added option to always prefer default relays for the `bridge` + and `set-relay` commands. +* *(bridgev2)* Added support for using [MSC4437] for ghost profile updates. +* *(bridgev2)* Added optional GetStateEvent method to `ASIntent` to get state + while respecting room membership. +* *(bridgev2/mxmain)* Added environment variables to change global values like + the portal event buffer size. +* *(event)* Changed `EnsureHasHTML` to also ensure the body is treated as a + caption for media messages. +* *(bridgev2)* Changed relay mode to treat stickers as normal images. +* *(bridgev2/matrix)* Changed various start methods to return ExitErrors instead + of calling `os.Exit` directly. +* *(client)* Changed sync response structs to use `omitzero` instead of custom + JSON marshaling functions. + +### Bug fixes +* *(crypto/goolm)* Fixed various issues. +* *(crypto)* Fixed new Olm session handling to only delete one-time keys after + successfully decrypting a message. +* *(crypto)* Fixed `ResolveTrust` not checking trust status of cross-signing + keys correctly. +* *(crypto)* Fixed `m.relates_to` copying not working for some inputs with goolm. +* *(event)* Fixed `Content.UnmarshalJSON` incorrectly keeping a reference to the + input data. +* *(format)* Fixed math blocks not being routed to correct convert function. +* *(bridgev2)* Fixed sending tombstone when redirecting a portal to another room. +* *(bridgev2)* Fixed removed messages/reactions not being removed from database. +* *(bridgev2)* Fixed race conditions where portal ID changes could result in a + duplicate room being created. +* *(bridgev2/mxmain)* Fixed some types of config fields not being settable with + environment variables. +* *(appservice)* Fixed redundant `mx_registrations` database query on every + request. + +[#466]: https://github.com/mautrix/go/pull/466 +[MSC2545]: https://github.com/matrix-org/matrix-spec-proposals/pull/2545 +[MSC4437]: https://github.com/matrix-org/matrix-spec-proposals/pull/4437 +[MSC4446]: https://github.com/matrix-org/matrix-spec-proposals/pull/4446 + +## v0.26.4 (2026-03-16) + +* **Breaking change *(client)*** Changed request structs that include UIA + (register, upload cross-signing keys, delete devices) to take the auth data + as a type parameter. +* *(crypto)* Changed device key mismatches in Megolm decryption to mark the + message as untrusted instead of failing entirely. +* *(crypto)* Added new column to save origin of received Megolm sessions. +* *(bridgev2)* Added support for setting custom profile fields (e.g. `m.tz`) + for ghosts. +* *(bridgev2/commands)* Added `delete-chat` command to delete chats on the + remote network. +* *(client)* Updated MSC2666 implementation to use stable endpoint. +* *(client)* Stopped logging large (>32 KiB) request bodies. +* *(bridgev2/portal)* Fixed potential deadlock when a portal ID change races + with room creation. +* *(bridgev2/portal)* Fixed the third reaction from Matrix being handled + incorrectly on networks that only allow one reaction per message. +* *(bridgev2/database)* Fixed finding first message in thread in case the thread + contains messages with a lower timestamp than the root message. +* *(bridgev2/commands)* Fixed login QR codes not having appropriate file info. +* *(bridgev2/commands)* Fixed user input steps not working correctly after a + display step. +* *(format/htmlparser)* Fixed generating markdown for code blocks containing + backticks. +* *(federation/eventauth)* Fixed inverted check in ban membership authorization + (thanks to [@timedoutuk] in [#464]). + +[#464]: https://github.com/mautrix/go/pull/464 + +## v0.26.3 (2026-02-16) + +* Bumped minimum Go version to 1.25. +* *(client)* Added fields for sending [MSC4354] sticky events. +* *(bridgev2)* Added automatic message request accepting when sending message. +* *(mediaproxy)* Added support for federation thumbnail endpoint. +* *(crypto/ssss)* Improved support for recovery keys with slightly broken + metadata. +* *(crypto)* Changed key import to call session received callback even for + sessions that already exist in the database. +* *(appservice)* Fixed building websocket URL accidentally using file path + separators instead of always `/`. +* *(crypto)* Fixed key exports not including the `sender_claimed_keys` field. +* *(client)* Fixed incorrect context usage in async uploads. +* *(crypto)* Fixed panic when passing invalid input to megolm message index + parser used for debugging. +* *(bridgev2/provisioning)* Fixed completed or failed logins not being cleaned + up properly. + +[MSC4354]: https://github.com/matrix-org/matrix-spec-proposals/pull/4354 + +## v0.26.2 (2026-01-16) + +* *(bridgev2)* Added chunked portal deletion to avoid database locks when + deleting large portals. +* *(crypto,bridgev2)* Added option to encrypt reaction and reply metadata + as per [MSC4392]. +* *(bridgev2/login)* Added `default_value` for user input fields. +* *(bridgev2)* Added interfaces to let the Matrix connector provide suggested + HTTP client settings and to reset active connections of the network connector. +* *(bridgev2)* Added interface to let network connectors get the provisioning + API HTTP router and add new endpoints. +* *(event)* Added blurhash field to Beeper link preview objects. +* *(event)* Added [MSC4391] support for bot commands. +* *(event)* Dropped [MSC4332] support for bot commands. +* *(client)* Changed media download methods to return an error if the provided + MXC URI is empty. +* *(client)* Stabilized support for [MSC4323]. +* *(bridgev2/matrix)* Fixed `GetEvent` panicking when trying to decrypt events. +* *(bridgev2)* Fixed some deadlocks when room creation happens in parallel with + a portal re-ID call. + +[MSC4391]: https://github.com/matrix-org/matrix-spec-proposals/pull/4391 +[MSC4392]: https://github.com/matrix-org/matrix-spec-proposals/pull/4392 + +## v0.26.1 (2025-12-16) + +* **Breaking change *(mediaproxy)*** Changed `GetMediaResponseFile` to return + the mime type from the callback rather than in the return get media return + value. The callback can now also redirect the caller to a different file. +* *(federation)* Added join/knock/leave functions + (thanks to [@timedoutuk] in [#422]). +* *(federation/eventauth)* Fixed various incorrect checks. +* *(client)* Added backoff for retrying media uploads to external URLs + (with MSC3870). +* *(bridgev2/config)* Added support for overriding config fields using + environment variables. +* *(bridgev2/commands)* Added command to mute chat on remote network. +* *(bridgev2)* Added interface for network connectors to redirect to a different + user ID when handling an invite from Matrix. +* *(bridgev2)* Added interface for signaling message request status of portals. +* *(bridgev2)* Changed portal creation to not backfill unless `CanBackfill` flag + is set in chat info. +* *(bridgev2)* Changed Matrix reaction handling to only delete old reaction if + bridging the new one is successful. +* *(bridgev2/mxmain)* Improved error message when trying to run bridge with + pre-megabridge database when no database migration exists. +* *(bridgev2)* Improved reliability of database migration when enabling split + portals. +* *(bridgev2)* Improved detection of orphaned DM rooms when starting new chats. +* *(bridgev2)* Stopped sending redundant invites when joining ghosts to public + portal rooms. +* *(bridgev2)* Stopped hardcoding room versions in favor of checking + server capabilities to determine appropriate `/createRoom` parameters. + +[#422]: https://github.com/mautrix/go/pull/422 + +## v0.26.0 (2025-11-16) + +* *(client,appservice)* Deprecated `SendMassagedStateEvent` as `SendStateEvent` + has been able to do the same for a while now. +* *(client,federation)* Added size limits for responses to make it safer to send + requests to untrusted servers. +* *(client)* Added wrapper for `/admin/whois` client API + (thanks to [@timedoutuk] in [#411]). +* *(synapseadmin)* Added `force_purge` option to DeleteRoom + (thanks to [@timedoutuk] in [#420]). +* *(statestore)* Added saving join rules for rooms. +* *(bridgev2)* Added optional automatic rollback of room state if bridging the + change to the remote network fails. +* *(bridgev2)* Added management room notices if transient disconnect state + doesn't resolve within 3 minutes. +* *(bridgev2)* Added interface to signal that certain participants couldn't be + invited when creating a group. +* *(bridgev2)* Added `select` type for user input fields in login. +* *(bridgev2)* Added interface to let network connector customize personal + filtering space. +* *(bridgev2/matrix)* Added checks to avoid sending error messages in reply to + other bots. +* *(bridgev2/matrix)* Switched to using [MSC4169] to send redactions whenever + possible. +* *(bridgev2/publicmedia)* Added support for custom path prefixes, file names, + and encrypted files. +* *(bridgev2/commands)* Added command to resync a single portal. +* *(bridgev2/commands)* Added create group command. +* *(bridgev2/config)* Added option to limit maximum number of logins. +* *(bridgev2)* Changed ghost joining to skip unnecessary invite if portal room + is public. +* *(bridgev2/disappear)* Changed read receipt handling to only start + disappearing timers for messages up to the read message (note: may not work in + all cases if the read receipt points at an unknown event). +* *(event/reply)* Changed plaintext reply fallback removal to only happen when + an HTML reply fallback is removed successfully. +* *(bridgev2/matrix)* Fixed unnecessary sleep after registering bot on first run. +* *(crypto/goolm)* Fixed panic when processing certain malformed Olm messages. +* *(federation)* Fixed HTTP method for sending transactions + (thanks to [@timedoutuk] in [#426]). +* *(federation)* Fixed response body being closed even when using `DontReadBody` + parameter. +* *(federation)* Fixed validating auth for requests with query params. +* *(federation/eventauth)* Fixed typo causing restricted joins to not work. + +[MSC4169]: https://github.com/matrix-org/matrix-spec-proposals/pull/4169 +[#411]: github.com/mautrix/go/pull/411 +[#420]: github.com/mautrix/go/pull/420 +[#426]: github.com/mautrix/go/pull/426 + +## v0.25.2 (2025-10-16) + +* **Breaking change *(id)*** Split `UserID.ParseAndValidate` into + `ParseAndValidateRelaxed` and `ParseAndValidateStrict`. Strict is the old + behavior, but most users likely want the relaxed version, as there are real + users whose user IDs aren't valid under the strict rules. +* *(crypto)* Added helper methods for generating and verifying with recovery + keys. +* *(bridgev2/matrix)* Added config option to automatically generate a recovery + key for the bridge bot and self-sign the bridge's device. +* *(bridgev2/matrix)* Added initial support for using appservice/MSC3202 mode + for encryption with standard servers like Synapse. +* *(bridgev2)* Added optional support for implicit read receipts. +* *(bridgev2)* Added interface for deleting chats on remote network. +* *(bridgev2)* Added local enforcement of media duration and size limits. +* *(bridgev2)* Extended event duration logging to log any event taking too long. +* *(bridgev2)* Improved validation in group creation provisioning API. +* *(event)* Added event type constant for poll end events. +* *(client)* Added wrapper for searching user directory. +* *(client)* Improved support for managing [MSC4140] delayed events. +* *(crypto/helper)* Changed default sync handling to not block on waiting for + decryption keys. On initial sync, keys won't be requested at all by default. +* *(crypto)* Fixed olm unwedging not working (regressed in v0.25.1). +* *(bridgev2)* Fixed various bugs with migrating to split portals. +* *(event)* Fixed poll start events having incorrect null `m.relates_to`. +* *(client)* Fixed `RespUserProfile` losing standard fields when re-marshaling. +* *(federation)* Fixed various bugs in event auth. + +## v0.25.1 (2025-09-16) + +* *(client)* Fixed HTTP method of delete devices API call + (thanks to [@fmseals] in [#393]). +* *(client)* Added wrappers for [MSC4323]: User suspension & locking endpoints + (thanks to [@timedoutuk] in [#407]). +* *(client)* Stabilized support for extensible profiles. +* *(client)* Stabilized support for `state_after` in sync. +* *(client)* Removed deprecated MSC2716 requests. +* *(crypto)* Added fallback to ensure `m.relates_to` is always copied even if + the content struct doesn't implement `Relatable`. +* *(crypto)* Changed olm unwedging to ignore newly created sessions if they + haven't been used successfully in either direction. +* *(federation)* Added utilities for generating, parsing, validating and + authorizing PDUs. + * Note: the new PDU code depends on `GOEXPERIMENT=jsonv2` +* *(event)* Added `is_animated` flag from [MSC4230] to file info. +* *(event)* Added types for [MSC4332]: In-room bot commands. +* *(event)* Added missing poll end event type for [MSC3381]. +* *(appservice)* Fixed URLs not being escaped properly when using unix socket + for homeserver connections. +* *(format)* Added more helpers for forming markdown links. +* *(event,bridgev2)* Added support for Beeper's disappearing message state event. +* *(bridgev2)* Redesigned group creation interface and added support in commands + and provisioning API. +* *(bridgev2)* Added GetEvent to Matrix interface to allow network connectors to + get an old event. The method is best effort only, as some configurations don't + allow fetching old events. +* *(bridgev2)* Added shared logic for provisioning that can be reused by the + API, commands and other sources. +* *(bridgev2)* Fixed mentions and URL previews not being copied over when + caption and media are merged. +* *(bridgev2)* Removed config option to change provisioning API prefix, which + had already broken in the previous release. + +[@fmseals]: https://github.com/fmseals +[#393]: https://github.com/mautrix/go/pull/393 +[#407]: https://github.com/mautrix/go/pull/407 +[MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 +[MSC4230]: https://github.com/matrix-org/matrix-spec-proposals/pull/4230 +[MSC4323]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +[MSC4332]: https://github.com/matrix-org/matrix-spec-proposals/pull/4332 + +## v0.25.0 (2025-08-16) + +* Bumped minimum Go version to 1.24. +* **Breaking change *(appservice,bridgev2,federation)*** Replaced gorilla/mux + with standard library ServeMux. +* *(client,bridgev2)* Added support for creator power in room v12. +* *(client)* Added option to not set `User-Agent` header for improved Wasm + compatibility. +* *(bridgev2)* Added support for following tombstones. +* *(bridgev2)* Added interface for getting arbitrary state event from Matrix. +* *(bridgev2)* Added batching to disappearing message queue to ensure it doesn't + use too many resources even if there are a large number of messages. +* *(bridgev2/commands)* Added support for canceling QR login with `cancel` + command. +* *(client)* Added option to override HTTP client used for .well-known + resolution. +* *(crypto/backup)* Added method for encrypting key backup session without + private keys. +* *(event->id)* Moved room version type and constants to id package. +* *(bridgev2)* Bots in DM portals will now be added to the functional members + state event to hide them from the room name calculation. +* *(bridgev2)* Changed message delete handling to ignore "delete for me" events + if there are multiple Matrix users in the room. +* *(format/htmlparser)* Changed text processing to collapse multiple spaces into + one when outside `pre`/`code` tags. +* *(format/htmlparser)* Removed link suffix in plaintext output when link text + is only missing protocol part of href. + * e.g. `example.com` will turn into + `example.com` rather than `example.com (https://example.com)` +* *(appservice)* Switched appservice websockets from gorilla/websocket to + coder/websocket. +* *(bridgev2/matrix)* Fixed encryption key sharing not ignoring ghosts properly. +* *(crypto/attachments)* Fixed hash check when decrypting file streams. +* *(crypto)* Removed unnecessary `AlreadyShared` error in `ShareGroupSession`. + The function will now act as if it was successful instead. + +## v0.24.2 (2025-07-16) + +* *(bridgev2)* Added support for return values from portal event handlers. Note + that the return value will always be "queued" unless the event buffer is + disabled. +* *(bridgev2)* Added support for [MSC4144] per-message profile passthrough in + relay mode. +* *(bridgev2)* Added option to auto-reconnect logins after a certain period if + they hit an `UNKNOWN_ERROR` state. +* *(bridgev2)* Added analytics for event handler panics. +* *(bridgev2)* Changed new room creation to hardcode room v11 to avoid v12 rooms + being created before proper support for them can be added. +* *(bridgev2)* Changed queuing events to block instead of dropping events if the + buffer is full. +* *(bridgev2)* Fixed assumption that replies to unknown messages are cross-room. +* *(id)* Fixed server name validation not including ports correctly + (thanks to [@krombel] in [#392]). +* *(federation)* Fixed base64 algorithm in signature generation. +* *(event)* Fixed [MSC4144] fallbacks not being removed from edits. + +[@krombel]: https://github.com/krombel +[#392]: https://github.com/mautrix/go/pull/392 + +## v0.24.1 (2025-06-16) + +* *(commands)* Added framework for using reactions as buttons that execute + command handlers. +* *(client)* Added wrapper for `/relations` endpoints. +* *(client)* Added support for stable version of room summary endpoint. +* *(client)* Fixed parsing URL preview responses where width/height are strings. +* *(federation)* Fixed bugs in server auth. +* *(id)* Added utilities for validating server names. +* *(event)* Fixed incorrect empty `entity` field when sending hashed moderation + policy events. +* *(event)* Added [MSC4293] redact events field to member events. +* *(event)* Added support for fallbacks in [MSC4144] per-message profiles. +* *(format)* Added `MarkdownLink` and `MarkdownMention` utility functions for + generating properly escaped markdown. +* *(synapseadmin)* Added support for synchronous (v1) room delete endpoint. +* *(synapseadmin)* Changed `Client` struct to not embed the `mautrix.Client`. + This is a breaking change if you were relying on accessing non-admin functions + from the admin client. +* *(bridgev2/provisioning)* Fixed `/display_and_wait` not passing through errors + from the network connector properly. +* *(bridgev2/crypto)* Fixed encryption not working if the user's ID had the same + prefix as the bridge ghosts (e.g. `@whatsappbridgeuser:example.com` with a + `@whatsapp_` prefix). +* *(bridgev2)* Fixed portals not being saved after creating a DM portal from a + Matrix DM invite. +* *(bridgev2)* Added config option to determine whether cross-room replies + should be bridged. +* *(appservice)* Fixed `EnsureRegistered` not being called when sending a custom + member event for the controlled user. + +[MSC4293]: https://github.com/matrix-org/matrix-spec-proposals/pull/4293 + +## v0.24.0 (2025-05-16) + +* *(commands)* Added generic framework for implementing bot commands. +* *(client)* Added support for specifying maximum number of HTTP retries using + a context value instead of having to call `MakeFullRequest` manually. +* *(client,federation)* Added methods for fetching room directories. +* *(federation)* Added support for server side of request authentication. +* *(synapseadmin)* Added wrapper for the account suspension endpoint. +* *(format)* Added method for safely wrapping a string in markdown inline code. +* *(crypto)* Added method to import key backup without persisting to database, + to allow the client more control over the process. +* *(bridgev2)* Added viewing chat interface to signal when the user is viewing + a given chat. +* *(bridgev2)* Added option to pass through transaction ID from client when + sending messages to remote network. +* *(crypto)* Fixed unnecessary error log when decrypting dummy events used for + unwedging Olm sessions. +* *(crypto)* Fixed `forwarding_curve25519_key_chain` not being set consistently + when backing up keys. +* *(event)* Fixed marshaling legacy VoIP events with no version field. +* *(bridgev2)* Fixed disappearing message references not being deleted when the + portal is deleted. +* *(bridgev2)* Fixed read receipt bridging not ignoring fake message entries + and causing unnecessary error logs. + +## v0.23.3 (2025-04-16) + +* *(commands)* Added generic command processing framework for bots. +* *(client)* Added `allowed_room_ids` field to room summary responses + (thanks to [@timedoutuk] in [#367]). +* *(bridgev2)* Added support for custom timeouts on outgoing messages which have + to wait for a remote echo. +* *(bridgev2)* Added automatic typing stop event if the ghost user had sent a + typing event before a message. +* *(bridgev2)* The saved management room is now cleared if the user leaves the + room, allowing the next DM to be automatically marked as a management room. +* *(bridge)* Removed deprecated fallback package for bridge statuses. + The status package is now only available under bridgev2. + +[#367]: https://github.com/mautrix/go/pull/367 + +## v0.23.2 (2025-03-16) + +* **Breaking change *(bridge)*** Removed legacy bridge module. +* **Breaking change *(event)*** Changed `m.federate` field in room create event + content to a pointer to allow detecting omitted values. +* *(bridgev2/commands)* Added `set-management-room` command to set a new + management room. +* *(bridgev2/portal)* Changed edit bridging to ignore remote edits if the + original sender on Matrix can't be puppeted. +* *(bridgv2)* Added config option to disable bridging `m.notice` messages. +* *(appservice/http)* Switched access token validation to use constant time + comparisons. +* *(event)* Added support for [MSC3765] rich text topics. +* *(event)* Added fields to policy list event contents for [MSC4204] and + [MSC4205]. +* *(client)* Added method for getting the content of a redacted event using + [MSC2815]. +* *(client)* Added methods for sending and updating [MSC4140] delayed events. +* *(client)* Added support for [MSC4222] in sync payloads. +* *(crypto/cryptohelper)* Switched to using `sqlite3-fk-wal` instead of plain + `sqlite3` by default. +* *(crypto/encryptolm)* Added generic method for encrypting to-device events. +* *(crypto/ssss)* Fixed panic if server-side key metadata is corrupted. +* *(crypto/sqlstore)* Fixed error when marking over 32 thousand device lists + as outdated on SQLite. + +[MSC2815]: https://github.com/matrix-org/matrix-spec-proposals/pull/2815 +[MSC3765]: https://github.com/matrix-org/matrix-spec-proposals/pull/3765 +[MSC4140]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140 +[MSC4204]: https://github.com/matrix-org/matrix-spec-proposals/pull/4204 +[MSC4205]: https://github.com/matrix-org/matrix-spec-proposals/pull/4205 +[MSC4222]: https://github.com/matrix-org/matrix-spec-proposals/pull/4222 + +## v0.23.1 (2025-02-16) + +* *(client)* Added `FullStateEvent` method to get a state event including + metadata (using the `?format=event` query parameter). +* *(client)* Added wrapper method for [MSC4194]'s redact endpoint. +* *(pushrules)* Fixed content rules not considering word boundaries and being + case-sensitive. +* *(crypto)* Fixed bugs that would cause key exports to fail for no reason. +* *(crypto)* Deprecated `ResolveTrust` in favor of `ResolveTrustContext`. +* *(crypto)* Stopped accepting secret shares from unverified devices. +* **Breaking change *(crypto)*** Changed `GetAndVerifyLatestKeyBackupVersion` + to take an optional private key parameter. The method will now trust the + public key if it matches the provided private key even if there are no valid + signatures. +* **Breaking change *(crypto)*** Added context parameter to `IsDeviceTrusted`. + +[MSC4194]: https://github.com/matrix-org/matrix-spec-proposals/pull/4194 + +## v0.23.0 (2025-01-16) + +* **Breaking change *(client)*** Changed `JoinRoom` parameters to allow multiple + `via`s. +* **Breaking change *(bridgev2)*** Updated capability system. + * The return type of `NetworkAPI.GetCapabilities` is now different. + * Media type capabilities are enforced automatically by bridgev2. + * Capabilities are now sent to Matrix rooms using the + `com.beeper.room_features` state event. +* *(client)* Added `GetRoomSummary` to implement [MSC3266]. +* *(client)* Added support for arbitrary profile fields to implement [MSC4133] + (thanks to [@timedoutuk] in [#337]). +* *(crypto)* Started storing olm message hashes to prevent decryption errors + if messages are repeated (e.g. if the app crashes right after decrypting). +* *(crypto)* Improved olm session unwedging to check when the last session was + created instead of only relying on an in-memory map. +* *(crypto/verificationhelper)* Fixed emoji verification not doing cross-signing + properly after a successful verification. +* *(bridgev2/config)* Moved MSC4190 flag from `appservice` to `encryption`. +* *(bridgev2/space)* Fixed failing to add rooms to spaces if the room create + call was made with a temporary context. +* *(bridgev2/commands)* Changed `help` command to hide commands which require + interfaces that aren't implemented by the network connector. +* *(bridgev2/matrixinterface)* Moved deterministic room ID generation to Matrix + connector. +* *(bridgev2)* Fixed service member state event not being set correctly when + creating a DM by inviting a ghost user. +* *(bridgev2)* Fixed `RemoteReactionSync` events replacing all reactions every + time instead of only changed ones. + +[MSC3266]: https://github.com/matrix-org/matrix-spec-proposals/pull/3266 +[MSC4133]: https://github.com/matrix-org/matrix-spec-proposals/pull/4133 +[@timedoutuk]: https://github.com/timedoutuk +[#337]: https://github.com/mautrix/go/pull/337 + +## v0.22.1 (2024-12-16) + +* *(crypto)* Added automatic cleanup when there are too many olm sessions with + a single device. +* *(crypto)* Added helper for getting cached device list with cross-signing + status. +* *(crypto/verificationhelper)* Added interface for persisting the state of + in-progress verifications. +* *(client)* Added `GetMutualRooms` wrapper for [MSC2666]. +* *(client)* Switched `JoinRoom` to use the `via` query param instead of + `server_name` as per [MSC4156]. +* *(bridgev2/commands)* Fixed `pm` command not actually starting the chat. +* *(bridgev2/interface)* Added separate network API interface for starting + chats with a Matrix ghost user. This allows treating internal user IDs + differently than arbitrary user-input strings. +* *(bridgev2/crypto)* Added support for [MSC4190] + (thanks to [@onestacked] in [#288]). + +[MSC2666]: https://github.com/matrix-org/matrix-spec-proposals/pull/2666 +[MSC4156]: https://github.com/matrix-org/matrix-spec-proposals/pull/4156 +[MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190 +[#288]: https://github.com/mautrix/go/pull/288 +[@onestacked]: https://github.com/onestacked + +## v0.22.0 (2024-11-16) + +* *(hicli)* Moved package into gomuks repo. +* *(bridgev2/commands)* Fixed cookie unescaping in login commands. +* *(bridgev2/portal)* Added special `DefaultChatName` constant to explicitly + reset portal names to the default (based on members). +* *(bridgev2/config)* Added options to disable room tag bridging. +* *(bridgev2/database)* Fixed reaction queries not including portal receiver. +* *(appservice)* Updated [MSC2409] stable registration field name from + `push_ephemeral` to `receive_ephemeral`. Homeserver admins must update + existing registrations manually. +* *(format)* Added support for `img` tags. +* *(format/mdext)* Added goldmark extensions for Matrix math and custom emojis. +* *(event/reply)* Removed support for generating reply fallbacks ([MSC2781]). +* *(pushrules)* Added support for `sender_notification_permission` condition + kind (used for `@room` mentions). +* *(crypto)* Added support for `json.RawMessage` in `EncryptMegolmEvent`. +* *(mediaproxy)* Added `GetMediaResponseCallback` and `GetMediaResponseFile` + to write proxied data directly to http response or temp file instead of + having to use an `io.Reader`. +* *(mediaproxy)* Dropped support for legacy media download endpoints. +* *(mediaproxy,bridgev2)* Made interface pass through query parameters. + +[MSC2781]: https://github.com/matrix-org/matrix-spec-proposals/pull/2781 + +## v0.21.1 (2024-10-16) + +* *(bridgev2)* Added more features and fixed bugs. +* *(hicli)* Added more features and fixed bugs. +* *(appservice)* Removed TLS support. A reverse proxy should be used if TLS + is needed. +* *(format/mdext)* Added goldmark extension to fix indented paragraphs when + disabling indented code block parser. +* *(event)* Added `Has` method for `Mentions`. +* *(event)* Added basic support for the unstable version of polls. + +## v0.21.0 (2024-09-16) + +* **Breaking change *(client)*** Dropped support for unauthenticated media. + Matrix v1.11 support is now required from the homeserver, although it's not + enforced using `/versions` as some servers don't advertise it. +* *(bridgev2)* Added more features and fixed bugs. +* *(appservice,crypto)* Added support for using MSC3202 for appservice + encryption. +* *(crypto/olm)* Made everything into an interface to allow side-by-side + testing of libolm and goolm, as well as potentially support vodozemac + in the future. +* *(client)* Fixed requests being retried even after context is canceled. +* *(client)* Added option to move `/sync` request logs to trace level. +* *(error)* Added `Write` and `WithMessage` helpers to `RespError` to make it + easier to use on servers. +* *(event)* Fixed `org.matrix.msc1767.audio` field allowing omitting the + duration and waveform. +* *(id)* Changed `MatrixURI` methods to not panic if the receiver is nil. +* *(federation)* Added limit to response size when fetching `.well-known` files. + +## v0.20.0 (2024-08-16) + +* Bumped minimum Go version to 1.22. +* *(bridgev2)* Added more features and fixed bugs. +* *(event)* Added types for [MSC4144]: Per-message profiles. +* *(federation)* Added implementation of server name resolution and a basic + client for making federation requests. +* *(crypto/ssss)* Changed recovery key/passphrase verify functions to take the + key ID as a parameter to ensure it's correctly set even if the key metadata + wasn't fetched via `GetKeyData`. +* *(format/mdext)* Added goldmark extensions for single-character bold, italic + and strikethrough parsing (as in `*foo*` -> **foo**, `_foo_` -> _foo_ and + `~foo~` -> ~~foo~~) +* *(format)* Changed `RenderMarkdown` et al to always include `m.mentions` in + returned content. The mention list is filled with matrix.to URLs from the + input by default. + +[MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144 + +## v0.19.0 (2024-07-16) + +* Renamed `master` branch to `main`. +* *(bridgev2)* Added more features. +* *(crypto)* Fixed bug with copying `m.relates_to` from wire content to + decrypted content. +* *(mediaproxy)* Added module for implementing simple media repos that proxy + requests elsewhere. +* *(client)* Changed `Members()` to automatically parse event content for all + returned events. +* *(bridge)* Added `/register` call if `/versions` fails with `M_FORBIDDEN`. +* *(crypto)* Fixed `DecryptMegolmEvent` sometimes calling database without + transaction by using the non-context version of `ResolveTrust`. +* *(crypto/attachment)* Implemented `io.Seeker` in `EncryptStream` to allow + using it in retriable HTTP requests. +* *(event)* Added helper method to add user ID to a `Mentions` object. +* *(event)* Fixed default power level for invites + (thanks to [@rudis] in [#250]). +* *(client)* Fixed incorrect warning log in `State()` when state store returns + no error (thanks to [@rudis] in [#249]). +* *(crypto/verificationhelper)* Fixed deadlock when ignoring unknown + cancellation events (thanks to [@rudis] in [#247]). + +[@rudis]: https://github.com/rudis +[#250]: https://github.com/mautrix/go/pull/250 +[#249]: https://github.com/mautrix/go/pull/249 +[#247]: https://github.com/mautrix/go/pull/247 + +### beta.1 (2024-06-16) + +* *(bridgev2)* Added experimental high-level bridge framework. +* *(hicli)* Added experimental high-level client framework. +* **Slightly breaking changes** + * *(crypto)* Added room ID and first known index parameters to + `SessionReceived` callback. + * *(crypto)* Changed `ImportRoomKeyFromBackup` to return the imported + session. + * *(client)* Added `error` parameter to `ResponseHook`. + * *(client)* Changed `Download` to return entire response instead of just an + `io.Reader`. +* *(crypto)* Changed initial olm device sharing to save keys before sharing to + ensure keys aren't accidentally regenerated in case the request fails. +* *(crypto)* Changed `EncryptMegolmEvent` and `ShareGroupSession` to return + more errors instead of only logging and ignoring them. +* *(crypto)* Added option to completely disable megolm ratchet tracking. + * The tracking is meant for bots and bridges which may want to delete old + keys, but for normal clients it's just unnecessary overhead. +* *(crypto)* Changed Megolm session storage methods in `Store` to not take + sender key as parameter. + * This causes a breaking change to the layout of the `MemoryStore` struct. + Using MemoryStore in production is not recommended. +* *(crypto)* Changed `DecryptMegolmEvent` to copy `m.relates_to` in the raw + content too instead of only in the parsed struct. +* *(crypto)* Exported function to parse megolm message index from raw + ciphertext bytes. +* *(crypto/sqlstore)* Fixed schema of `crypto_secrets` table to include + account ID. +* *(crypto/verificationhelper)* Fixed more bugs. +* *(client)* Added `UpdateRequestOnRetry` hook which is called immediately + before retrying a normal HTTP request. +* *(client)* Added support for MSC3916 media download endpoint. + * Support is automatically detected from spec versions. The `SpecVersions` + property can either be filled manually, or `Versions` can be called to + automatically populate the field with the response. +* *(event)* Added constants for known room versions. + +## v0.18.1 (2024-04-16) + +* *(format)* Added a `context.Context` field to HTMLParser's Context struct. +* *(bridge)* Added support for handling join rules, knocks, invites and bans + (thanks to [@maltee1] in [#193] and [#204]). +* *(crypto)* Changed forwarded room key handling to only accept keys with a + lower first known index than the existing session if there is one. +* *(crypto)* Changed key backup restore to assume own device list is up to date + to avoid re-requesting device list for every deleted device that has signed + key backup. +* *(crypto)* Fixed memory cache not being invalidated when storing own + cross-signing keys + +[@maltee1]: https://github.com/maltee1 +[#193]: https://github.com/mautrix/go/pull/193 +[#204]: https://github.com/mautrix/go/pull/204 + +## v0.18.0 (2024-03-16) + +* **Breaking change *(client, bridge, appservice)*** Dropped support for + maulogger. Only zerolog loggers are now provided by default. +* *(bridge)* Fixed upload size limit not having a default if the server + returned no value. +* *(synapseadmin)* Added wrappers for some room and user admin APIs. + (thanks to [@grvn-ht] in [#181]). +* *(crypto/verificationhelper)* Fixed bugs. +* *(crypto)* Fixed key backup uploading doing too much base64. +* *(crypto)* Changed `EncryptMegolmEvent` to return an error if persisting the + megolm session fails. This ensures that database errors won't cause messages + to be sent with duplicate indexes. +* *(crypto)* Changed `GetOrRequestSecret` to use a callback instead of returning + the value directly. This allows validating the value in order to ignore + invalid secrets. +* *(id)* Added `ParseCommonIdentifier` function to parse any Matrix identifier + in the [Common Identifier Format]. +* *(federation)* Added simple key server that passes the federation tester. + +[@grvn-ht]: https://github.com/grvn-ht +[#181]: https://github.com/mautrix/go/pull/181 +[Common Identifier Format]: https://spec.matrix.org/v1.9/appendices/#common-identifier-format + +### beta.1 (2024-02-16) + +* Bumped minimum Go version to 1.21. +* *(bridge)* Bumped minimum Matrix spec version to v1.4. +* **Breaking change *(crypto)*** Deleted old half-broken interactive + verification code and replaced it with a new `verificationhelper`. + * The new verification helper is still experimental. + * Both QR and emoji verification are supported (in theory). +* *(crypto)* Added support for server-side key backup. +* *(crypto)* Added support for receiving and sending secrets like cross-signing + private keys via secret sharing. +* *(crypto)* Added support for tracking which devices megolm sessions were + initially shared to, and allowing re-sharing the keys to those sessions. +* *(client)* Changed cross-signing key upload method to accept a callback for + user-interactive auth instead of only hardcoding password support. +* *(appservice)* Dropped support for legacy non-prefixed appservice paths + (e.g. `/transactions` instead of `/_matrix/app/v1/transactions`). +* *(appservice)* Dropped support for legacy `access_token` authorization in + appservice endpoints. +* *(bridge)* Fixed `RawArgs` field in command events of command state callbacks. +* *(appservice)* Added `CreateFull` helper function for creating an `AppService` + instance with all the mandatory fields set. + +## v0.17.0 (2024-01-16) + +* **Breaking change *(bridge)*** Added raw event to portal membership handling + functions. +* **Breaking change *(everything)*** Added context parameters to all functions + (started by [@recht] in [#144]). +* **Breaking change *(client)*** Moved event source from sync event handler + function parameters to the `Mautrix.EventSource` field inside the event + struct. +* **Breaking change *(client)*** Moved `EventSource` to `event.Source`. +* *(client)* Removed deprecated `OldEventIgnorer`. The non-deprecated version + (`Client.DontProcessOldEvents`) is still available. +* *(crypto)* Added experimental pure Go Olm implementation to replace libolm + (thanks to [@DerLukas15] in [#106]). + * You can use the `goolm` build tag to the new implementation. +* *(bridge)* Added context parameter for bridge command events. +* *(bridge)* Added method to allow custom validation for the entire config. +* *(client)* Changed default syncer to not drop unknown events. + * The syncer will still drop known events if parsing the content fails. + * The behavior can be changed by changing the `ParseErrorHandler` function. +* *(crypto)* Fixed some places using math/rand instead of crypto/rand. + +[@DerLukas15]: https://github.com/DerLukas15 +[@recht]: https://github.com/recht +[#106]: https://github.com/mautrix/go/pull/106 +[#144]: https://github.com/mautrix/go/pull/144 + +## v0.16.2 (2023-11-16) + +* *(event)* Added `Redacts` field to `RedactionEventContent` for room v11+. +* *(event)* Added `ReverseTextToHTML` which reverses the changes made by + `TextToHTML` (i.e. unescapes HTML characters and replaces `
` with `\n`). +* *(bridge)* Added global zerologger to ensure all logs go through the bridge + logger. +* *(bridge)* Changed encryption error messages to be sent in a thread if the + message that failed to decrypt was in a thread. + +## v0.16.1 (2023-09-16) + +* **Breaking change *(id)*** Updated user ID localpart encoding to not encode + `+` as per [MSC4009]. +* *(bridge)* Added bridge utility to handle double puppeting logins. + * The utility supports automatic logins with all three current methods + (shared secret, legacy appservice, new appservice). +* *(appservice)* Added warning logs and timeout on appservice event handling. + * Defaults to warning after 30 seconds and timeout 15 minutes after that. + * Timeouts can be adjusted or disabled by setting `ExecSync` variables in the + `EventProcessor`. +* *(crypto/olm)* Added `PkDecryption` wrapper. + +[MSC4009]: https://github.com/matrix-org/matrix-spec-proposals/pull/4009 + +## v0.16.0 (2023-08-16) + +* Bumped minimum Go version to 1.20. +* **Breaking change *(util)*** Moved package to [go.mau.fi/util](https://go.mau.fi/util/) +* *(event)* Removed MSC2716 `historical` field in the `m.room.power_levels` + event content struct. +* *(bridge)* Added `--version-json` flag to print bridge version info as JSON. +* *(appservice)* Added option to use custom transaction handler for websocket mode. + +## v0.15.4 (2023-07-16) + +* *(client)* Deprecated MSC2716 methods and added new Beeper-specific batch + send methods, as upstream MSC2716 support has been abandoned. +* *(client)* Added proper error handling and automatic retries to media + downloads. +* *(crypto, bridge)* Added option to remove all keys that were received before + the automatic ratcheting was implemented (in v0.15.1). +* *(dbutil)* Added `JSON` utility for writing/reading arbitrary JSON objects to + the db conveniently without manually de/serializing. + +## v0.15.3 (2023-06-16) + +* *(synapseadmin)* Added wrappers for some Synapse admin API endpoints. +* *(pushrules)* Implemented new `event_property_is` and `event_property_contains` + push rule condition kinds as per MSC3758 and MSC3966. +* *(bridge)* Moved websocket code from mautrix-imessage to enable all bridges + to use appservice websockets easily. +* *(bridge)* Added retrying for appservice pings. +* *(types)* Removed unstable field for MSC3952 (intentional mentions). +* *(client)* Deprecated `OldEventIgnorer` and added `Client.DontProcessOldEvents` + to replace it. +* *(client)* Added `MoveInviteState` sync handler for moving state events in + the invite section of sync inside the invite event itself. +* *(crypto)* Added option to not rotate keys when devices change. +* *(crypto)* Added additional duplicate message index check if decryption fails + because the keys had been ratcheted forward. +* *(client)* Stabilized support for asynchronous uploads. + * `UnstableCreateMXC` and `UnstableUploadAsync` were renamed to `CreateMXC` + and `UploadAsync` respectively. +* *(util/dbutil)* Added option to use a separate database connection pool for + read-only transactions. + * This is mostly meant for SQLite and it enables read-only transactions that + don't lock the database, even when normal transactions are configured to + acquire a write lock immediately. +* *(util/dbutil)* Enabled caller info in zerolog by default. + +## v0.15.2 (2023-05-16) + +* *(client)* Changed member-fetching methods to clear existing member info in + state store. +* *(client)* Added support for inserting mautrix-go commit hash into default + user agent at compile time. +* *(bridge)* Fixed bridge bot intent not having state store set. +* *(client)* Fixed `RespError` marshaling mutating the `ExtraData` map and + potentially causing panics. +* *(util/dbutil)* Added `DoTxn` method for an easier way to manage database + transactions. +* *(util)* Added a zerolog `CallerMarshalFunc` implementation that includes the + function name. +* *(bridge)* Added error reply to encrypted messages if the bridge isn't + configured to do encryption. + +## v0.15.1 (2023-04-16) + +* *(crypto, bridge)* Added options to automatically ratchet/delete megolm + sessions to minimize access to old messages. +* *(pushrules)* Added method to get entire push rule that matched (instead of + only the list of actions). +* *(pushrules)* Deprecated `NotifySpecified` as there's no reason to read it. +* *(crypto)* Changed `max_age` column in `crypto_megolm_inbound_session` table + to be milliseconds instead of nanoseconds. +* *(util)* Added method for iterating `RingBuffer`. +* *(crypto/cryptohelper)* Changed decryption errors to request session from all + own devices in addition to the sender, instead of only asking the sender. +* *(sqlstatestore)* Fixed `FindSharedRooms` throwing an error when using from + a non-bridge context. +* *(client)* Optimized `AccountDataSyncStore` to not resend save requests if + the sync token didn't change. +* *(types)* Added `Clone()` method for `PowerLevelEventContent`. + +## v0.15.0 (2023-03-16) + +### beta.3 (2023-03-15) + +* **Breaking change *(appservice)*** Removed `Load()` and `AppService.Init()` + functions. The struct should just be created with `Create()` and the relevant + fields should be filled manually. +* **Breaking change *(appservice)*** Removed public `HomeserverURL` field and + replaced it with a `SetHomeserverURL` method. +* *(appservice)* Added support for unix sockets for homeserver URL and + appservice HTTP server. +* *(client)* Changed request logging to log durations as floats instead of + strings (using zerolog's `Dur()`, so the exact output can be configured). +* *(bridge)* Changed zerolog to use nanosecond precision timestamps. +* *(crypto)* Added message index to log after encrypting/decrypting megolm + events, and when failing to decrypt due to duplicate index. +* *(sqlstatestore)* Fixed warning log for rooms that don't have encryption + enabled. + +### beta.2 (2023-03-02) + +* *(bridge)* Fixed building with `nocrypto` tag. +* *(bridge)* Fixed legacy logging config migration not disabling file writer + when `file_name_format` was empty. +* *(bridge)* Added option to require room power level to run commands. +* *(event)* Added structs for [MSC3952]: Intentional Mentions. +* *(util/variationselector)* Added `FullyQualify` method to add necessary emoji + variation selectors without adding all possible ones. + +[MSC3952]: https://github.com/matrix-org/matrix-spec-proposals/pull/3952 + +### beta.1 (2023-02-24) + +* Bumped minimum Go version to 1.19. +* **Breaking changes** + * *(all)* Switched to zerolog for logging. + * The `Client` and `Bridge` structs still include a legacy logger for + backwards compatibility. + * *(client, appservice)* Moved `SQLStateStore` from appservice module to the + top-level (client) module. + * *(client, appservice)* Removed unused `Typing` map in `SQLStateStore`. + * *(client)* Removed unused `SaveRoom` and `LoadRoom` methods in `Storer`. + * *(client, appservice)* Removed deprecated `SendVideo` and `SendImage` methods. + * *(client)* Replaced `AppServiceUserID` field with `SetAppServiceUserID` boolean. + The `UserID` field is used as the value for the query param. + * *(crypto)* Renamed `GobStore` to `MemoryStore` and removed the file saving + features. The data can still be persisted, but the persistence part must be + implemented separately. + * *(crypto)* Removed deprecated `DeviceIdentity` alias + (renamed to `id.Device` long ago). + * *(client)* Removed `Stringifable` interface as it's the same as `fmt.Stringer`. +* *(client)* Renamed `Storer` interface to `SyncStore`. A type alias exists for + backwards-compatibility. +* *(crypto/cryptohelper)* Added package for a simplified crypto interface for clients. +* *(example)* Added e2ee support to example using crypto helper. +* *(client)* Changed default syncer to stop syncing on `M_UNKNOWN_TOKEN` errors. + +## v0.14.0 (2023-02-16) + +* **Breaking change *(format)*** Refactored the HTML parser `Context` to have + more data. +* *(id)* Fixed escaping path components when forming matrix.to URLs + or `matrix:` URIs. +* *(bridge)* Bumped default timeouts for decrypting incoming messages. +* *(bridge)* Added `RawArgs` to commands to allow accessing non-split input. +* *(bridge)* Added `ReplyAdvanced` to commands to allow setting markdown + settings. +* *(event)* Added `notifications` key to `PowerLevelEventContent`. +* *(event)* Changed `SetEdit` to cut off edit fallback if the message is long. +* *(util)* Added `SyncMap` as a simple generic wrapper for a map with a mutex. +* *(util)* Added `ReturnableOnce` as a wrapper for `sync.Once` with a return + value. + +## v0.13.0 (2023-01-16) + +* **Breaking change:** Removed `IsTyping` and `SetTyping` in `appservice.StateStore` + and removed the `TypingStateStore` struct implementing those methods. +* **Breaking change:** Removed legacy fields in Beeper MSS events. +* Added knocked rooms to sync response structs. +* Added wrapper for `/timestamp_to_event` endpoint added in Matrix v1.6. +* Fixed MSC3870 uploads not failing properly after using up the max retry count. +* Fixed parsing non-positive ordered list start positions in HTML parser. + +## v0.12.4 (2022-12-16) + +* Added `SendReceipt` to support private read receipts and thread receipts in + the same function. `MarkReadWithContent` is now deprecated. +* Changed media download methods to return errors if the server returns a + non-2xx status code. +* Removed legacy `sql_store_upgrade.Upgrade` method. Using `store.DB.Upgrade()` + after `NewSQLCryptoStore(...)` is recommended instead (the bridge module does + this automatically). +* Added missing `suggested` field to `m.space.child` content struct. +* Added `device_unused_fallback_key_types` to `/sync` response and appservice + transaction structs. +* Changed `ReqSetReadMarkers` to omit empty fields. +* Changed bridge configs to force `sqlite3-fk-wal` instead of `sqlite3`. +* Updated bridge helper to close database connection when stopping. +* Fixed read receipt and account data endpoints sending `null` instead of an + empty object as the body when content isn't provided. + +## v0.12.3 (2022-11-16) + +* **Breaking change:** Added logging for row iteration in the dbutil package. + This changes the return type of `Query` methods from `*sql.Rows` to a new + `dbutil.Rows` interface. +* Added flag to disable wrapping database upgrades in a transaction (e.g. to + allow setting `PRAGMA`s for advanced table mutations on SQLite). +* Deprecated `MessageEventContent.GetReplyTo` in favor of directly using + `RelatesTo.GetReplyTo`. RelatesTo methods are nil-safe, so checking if + RelatesTo is nil is not necessary for using those methods. +* Added wrapper for space hierarchyendpoint (thanks to [@mgcm] in [#100]). +* Added bridge config option to handle transactions asynchronously. +* Added separate channels for to-device events in appservice transaction + handler to avoid blocking to-device events behind normal events. +* Added `RelatesTo.GetNonFallbackReplyTo` utility method to get the reply event + ID, unless the reply is a thread fallback. +* Added `event.TextToHTML` as an utility method to HTML-escape a string and + replace newlines with `
`. +* Added check to bridge encryption helper to make sure the e2ee keys are still + on the server. Synapse is known to sometimes lose keys randomly. +* Changed bridge crypto syncer to crash on `M_UNKNOWN_TOKEN` errors instead of + retrying forever pointlessly. +* Fixed verifying signatures of fallback one-time keys. + +[@mgcm]: https://github.com/mgcm +[#100]: https://github.com/mautrix/go/pull/100 + +## v0.12.2 (2022-10-16) + +* Added utility method to redact bridge commands. +* Added thread ID field to read receipts to match Matrix v1.4 changes. +* Added automatic fetching of media repo config at bridge startup to make it + easier for bridges to check homeserver media size limits. +* Added wrapper for the `/register/available` endpoint. +* Added custom user agent to all requests mautrix-go makes. The value can be + customized by changing the `DefaultUserAgent` variable. +* Implemented [MSC3664], [MSC3862] and [MSC3873] in the push rule evaluator. +* Added workaround for potential race conditions in OTK uploads when using + appservice encryption ([MSC3202]). +* Fixed generating registrations to use `.+` instead of `[0-9]+` in the + username regex. +* Fixed panic in megolm session listing methods if the store contains withheld + key entries. +* Fixed missing header in bridge command help messages. + +[MSC3664]: https://github.com/matrix-org/matrix-spec-proposals/pull/3664 +[MSC3862]: https://github.com/matrix-org/matrix-spec-proposals/pull/3862 +[MSC3873]: https://github.com/matrix-org/matrix-spec-proposals/pull/3873 + +## v0.12.1 (2022-09-16) + +* Bumped minimum Go version to 1.18. +* Added `omitempty` for a bunch of fields in response structs to make them more + usable for server implementations. +* Added `util.RandomToken` to generate GitHub-style access tokens with checksums. +* Added utilities to call the push gateway API. +* Added `unread_notifications` and [MSC2654] `unread_count` fields to /sync + response structs. +* Implemented [MSC3870] for uploading and downloading media directly to/from an + external media storage like S3. +* Fixed dbutil database ownership checks on SQLite. +* Fixed typo in unauthorized encryption key withheld code + (`m.unauthorized` -> `m.unauthorised`). +* Fixed [MSC2409] support to have a separate field for to-device events. + +[MSC2654]: https://github.com/matrix-org/matrix-spec-proposals/pull/2654 +[MSC3870]: https://github.com/matrix-org/matrix-spec-proposals/pull/3870 + +## v0.12.0 (2022-08-16) + +* **Breaking change:** Switched `Client.UserTyping` to take a `time.Duration` + instead of raw `int64` milliseconds. +* **Breaking change:** Removed custom reply relation type and switched to using + the wire format (nesting in `m.in_reply_to`). +* Added device ID to appservice OTK count map to match updated [MSC3202]. + This is also a breaking change, but the previous incorrect behavior wasn't + implemented by anything other than mautrix-syncproxy/imessage. +* (There are probably other breaking changes too). +* Added database utility and schema upgrade framework + * Originally from mautrix-whatsapp, but usable for non-bridges too + * Includes connection wrapper to log query durations and mutate queries for + SQLite compatibility (replacing `$x` with `?x`). +* Added bridge utilities similar to mautrix-python. Currently includes: + * Crypto helper + * Startup flow + * Command handling and some standard commands + * Double puppeting things + * Generic parts of config, basic config validation + * Appservice SQL state store +* Added alternative markdown spoiler parsing extension that doesn't support + reasons, but works better otherwise. +* Added Discord underline markdown parsing extension (`_foo_` -> foo). +* Added support for parsing spoilers and color tags in the HTML parser. +* Added support for mutating plain text nodes in the HTML parser. +* Added room version field to the create room request struct. +* Added empty JSON object as default request body for all non-GET requests. +* Added wrapper for `/capabilities` endpoint. +* Added `omitempty` markers for lots of structs to make the structs easier to + use on the server side too. +* Added support for registering to-device event handlers via the default + Syncer's `OnEvent` and `OnEventType` methods. +* Fixed `CreateEventContent` using the wrong field name for the room version + field. +* Fixed `StopSync` not immediately cancelling the sync loop if it was sleeping + after a failed sync. +* Fixed `GetAvatarURL` always returning the current user's avatar instead of + the specified user's avatar (thanks to [@nightmared] in [#83]). +* Improved request logging and added new log when a request finishes. +* Crypto store improvements: + * Deleted devices are now kept in the database. + * Made ValidateMessageIndex atomic. +* Moved `appservice.RandomString` to the `util` package and made it use + `crypto/rand` instead of `math/rand`. +* Significantly improved cross-signing validation code. + * There are now more options for required trust levels, + e.g. you can set `SendKeysMinTrust` to `id.TrustStateCrossSignedTOFU` + to trust the first cross-signing master key seen and require all devices + to be signed by that key. + * Trust state of incoming messages is automatically resolved and stored in + `evt.Mautrix.TrustState`. This can be used to reject incoming messages from + untrusted devices. + +[@nightmared]: https://github.com/nightmared +[#83]: https://github.com/mautrix/go/pull/83 + +## v0.11.1 (2023-01-15) + +* Fixed parsing non-positive ordered list start positions in HTML parser + (backport of the same fix in v0.13.0). + +## v0.11.0 (2022-05-16) + +* Bumped minimum Go version to 1.17. +* Switched from `/r0` to `/v3` paths everywhere. + * The new `v3` paths are implemented since Synapse 1.48, Dendrite 0.6.5, and + Conduit 0.4.0. Servers older than these are no longer supported. +* Switched from blackfriday to goldmark for markdown parsing in the `format` + module and added spoiler syntax. +* Added `EncryptInPlace` and `DecryptInPlace` methods for attachment encryption. + In most cases the plain/ciphertext is not necessary after en/decryption, so + the old `Encrypt` and `Decrypt` are deprecated. +* Added wrapper for `/rooms/.../aliases`. +* Added utility for adding/removing emoji variation selectors to match + recommendations on reactions in Matrix. +* Added support for async media uploads ([MSC2246]). +* Added automatic sleep when receiving 429 error + (thanks to [@ownaginatious] in [#44]). +* Added support for parsing spec version numbers from the `/versions` endpoint. +* Removed unstable prefixed constant used for appservice login. +* Fixed URL encoding not working correctly in some cases. + +[MSC2246]: https://github.com/matrix-org/matrix-spec-proposals/pull/2246 +[@ownaginatious]: https://github.com/ownaginatious +[#44]: https://github.com/mautrix/go/pull/44 + +## v0.10.12 (2022-03-16) + +* Added option to use a different `Client` to send invites in + `IntentAPI.EnsureJoined`. +* Changed `MessageEventContent` struct to omit empty `msgtype`s in the output + JSON, as sticker events shouldn't have that field. +* Fixed deserializing the `thumbnail_file` field in `FileInfo`. +* Fixed bug that broke `SQLCryptoStore.FindDeviceByKey`. + +## v0.10.11 (2022-02-16) + +* Added automatic updating of state store from `IntentAPI` calls. +* Added option to ignore cache in `IntentAPI.EnsureJoined`. +* Added `GetURLPreview` as a wrapper for the `/preview_url` media repo endpoint. +* Moved base58 module inline to avoid pulling in btcd as a dependency. + +## v0.10.10 (2022-01-16) + +* Added event types and content structs for server ACLs and moderation policy + lists (thanks to [@qua3k] in [#59] and [#60]). +* Added optional parameter to `Client.LeaveRoom` to pass a `reason` field. + +[#59]: https://github.com/mautrix/go/pull/59 +[#60]: https://github.com/mautrix/go/pull/60 + +## v0.10.9 (2022-01-04) + +* **Breaking change:** Changed `Messages()` to take a filter as a parameter + instead of using the syncer's filter (thanks to [@qua3k] in [#55] and [#56]). + * The previous filter behavior was completely broken, as it sent a whole + filter instead of just a RoomEventFilter. + * Passing `nil` as the filter is fine and will disable filtering + (which is equivalent to what it did before with the invalid filter). +* Added `Context()` wrapper for the `/context` API (thanks to [@qua3k] in [#54]). +* Added utility for converting media files with ffmpeg. + +[#54]: https://github.com/mautrix/go/pull/54 +[#55]: https://github.com/mautrix/go/pull/55 +[#56]: https://github.com/mautrix/go/pull/56 +[@qua3k]: https://github.com/qua3k + +## v0.10.8 (2021-12-30) + +* Added `OlmSession.Describe()` to wrap `olm_session_describe`. +* Added trace logs to log olm session descriptions when encrypting/decrypting + to-device messages. +* Added space event types and content structs. +* Added support for power level content override field in `CreateRoom`. +* Fixed ordering of olm sessions which would cause an old session to be used in + some cases even after a client created a new session. + +## v0.10.7 (2021-12-16) + +* Changed `Client.RedactEvent` to allow arbitrary fields in redaction request. + +## v0.10.5 (2021-12-06) + +* Fixed websocket disconnection not clearing all pending requests. +* Added `OlmMachine.SendRoomKeyRequest` as a more direct way of sending room + key requests. +* Added automatic Olm session recreation if an incoming message fails to decrypt. +* Changed `Login` to only omit request content from logs if there's a password + or token (appservice logins don't have sensitive content). + +## v0.10.4 (2021-11-25) + +* Added `reason` field to invite and unban requests + (thanks to [@ptman] in [#48]). +* Fixed `AppService.HasWebsocket()` returning `true` even after websocket has + disconnected. + +[@ptman]: https://github.com/ptman +[#48]: https://github.com/mautrix/go/pull/48 + +## v0.10.3 (2021-11-18) + +* Added logs about incoming appservice transactions. +* Added support for message send checkpoints (as HTTP requests, similar to the + bridge state reporting system). + +## v0.10.2 (2021-11-15) + +* Added utility method for finding the first supported login flow matching any + of the given types. +* Updated registering appservice ghosts to use `inhibit_login` flag to prevent + lots of unnecessary access tokens from being created. + * If you want to log in as an appservice ghost, you should use [MSC2778]'s + appservice login (e.g. like [mautrix-whatsapp does for e2be](https://github.com/mautrix/whatsapp/blob/v0.2.1/crypto.go#L143-L149)). + +## v0.10.1 (2021-11-05) + +* Removed direct dependency on `pq` + * In order to use some more efficient queries on postgres, you must set + `crypto.PostgresArrayWrapper = pq.Array` if you want to use both postgres + and e2ee. +* Added temporary hack to ignore state events with the MSC2716 historical flag + (to be removed after [matrix-org/synapse#11265] is merged) +* Added received transaction acknowledgements for websocket appservice + transactions. +* Added automatic fallback to move `prev_content` from top level to the + standard location inside `unsigned`. + +[matrix-org/synapse#11265]: https://github.com/matrix-org/synapse/pull/11265 + +## v0.9.31 (2021-10-27) + +* Added `SetEdit` utility function for `MessageEventContent`. + +## v0.9.30 (2021-10-26) + +* Added wrapper for [MSC2716]'s `/batch_send` endpoint. +* Added `MarshalJSON` method for `Event` struct to prevent empty unsigned + structs from being included in the JSON. + +[MSC2716]: https://github.com/matrix-org/matrix-spec-proposals/pull/2716 + +## v0.9.29 (2021-09-30) + +* Added `client.State` method to get full room state. +* Added bridge info structs and event types ([MSC2346]). +* Made response handling more customizable. +* Fixed type of `AuthType` constants. + +[MSC2346]: https://github.com/matrix-org/matrix-spec-proposals/pull/2346 + +## v0.9.28 (2021-09-30) + +* Added `X-Mautrix-Process-ID` to appservice websocket headers to help debug + issues where multiple instances are connecting to the server at the same time. + +## v0.9.27 (2021-09-23) + +* Fixed Go 1.14 compatibility (broken in v0.9.25). +* Added GitHub actions CI to build, test and check formatting on Go 1.14-1.17. + +## v0.9.26 (2021-09-21) + +* Added default no-op logger to `Client` in order to prevent panic when the + application doesn't set a logger. + +## v0.9.25 (2021-09-19) + +* Disabled logging request JSON for sensitive requests like `/login`, + `/register` and other UIA endpoints. Logging can still be enabled by + setting `MAUTRIX_LOG_SENSITIVE_CONTENT` to `yes`. +* Added option to store new homeserver URL from `/login` response well-known data. +* Added option to stream big sync responses via disk to maybe reduce memory usage. +* Fixed trailing slashes in homeserver URL breaking all requests. + +## v0.9.24 (2021-09-03) + +* Added write deadline for appservice websocket connection. + +## v0.9.23 (2021-08-31) + +* Fixed storing e2ee key withheld events in the SQL store. + +## v0.9.22 (2021-08-30) + +* Updated appservice handler to cache multiple recent transaction IDs + instead of only the most recent one. + +## v0.9.21 (2021-08-25) + +* Added liveness and readiness endpoints to appservices. + * The endpoints are the same as mautrix-python: + `/_matrix/mau/live` and `/_matrix/mau/ready` + * Liveness always returns 200 and an empty JSON object by default, + but it can be turned off by setting `appservice.Live` to `false`. + * Readiness defaults to returning 500, and it can be switched to 200 + by setting `appservice.Ready` to `true`. + +## v0.9.20 (2021-08-19) + +* Added crypto store migration for converting all `VARCHAR(255)` columns + to `TEXT` in Postgres databases. + +## v0.9.19 (2021-08-17) + +* Fixed HTML parser outputting two newlines after paragraph tags. + +## v0.9.18 (2021-08-16) + +* Added new `BuildURL` method that does the same as `Client.BuildBaseURL` + but without requiring the `Client` instance. + +## v0.9.17 (2021-07-25) + +* Fixed handling OTK counts and device lists coming in through the appservice + transaction websocket. +* Updated OlmMachine to ignore OTK counts intended for other devices. + +## v0.9.15 (2021-07-16) + +* Added support for [MSC3202] and the to-device part of [MSC2409] in the + appservice package. +* Added support for sending commands through appservice websocket. +* Changed error message JSON field name in appservice error responses to + conform with standard Matrix errors (`message` -> `error`). + +[MSC3202]: https://github.com/matrix-org/matrix-spec-proposals/pull/3202 + +## v0.9.14 (2021-06-17) + +* Added default implementation of `PillConverter` in HTML parser utility. + +## v0.9.13 (2021-06-15) + +* Added support for parsing and generating encoded matrix.to URLs and `matrix:` URIs ([MSC2312](https://github.com/matrix-org/matrix-doc/pull/2312)). +* Updated HTML parser to use new URI parser for parsing user/room pills. + +## v0.9.12 (2021-05-18) + +* Added new method for sending custom data with read receipts + (not currently a part of the spec). + +## v0.9.11 (2021-05-12) + +* Improved debug log for unsupported event types. +* Added VoIP events to GuessClass. +* Added support for parsing strings in VoIP event version field. + +## v0.9.10 (2021-04-29) + +* Fixed `format.RenderMarkdown()` still allowing HTML when both `allowHTML` + and `allowMarkdown` are `false`. + +## v0.9.9 (2021-04-26) + +* Updated appservice `StartWebsocket` to return websocket close info. + +## v0.9.8 (2021-04-20) + +* Added methods for getting room tags and account data. + +## v0.9.7 (2021-04-19) + +* **Breaking change (crypto):** `SendEncryptedToDevice` now requires an event + type parameter. Previously it only allowed sending events of type + `event.ToDeviceForwardedRoomKey`. +* Added content structs for VoIP events. +* Added global mutex for Olm decryption + (previously it was only used for encryption). + +## v0.9.6 (2021-04-15) + +* Added option to retry all HTTP requests when encountering a HTTP network + error or gateway error response (502/503/504) + * Disabled by default, you need to set the `DefaultHTTPRetries` field in + the `AppService` or `Client` struct to enable. + * Can also be enabled with `FullRequest`s `MaxAttempts` field. + +## v0.9.5 (2021-04-06) + +* Reverted update of `golang.org/x/sys` which broke Go 1.14 / darwin/arm. + +## v0.9.4 (2021-04-06) + +* Switched appservices to using shared `http.Client` instance with a in-memory + cookie jar. + +## v0.9.3 (2021-03-26) + +* Made user agent headers easier to configure. +* Improved logging when receiving weird/unhandled to-device events. + +## v0.9.2 (2021-03-15) + +* Fixed type of presence state constants (thanks to [@babolivier] in [#30]). +* Implemented presence state fetching methods (thanks to [@babolivier] in [#29]). +* Added support for sending and receiving commands via appservice transaction websocket. + +[@babolivier]: https://github.com/babolivier +[#29]: https://github.com/mautrix/go/pull/29 +[#30]: https://github.com/mautrix/go/pull/30 + +## v0.9.1 (2021-03-11) + +* Fixed appservice register request hiding actual errors due to UIA error handling. + +## v0.9.0 (2021-03-04) + +* **Breaking change (manual API requests):** `MakeFullRequest` now takes a + `FullRequest` struct instead of individual parameters. `MakeRequest`'s + parameters are unchanged. +* **Breaking change (manual /sync):** `SyncRequest` now requires a `Context` + parameter. +* **Breaking change (end-to-bridge encryption):** + the `uk.half-shot.msc2778.login.application_service` constant used for + appservice login ([MSC2778]) was renamed from `AuthTypeAppservice` + to `AuthTypeHalfyAppservice`. + * The `AuthTypeAppservice` constant now contains `m.login.application_service`, + which is currently only used for registrations, but will also be used for + login once MSC2778 lands in the spec. +* Fixed appservice registration requests to include `m.login.application_service` + as the `type` (re [matrix-org/synapse#9548]). +* Added wrapper for `/logout/all`. + +[MSC2778]: https://github.com/matrix-org/matrix-spec-proposals/pull/2778 +[matrix-org/synapse#9548]: https://github.com/matrix-org/synapse/pull/9548 + +## v0.8.6 (2021-03-02) + +* Added client-side timeout to `mautrix.Client`'s `http.Client` + (defaults to 3 minutes). +* Updated maulogger to fix bug where plaintext file logs wouldn't have newlines. + +## v0.8.5 (2021-02-26) + +* Fixed potential concurrent map writes in appservice `Client` and `Intent` + methods. + +## v0.8.4 (2021-02-24) + +* Added option to output appservice logs as JSON. +* Added new methods for validating user ID localparts. + +## v0.8.3 (2021-02-21) + +* Allowed empty content URIs in parser +* Added functions for device management endpoints + (thanks to [@edwargix] in [#26]). + +[@edwargix]: https://github.com/edwargix +[#26]: https://github.com/mautrix/go/pull/26 + +## v0.8.2 (2021-02-09) + +* Fixed error when removing the user's avatar. + +## v0.8.1 (2021-02-09) + +* Added AccountDataStore to remove the need for persistent local storage other + than the access token (thanks to [@daenney] in [#23]). +* Added support for receiving appservice transactions over websocket. + See for the server-side implementation. +* Fixed error when removing the room avatar. + +[@daenney]: https://github.com/daenney +[#23]: https://github.com/mautrix/go/pull/23 + +## v0.8.0 (2020-12-24) + +* **Breaking change:** the `RateLimited` field in the `Registration` struct is + now a pointer, so that it can be omitted entirely. +* Merged initial SSSS/cross-signing code by [@nikofil]. Interactive verification + doesn't work, but the other things mostly do. +* Added support for authorization header auth in appservices ([MSC2832]). +* Added support for receiving ephemeral events directly ([MSC2409]). +* Fixed `SendReaction()` and other similar methods in the `Client` struct. +* Fixed crypto cgo code panicking in Go 1.15.3+. +* Fixed olm session locks sometime getting deadlocked. + +[MSC2832]: https://github.com/matrix-org/matrix-spec-proposals/pull/2832 +[MSC2409]: https://github.com/matrix-org/matrix-spec-proposals/pull/2409 +[@nikofil]: https://github.com/nikofil diff --git a/mautrix-patched/LICENSE b/mautrix-patched/LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/mautrix-patched/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/mautrix-patched/README.md b/mautrix-patched/README.md new file mode 100644 index 00000000..b1a2edf8 --- /dev/null +++ b/mautrix-patched/README.md @@ -0,0 +1,24 @@ +# mautrix-go +[![GoDoc](https://pkg.go.dev/badge/maunium.net/go/mautrix)](https://pkg.go.dev/maunium.net/go/mautrix) + +A Golang Matrix framework. Used by [gomuks](https://gomuks.app), +[go-neb](https://github.com/matrix-org/go-neb), +[mautrix-whatsapp](https://github.com/mautrix/whatsapp) +and others. + +Matrix room: [`#go:maunium.net`](https://matrix.to/#/#go:maunium.net) + +This project is based on [matrix-org/gomatrix](https://github.com/matrix-org/gomatrix). +The original project is licensed under [Apache 2.0](https://github.com/matrix-org/gomatrix/blob/master/LICENSE). + +In addition to the basic client API features the original project has, this framework also has: + +* Appservice support (Intent API like mautrix-python, room state storage, etc) +* End-to-end encryption support (incl. key backup, cross-signing, interactive verification, etc) +* High-level module for building puppeting bridges +* Partial federation module (making requests, PDU processing and event authorization) +* A media proxy server which can be used to expose anything as a Matrix media repo +* Wrapper functions for the Synapse admin API +* Structs for parsing event content +* Helpers for parsing and generating Matrix HTML +* Helpers for handling push rules diff --git a/mautrix-patched/appservice/appservice.go b/mautrix-patched/appservice/appservice.go new file mode 100644 index 00000000..554a463b --- /dev/null +++ b/mautrix-patched/appservice/appservice.go @@ -0,0 +1,434 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "strings" + "sync" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/rs/zerolog" + "golang.org/x/net/publicsuffix" + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// EventChannelSize is the size for the Events channel in Appservice instances. +var EventChannelSize = 64 +var OTKChannelSize = 64 + +// Create creates a blank appservice instance. +func Create() *AppService { + jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + as := &AppService{ + Log: zerolog.Nop(), + clients: make(map[id.UserID]*mautrix.Client), + intents: make(map[id.UserID]*IntentAPI), + HTTPClient: &http.Client{Timeout: 180 * time.Second, Jar: jar}, + StateStore: mautrix.NewMemoryStateStore().(StateStore), + Router: http.NewServeMux(), + UserAgent: mautrix.DefaultUserAgent, + txnIDC: NewTransactionIDCache(128), + Live: true, + Ready: false, + ProcessID: getDefaultProcessID(), + + Events: make(chan *event.Event, EventChannelSize), + ToDeviceEvents: make(chan *event.Event, EventChannelSize), + OTKCounts: make(chan *mautrix.OTKCount, OTKChannelSize), + DeviceLists: make(chan *mautrix.DeviceLists, EventChannelSize), + QueryHandler: &QueryHandlerStub{}, + + SpecVersions: &mautrix.RespVersions{}, + + DefaultHTTPRetries: 4, + } + + as.Router.HandleFunc("PUT /_matrix/app/v1/transactions/{txnID}", as.PutTransaction) + as.Router.HandleFunc("GET /_matrix/app/v1/rooms/{roomAlias}", as.GetRoom) + as.Router.HandleFunc("GET /_matrix/app/v1/users/{userID}", as.GetUser) + as.Router.HandleFunc("POST /_matrix/app/v1/ping", as.PostPing) + as.Router.HandleFunc("GET /_matrix/mau/live", as.GetLive) + as.Router.HandleFunc("GET /_matrix/mau/ready", as.GetReady) + + return as +} + +// CreateOpts contains the options for initializing a new [AppService] instance. +type CreateOpts struct { + // Required, the registration file data for this appservice. + Registration *Registration + // Required, the homeserver's server_name. + HomeserverDomain string + // Required, the homeserver URL to connect to. Should be either https://address or unix:path + HomeserverURL string + // Required if you want to use the standard HTTP server, optional for websockets (non-standard) + HostConfig HostConfig + // Optional, defaults to a memory state store + StateStore StateStore +} + +// CreateFull creates a fully configured appservice instance that can be [Start]ed and used directly. +func CreateFull(opts CreateOpts) (*AppService, error) { + if opts.HomeserverDomain == "" { + return nil, fmt.Errorf("missing homeserver domain") + } else if opts.HomeserverURL == "" { + return nil, fmt.Errorf("missing homeserver URL") + } else if opts.Registration == nil { + return nil, fmt.Errorf("missing registration") + } + as := Create() + as.HomeserverDomain = opts.HomeserverDomain + as.Host = opts.HostConfig + as.Registration = opts.Registration + err := as.SetHomeserverURL(opts.HomeserverURL) + if err != nil { + return nil, err + } + if opts.StateStore != nil { + as.StateStore = opts.StateStore + } else { + as.StateStore = mautrix.NewMemoryStateStore().(StateStore) + } + return as, nil +} + +var _ StateStore = (*mautrix.MemoryStateStore)(nil) + +// QueryHandler handles room alias and user ID queries from the homeserver. +type QueryHandler interface { + QueryAlias(alias id.RoomAlias) bool + QueryUser(userID id.UserID) bool +} + +type QueryHandlerStub struct{} + +func (qh *QueryHandlerStub) QueryAlias(alias id.RoomAlias) bool { + return false +} + +func (qh *QueryHandlerStub) QueryUser(userID id.UserID) bool { + return false +} + +type WebsocketHandler func(WebsocketCommand) (ok bool, data any) + +type StateStore interface { + mautrix.StateStore + + IsRegistered(ctx context.Context, userID id.UserID) (bool, error) + MarkRegistered(ctx context.Context, userID id.UserID) error + + GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) + GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) + HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) +} + +// AppService is the main config for all appservices. +// It also serves as the appservice instance struct. +type AppService struct { + HomeserverDomain string + hsURLForClient *url.URL + Host HostConfig + + Registration *Registration + Log zerolog.Logger + + txnIDC *TransactionIDCache + + Events chan *event.Event + ToDeviceEvents chan *event.Event + DeviceLists chan *mautrix.DeviceLists + OTKCounts chan *mautrix.OTKCount + QueryHandler QueryHandler + StateStore StateStore + + Router *http.ServeMux + UserAgent string + server *http.Server + HTTPClient *http.Client + botClient *mautrix.Client + botIntent *IntentAPI + SpecVersions *mautrix.RespVersions + + DefaultHTTPRetries int + + Live bool + Ready bool + + clients map[id.UserID]*mautrix.Client + clientsLock sync.RWMutex + intents map[id.UserID]*IntentAPI + intentsLock sync.RWMutex + + ws *websocket.Conn + StopWebsocket func(error) + websocketHandlers map[string]WebsocketHandler + websocketHandlersLock sync.RWMutex + websocketRequests map[int]chan<- *WebsocketCommand + websocketRequestsLock sync.RWMutex + websocketRequestID int32 + // ProcessID is an identifier sent to the websocket proxy for debugging connections + ProcessID string + + WebsocketTransactionHandler WebsocketTransactionHandler + + DoublePuppetValue string + GetProfile func(userID id.UserID, roomID id.RoomID) *event.MemberEventContent +} + +const DoublePuppetKey = "fi.mau.double_puppet_source" +const DoublePuppetTSKey = "fi.mau.double_puppet_ts" + +func getDefaultProcessID() string { + pid := syscall.Getpid() + uid := syscall.Getuid() + hostname, _ := os.Hostname() + return fmt.Sprintf("%s-%d-%d", hostname, uid, pid) +} + +func (as *AppService) PrepareWebsocket() { + as.websocketHandlersLock.Lock() + defer as.websocketHandlersLock.Unlock() + if as.WebsocketTransactionHandler == nil { + as.WebsocketTransactionHandler = as.defaultHandleWebsocketTransaction + } + if as.websocketHandlers == nil { + as.websocketHandlers = make(map[string]WebsocketHandler, 32) + as.websocketHandlers[WebsocketCommandHTTPProxy] = as.WebsocketHTTPProxy + as.websocketRequests = make(map[int]chan<- *WebsocketCommand) + } +} + +// HostConfig contains info about how to host the appservice. +type HostConfig struct { + // Hostname can be an IP address or an absolute path for a unix socket. + Hostname string `yaml:"hostname"` + // Port is required when Hostname is an IP address, optional for unix sockets + Port uint16 `yaml:"port"` +} + +// Address gets the whole address of the Appservice. +func (hc *HostConfig) Address() string { + return fmt.Sprintf("%s:%d", hc.Hostname, hc.Port) +} + +func (hc *HostConfig) IsUnixSocket() bool { + return strings.HasPrefix(hc.Hostname, "/") +} + +func (hc *HostConfig) IsConfigured() bool { + return hc.IsUnixSocket() || hc.Port != 0 +} + +// Save saves this config into a file at the given path. +func (as *AppService) Save(path string) error { + data, err := yaml.Marshal(as) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// YAML returns the config in YAML format. +func (as *AppService) YAML() (string, error) { + data, err := yaml.Marshal(as) + if err != nil { + return "", err + } + return string(data), nil +} + +// BotMXID returns the user ID corresponding to the appservice's sender_localpart +func (as *AppService) BotMXID() id.UserID { + if as.botClient != nil { + return as.botClient.UserID + } + return id.NewUserID(as.Registration.SenderLocalpart, as.HomeserverDomain) +} + +func (as *AppService) makeIntent(userID id.UserID) *IntentAPI { + as.intentsLock.Lock() + defer as.intentsLock.Unlock() + + intent, ok := as.intents[userID] + if ok { + return intent + } + + localpart, homeserver, err := userID.Parse() + if err != nil || len(localpart) == 0 || homeserver != as.HomeserverDomain { + if err != nil { + as.Log.Error().Err(err). + Str("user_id", userID.String()). + Msg("Failed to parse user ID") + } else if len(localpart) == 0 { + as.Log.Error().Err(err). + Str("user_id", userID.String()). + Msg("Failed to make intent: localpart is empty") + } else if homeserver != as.HomeserverDomain { + as.Log.Error().Err(err). + Str("user_id", userID.String()). + Str("expected_homeserver", as.HomeserverDomain). + Msg("Failed to make intent: homeserver doesn't match") + } + return nil + } + intent = as.NewIntentAPI(localpart) + as.intents[userID] = intent + return intent +} + +// Intent returns an [IntentAPI] object for the given user ID. +// +// This will return nil if the given user ID has an empty localpart, +// or if the server name is not the same as the appservice's HomeserverDomain. +// It does not currently validate that the given user ID is actually in the +// appservice's namespace. Validation may be added later. +func (as *AppService) Intent(userID id.UserID) *IntentAPI { + if userID == as.BotMXID() { + return as.BotIntent() + } + as.intentsLock.RLock() + intent, ok := as.intents[userID] + as.intentsLock.RUnlock() + if !ok { + return as.makeIntent(userID) + } + return intent +} + +// BotIntent returns an [IntentAPI] object for the appservice's sender_localpart user. +func (as *AppService) BotIntent() *IntentAPI { + if as.botIntent == nil { + as.botIntent = as.makeIntent(as.BotMXID()) + } + return as.botIntent +} + +// SetHomeserverURL updates the appservice's homeserver URL. +// +// Note that this does not affect already-created [IntentAPI] or [mautrix.Client] objects, +// so it should not be called after Intent or Client are called. +func (as *AppService) SetHomeserverURL(homeserverURL string) error { + parsedURL, err := url.Parse(homeserverURL) + if err != nil { + return err + } + copied := *parsedURL + as.hsURLForClient = &copied + if as.hsURLForClient.Scheme == "unix" { + as.hsURLForClient.Scheme = "http" + as.hsURLForClient.Host = "unix" + as.hsURLForClient.Path = "" + } else if as.hsURLForClient.Scheme == "" { + as.hsURLForClient.Scheme = "https" + } + as.hsURLForClient.RawPath = as.hsURLForClient.EscapedPath() + + jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + as.HTTPClient = &http.Client{Timeout: 180 * time.Second, Jar: jar} + if parsedURL.Scheme == "unix" { + as.HTTPClient.Transport = &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", parsedURL.Path) + }, + } + } + return nil +} + +// NewMautrixClient creates a new [mautrix.Client] instance for the given user ID. +// +// This does not do any validation, and it does not cache the client. +// Usually you should prefer [AppService.Client] or [AppService.Intent] over this method. +func (as *AppService) NewMautrixClient(userID id.UserID) *mautrix.Client { + return &mautrix.Client{ + HomeserverURL: as.hsURLForClient, + UserID: userID, + SetAppServiceUserID: true, + AccessToken: as.Registration.AppToken, + UserAgent: as.UserAgent, + StateStore: as.StateStore, + Log: as.Log.With().Stringer("as_user_id", userID).Logger(), + Client: as.HTTPClient, + DefaultHTTPRetries: as.DefaultHTTPRetries, + SpecVersions: as.SpecVersions, + } +} + +// NewExternalMautrixClient creates a new [mautrix.Client] instance for an external user, +// with a token and homeserver URL separate from the main appservice. +// +// This is primarily meant to facilitate double puppeting in bridges, and is used by [bridge.doublePuppetUtil]. +// Non-bridge appservices will likely not need this. +func (as *AppService) NewExternalMautrixClient(userID id.UserID, token string, homeserverURL string) (*mautrix.Client, error) { + client := as.NewMautrixClient(userID) + client.AccessToken = token + client.SetAppServiceUserID = false + if homeserverURL != "" { + client.Client = &http.Client{Timeout: 180 * time.Second} + var err error + client.HomeserverURL, err = mautrix.ParseAndNormalizeBaseURL(homeserverURL) + if err != nil { + return nil, err + } + } + return client, nil +} + +func (as *AppService) makeClient(userID id.UserID) *mautrix.Client { + as.clientsLock.Lock() + defer as.clientsLock.Unlock() + + client, ok := as.clients[userID] + if !ok { + client = as.NewMautrixClient(userID) + as.clients[userID] = client + } + return client +} + +// Client returns the [mautrix.Client] instance for the given user ID. +// +// Unlike [AppService.Intent], this does not do any validation, and will always return a value. +// Usually you should prefer creating intents and using intent methods over direct client methods. +// You can always access the client inside an intent with [IntentAPI.Client]. +func (as *AppService) Client(userID id.UserID) *mautrix.Client { + if userID == as.BotMXID() { + return as.BotClient() + } + as.clientsLock.RLock() + client, ok := as.clients[userID] + as.clientsLock.RUnlock() + if !ok { + return as.makeClient(userID) + } + return client +} + +// BotClient returns the [mautrix.Client] instance for the appservice's sender_localpart user. +// +// Like with the generic Client method, [AppService.BotIntent] should be preferred over this. +func (as *AppService) BotClient() *mautrix.Client { + if as.botClient == nil { + as.botClient = as.makeClient(as.BotMXID()) + } + return as.botClient +} diff --git a/mautrix-patched/appservice/appservice_test.go b/mautrix-patched/appservice/appservice_test.go new file mode 100644 index 00000000..eace1668 --- /dev/null +++ b/mautrix-patched/appservice/appservice_test.go @@ -0,0 +1,42 @@ +package appservice + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClient_UnixSocket(t *testing.T) { + + tmpDir := t.TempDir() + socket := path.Join(tmpDir, "socket") + + l, err := net.Listen("unix", socket) + assert.NoError(t, err) + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, ` +{ + "device_id": "ABC1234", + "user_id": "@joe:example.org" +}`) + })) + + ts.Listener.Close() + ts.Listener = l + ts.Start() + defer ts.Close() + as := Create() + as.Registration = &Registration{} + err = as.SetHomeserverURL(fmt.Sprintf("unix://%s", socket)) + assert.NoError(t, err) + client := as.Client("user1") + resp, err := client.Whoami(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "@joe:example.org", string(resp.UserID)) +} diff --git a/mautrix-patched/appservice/eventprocessor.go b/mautrix-patched/appservice/eventprocessor.go new file mode 100644 index 00000000..4cd2ce4e --- /dev/null +++ b/mautrix-patched/appservice/eventprocessor.go @@ -0,0 +1,209 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "encoding/json" + "runtime/debug" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +type ExecMode uint8 + +const ( + AsyncHandlers ExecMode = iota + AsyncLoop + Sync +) + +type EventHandler = func(ctx context.Context, evt *event.Event) +type OTKHandler = func(ctx context.Context, otk *mautrix.OTKCount) +type DeviceListHandler = func(ctx context.Context, lists *mautrix.DeviceLists, since string) + +type EventProcessor struct { + ExecMode ExecMode + + ExecSyncWarnTime time.Duration + ExecSyncTimeout time.Duration + + as *AppService + stop chan struct{} + handlers map[event.Type][]EventHandler + + otkHandlers []OTKHandler + deviceListHandlers []DeviceListHandler +} + +func NewEventProcessor(as *AppService) *EventProcessor { + return &EventProcessor{ + ExecMode: AsyncHandlers, + as: as, + stop: make(chan struct{}, 1), + handlers: make(map[event.Type][]EventHandler), + + ExecSyncWarnTime: 30 * time.Second, + ExecSyncTimeout: 15 * time.Minute, + + otkHandlers: make([]OTKHandler, 0), + deviceListHandlers: make([]DeviceListHandler, 0), + } +} + +func (ep *EventProcessor) On(evtType event.Type, handler EventHandler) { + handlers, ok := ep.handlers[evtType] + if !ok { + handlers = []EventHandler{handler} + } else { + handlers = append(handlers, handler) + } + ep.handlers[evtType] = handlers +} + +func (ep *EventProcessor) PrependHandler(evtType event.Type, handler EventHandler) { + handlers, ok := ep.handlers[evtType] + if !ok { + handlers = []EventHandler{handler} + } else { + handlers = append([]EventHandler{handler}, handlers...) + } + ep.handlers[evtType] = handlers +} + +func (ep *EventProcessor) OnOTK(handler OTKHandler) { + ep.otkHandlers = append(ep.otkHandlers, handler) +} + +func (ep *EventProcessor) OnDeviceList(handler DeviceListHandler) { + ep.deviceListHandlers = append(ep.deviceListHandlers, handler) +} + +func (ep *EventProcessor) recoverFunc(data interface{}) { + if err := recover(); err != nil { + d, _ := json.Marshal(data) + ep.as.Log.Error(). + Str(zerolog.ErrorStackFieldName, string(debug.Stack())). + Interface(zerolog.ErrorFieldName, err). + Str("event_content", string(d)). + Msg("Panic in Matrix event handler") + } +} + +func (ep *EventProcessor) callHandler(ctx context.Context, handler EventHandler, evt *event.Event) { + defer ep.recoverFunc(evt) + handler(ctx, evt) +} + +func (ep *EventProcessor) callOTKHandler(ctx context.Context, handler OTKHandler, otk *mautrix.OTKCount) { + defer ep.recoverFunc(otk) + handler(ctx, otk) +} + +func (ep *EventProcessor) callDeviceListHandler(ctx context.Context, handler DeviceListHandler, dl *mautrix.DeviceLists) { + defer ep.recoverFunc(dl) + handler(ctx, dl, "") +} + +func (ep *EventProcessor) DispatchOTK(ctx context.Context, otk *mautrix.OTKCount) { + for _, handler := range ep.otkHandlers { + go ep.callOTKHandler(ctx, handler, otk) + } +} + +func (ep *EventProcessor) DispatchDeviceList(ctx context.Context, dl *mautrix.DeviceLists) { + for _, handler := range ep.deviceListHandlers { + go ep.callDeviceListHandler(ctx, handler, dl) + } +} + +func (ep *EventProcessor) Dispatch(ctx context.Context, evt *event.Event) { + handlers, ok := ep.handlers[evt.Type] + if !ok { + return + } + switch ep.ExecMode { + case AsyncHandlers: + for _, handler := range handlers { + go ep.callHandler(ctx, handler, evt) + } + case AsyncLoop: + go func() { + for _, handler := range handlers { + ep.callHandler(ctx, handler, evt) + } + }() + case Sync: + if ep.ExecSyncWarnTime == 0 && ep.ExecSyncTimeout == 0 { + for _, handler := range handlers { + ep.callHandler(ctx, handler, evt) + } + return + } + doneChan := make(chan struct{}) + go func() { + for _, handler := range handlers { + ep.callHandler(ctx, handler, evt) + } + close(doneChan) + }() + select { + case <-doneChan: + return + case <-time.After(ep.ExecSyncWarnTime): + log := ep.as.Log.With(). + Str("event_id", evt.ID.String()). + Str("event_type", evt.Type.String()). + Logger() + log.Warn().Msg("Handling event in appservice transaction channel is taking long") + select { + case <-doneChan: + return + case <-time.After(ep.ExecSyncTimeout): + log.Error().Msg("Giving up waiting for event handler") + } + } + } +} +func (ep *EventProcessor) startEvents(ctx context.Context) { + for { + select { + case evt := <-ep.as.Events: + ep.Dispatch(ctx, evt) + case <-ep.stop: + return + } + } +} + +func (ep *EventProcessor) startEncryption(ctx context.Context) { + for { + select { + case evt := <-ep.as.ToDeviceEvents: + ep.Dispatch(ctx, evt) + case otk := <-ep.as.OTKCounts: + ep.DispatchOTK(ctx, otk) + case dl := <-ep.as.DeviceLists: + ep.DispatchDeviceList(ctx, dl) + case <-ep.stop: + return + } + } +} + +func (ep *EventProcessor) Start(ctx context.Context) { + go ep.startEvents(ctx) + go ep.startEncryption(ctx) +} + +func (ep *EventProcessor) Stop() { + close(ep.stop) +} diff --git a/mautrix-patched/appservice/http.go b/mautrix-patched/appservice/http.go new file mode 100644 index 00000000..27ce6288 --- /dev/null +++ b/mautrix-patched/appservice/http.go @@ -0,0 +1,296 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strings" + "syscall" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/exstrings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Start starts the HTTP server that listens for calls from the Matrix homeserver. +func (as *AppService) Start() { + as.server = &http.Server{ + Handler: as.Router, + } + var err error + if as.Host.IsUnixSocket() { + err = as.listenUnix() + } else { + as.server.Addr = as.Host.Address() + err = as.listenTCP() + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + as.Log.Error().Err(err).Msg("Error in HTTP listener") + } else { + as.Log.Debug().Msg("HTTP listener stopped") + } +} + +func (as *AppService) listenUnix() error { + socket := as.Host.Hostname + _ = syscall.Unlink(socket) + defer func() { + _ = syscall.Unlink(socket) + }() + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + as.Log.Info().Str("socket", socket).Msg("Starting unix socket HTTP listener") + return as.server.Serve(listener) +} + +func (as *AppService) listenTCP() error { + as.Log.Info().Str("address", as.server.Addr).Msg("Starting HTTP listener") + return as.server.ListenAndServe() +} + +func (as *AppService) Stop() { + if as.server == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = as.server.Shutdown(ctx) + as.server = nil +} + +// CheckServerToken checks if the given request originated from the Matrix homeserver. +func (as *AppService) CheckServerToken(w http.ResponseWriter, r *http.Request) (isValid bool) { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + mautrix.MMissingToken.WithMessage("Missing access token").Write(w) + } else if !exstrings.ConstantTimeEqual(authHeader[len("Bearer "):], as.Registration.ServerToken) { + mautrix.MUnknownToken.WithMessage("Invalid access token").Write(w) + } else { + isValid = true + } + return +} + +// PutTransaction handles a /transactions PUT call from the homeserver. +func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) { + if !as.CheckServerToken(w, r) { + return + } + + txnID := r.PathValue("txnID") + if len(txnID) == 0 { + mautrix.MInvalidParam.WithMessage("Missing transaction ID").Write(w) + return + } + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil || len(body) == 0 { + mautrix.MNotJSON.WithMessage("Failed to read response body").Write(w) + return + } + log := as.Log.With().Str("transaction_id", txnID).Logger() + // Don't use request context, handling shouldn't be stopped even if the request times out + ctx := context.Background() + ctx = log.WithContext(ctx) + if as.txnIDC.IsProcessed(txnID) { + // Duplicate transaction ID: no-op + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + log.Debug().Msg("Ignoring duplicate transaction") + return + } + + var txn Transaction + err = json.Unmarshal(body, &txn) + if err != nil { + log.Error().Err(err).Msg("Failed to parse transaction content") + mautrix.MBadJSON.WithMessage("Failed to parse transaction content").Write(w) + } else { + as.handleTransaction(ctx, txnID, &txn) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + } +} + +func (as *AppService) handleTransaction(ctx context.Context, id string, txn *Transaction) { + log := zerolog.Ctx(ctx) + log.Debug().Object("content", txn).Msg("Starting handling of transaction") + if as.Registration.EphemeralEvents { + if txn.EphemeralEvents != nil { + as.handleEvents(ctx, txn.EphemeralEvents, event.EphemeralEventType) + } else if txn.MSC2409EphemeralEvents != nil { + as.handleEvents(ctx, txn.MSC2409EphemeralEvents, event.EphemeralEventType) + } + if txn.ToDeviceEvents != nil { + as.handleEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) + } else if txn.MSC2409ToDeviceEvents != nil { + as.handleEvents(ctx, txn.MSC2409ToDeviceEvents, event.ToDeviceEventType) + } + } + as.handleEvents(ctx, txn.Events, event.UnknownEventType) + if txn.DeviceLists != nil { + as.handleDeviceLists(ctx, txn.DeviceLists) + } else if txn.MSC3202DeviceLists != nil { + as.handleDeviceLists(ctx, txn.MSC3202DeviceLists) + } + if txn.DeviceOTKCount != nil { + as.handleOTKCounts(ctx, txn.DeviceOTKCount) + } else if txn.MSC3202DeviceOTKCount != nil { + as.handleOTKCounts(ctx, txn.MSC3202DeviceOTKCount) + } + as.txnIDC.MarkProcessed(id) + log.Debug().Msg("Finished dispatching events from transaction") +} + +func (as *AppService) handleOTKCounts(ctx context.Context, otks OTKCountMap) { + for userID, devices := range otks { + for deviceID, otkCounts := range devices { + otkCounts.UserID = userID + otkCounts.DeviceID = deviceID + select { + case as.OTKCounts <- &otkCounts: + default: + zerolog.Ctx(ctx).Warn(). + Str("user_id", userID.String()). + Msg("Dropped OTK count update for user because channel is full") + } + } + } +} + +func (as *AppService) handleDeviceLists(ctx context.Context, dl *mautrix.DeviceLists) { + select { + case as.DeviceLists <- dl: + default: + zerolog.Ctx(ctx).Warn().Msg("Dropped device list update because channel is full") + } +} + +func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, defaultTypeClass event.TypeClass) { + log := zerolog.Ctx(ctx) + for _, evt := range evts { + evt.Mautrix.ReceivedAt = time.Now() + if defaultTypeClass != event.UnknownEventType { + if defaultTypeClass == event.EphemeralEventType { + evt.Mautrix.EventSource = event.SourceEphemeral + } else if defaultTypeClass == event.ToDeviceEventType { + evt.Mautrix.EventSource = event.SourceToDevice + } + evt.Type.Class = defaultTypeClass + } else if evt.StateKey != nil { + evt.Mautrix.EventSource = event.SourceTimeline & event.SourceJoin + evt.Type.Class = event.StateEventType + } else { + evt.Mautrix.EventSource = event.SourceTimeline + evt.Type.Class = event.MessageEventType + } + err := evt.Content.ParseRaw(evt.Type) + if errors.Is(err, event.ErrUnsupportedContentType) { + log.Debug().Stringer("event_id", evt.ID).Msg("Not parsing content of unsupported event") + } else if err != nil { + log.Warn().Err(err). + Str("event_id", evt.ID.String()). + Str("event_type", evt.Type.Type). + Str("event_type_class", evt.Type.Class.Name()). + Msg("Failed to parse content of event") + } + + if evt.Type.IsState() { + mautrix.UpdateStateStore(ctx, as.StateStore, evt) + } + var ch chan *event.Event + if evt.Type.Class == event.ToDeviceEventType { + ch = as.ToDeviceEvents + } else { + ch = as.Events + } + select { + case ch <- evt: + default: + log.Warn(). + Str("event_id", evt.ID.String()). + Str("event_type", evt.Type.Type). + Str("event_type_class", evt.Type.Class.Name()). + Msg("Event channel is full") + ch <- evt + } + } +} + +// GetRoom handles a /rooms GET call from the homeserver. +func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) { + if !as.CheckServerToken(w, r) { + return + } + + roomAlias := id.RoomAlias(r.PathValue("roomAlias")) + ok := as.QueryHandler.QueryAlias(roomAlias) + if ok { + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + } else { + mautrix.MNotFound.WithMessage("Alias not found").Write(w) + } +} + +// GetUser handles a /users GET call from the homeserver. +func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) { + if !as.CheckServerToken(w, r) { + return + } + + userID := id.UserID(r.PathValue("userID")) + ok := as.QueryHandler.QueryUser(userID) + if ok { + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + } else { + mautrix.MNotFound.WithMessage("User not found").Write(w) + } +} + +func (as *AppService) PostPing(w http.ResponseWriter, r *http.Request) { + if !as.CheckServerToken(w, r) { + return + } + body, err := io.ReadAll(r.Body) + if err != nil || len(body) == 0 || !json.Valid(body) { + mautrix.MNotJSON.WithMessage("Invalid or missing request body").Write(w) + return + } + + var txn mautrix.ReqAppservicePing + _ = json.Unmarshal(body, &txn) + as.Log.Debug().Str("txn_id", txn.TxnID).Msg("Received ping from homeserver") + + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) +} + +func (as *AppService) GetLive(w http.ResponseWriter, r *http.Request) { + if as.Live { + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + } else { + exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError) + } +} + +func (as *AppService) GetReady(w http.ResponseWriter, r *http.Request) { + if as.Ready { + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + } else { + exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError) + } +} diff --git a/mautrix-patched/appservice/intent.go b/mautrix-patched/appservice/intent.go new file mode 100644 index 00000000..73e3f5ff --- /dev/null +++ b/mautrix-patched/appservice/intent.go @@ -0,0 +1,562 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type IntentAPI struct { + *mautrix.Client + bot *mautrix.Client + as *AppService + Localpart string + UserID id.UserID + + registerLock sync.Mutex + + IsCustomPuppet bool + Registered bool +} + +func (as *AppService) NewIntentAPI(localpart string) *IntentAPI { + userID := id.NewUserID(localpart, as.HomeserverDomain) + bot := as.BotClient() + if userID == bot.UserID { + bot = nil + } + return &IntentAPI{ + Client: as.Client(userID), + bot: bot, + as: as, + Localpart: localpart, + UserID: userID, + + IsCustomPuppet: false, + } +} + +func (intent *IntentAPI) Register(ctx context.Context) error { + _, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{ + Username: intent.Localpart, + Type: mautrix.AuthTypeAppservice, + InhibitLogin: true, + }, nil) + return err +} + +func (intent *IntentAPI) EnsureRegistered(ctx context.Context) error { + if intent.IsCustomPuppet || intent.Registered { + return nil + } + intent.registerLock.Lock() + defer intent.registerLock.Unlock() + isRegistered, err := intent.as.StateStore.IsRegistered(ctx, intent.UserID) + if err != nil { + return fmt.Errorf("failed to check if user is registered: %w", err) + } else if isRegistered { + intent.Registered = true + return nil + } + + err = intent.Register(ctx) + if err != nil && !errors.Is(err, mautrix.MUserInUse) { + return fmt.Errorf("failed to ensure registered: %w", err) + } + err = intent.as.StateStore.MarkRegistered(ctx, intent.UserID) + if err != nil { + return fmt.Errorf("failed to mark user as registered in state store: %w", err) + } + intent.Registered = true + return nil +} + +type EnsureJoinedParams struct { + IgnoreCache bool + BotOverride *mautrix.Client + Via []string +} + +func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...EnsureJoinedParams) error { + var params EnsureJoinedParams + if len(extra) > 1 { + panic("invalid number of extra parameters") + } else if len(extra) == 1 { + params = extra[0] + } + if intent.as.StateStore.IsInRoom(ctx, roomID, intent.UserID) && !params.IgnoreCache { + return nil + } + + err := intent.EnsureRegistered(ctx) + if err != nil { + return fmt.Errorf("failed to ensure joined: %w", err) + } + + var resp *mautrix.RespJoinRoom + if len(params.Via) > 0 { + resp, err = intent.JoinRoom(ctx, roomID.String(), &mautrix.ReqJoinRoom{Via: params.Via}) + } else { + resp, err = intent.JoinRoomByID(ctx, roomID) + } + if err != nil { + bot := intent.bot + if params.BotOverride != nil { + bot = params.BotOverride + } + if !errors.Is(err, mautrix.MForbidden) || bot == nil { + return fmt.Errorf("failed to ensure joined: %w", err) + } + var inviteErr error + if intent.IsCustomPuppet { + _, inviteErr = bot.SendStateEvent(ctx, roomID, event.StateMember, intent.UserID.String(), &event.Content{ + Raw: map[string]any{ + "fi.mau.will_auto_accept": true, + }, + Parsed: &event.MemberEventContent{ + Membership: event.MembershipInvite, + }, + }) + } else { + _, inviteErr = bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{ + UserID: intent.UserID, + }) + } + if inviteErr != nil { + return fmt.Errorf("failed to invite in ensure joined: %w", inviteErr) + } + resp, err = intent.JoinRoomByID(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to ensure joined after invite: %w", err) + } + } + err = intent.as.StateStore.SetMembership(ctx, resp.RoomID, intent.UserID, event.MembershipJoin) + if err != nil { + return fmt.Errorf("failed to set membership in state store: %w", err) + } + return nil +} + +func (intent *IntentAPI) IsDoublePuppet() bool { + return intent.IsCustomPuppet && intent.as.DoublePuppetValue != "" +} + +func (intent *IntentAPI) AddDoublePuppetValue(into any) any { + return intent.AddDoublePuppetValueWithTS(into, 0) +} + +func (intent *IntentAPI) AddDoublePuppetValueWithTS(into any, ts int64) any { + if !intent.IsDoublePuppet() { + return into + } + // Only use ts deduplication feature with appservice double puppeting + if !intent.SetAppServiceUserID { + ts = 0 + } + switch val := into.(type) { + case *map[string]any: + if *val == nil { + valNonPtr := make(map[string]any) + *val = valNonPtr + } + (*val)[DoublePuppetKey] = intent.as.DoublePuppetValue + if ts != 0 { + (*val)[DoublePuppetTSKey] = ts + } + return val + case map[string]any: + val[DoublePuppetKey] = intent.as.DoublePuppetValue + if ts != 0 { + val[DoublePuppetTSKey] = ts + } + return val + case *event.Content: + if val.Raw == nil { + val.Raw = make(map[string]any) + } + val.Raw[DoublePuppetKey] = intent.as.DoublePuppetValue + if ts != 0 { + val.Raw[DoublePuppetTSKey] = ts + } + return val + case event.Content: + if val.Raw == nil { + val.Raw = make(map[string]any) + } + val.Raw[DoublePuppetKey] = intent.as.DoublePuppetValue + if ts != 0 { + val.Raw[DoublePuppetTSKey] = ts + } + return val + default: + content := &event.Content{ + Raw: map[string]any{ + DoublePuppetKey: intent.as.DoublePuppetValue, + }, + Parsed: val, + } + if ts != 0 { + content.Raw[DoublePuppetTSKey] = ts + } + return content + } +} + +func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + contentJSON = intent.AddDoublePuppetValue(contentJSON) + return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...) +} + +// Deprecated: use SendMessageEvent with mautrix.ReqSendEvent.Timestamp instead +func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { + return intent.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) +} + +func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { + if eventType != event.StateMember || stateKey != string(intent.UserID) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + } else if err := intent.EnsureRegistered(ctx); err != nil { + return nil, err + } + contentJSON = intent.AddDoublePuppetValue(contentJSON) + return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...) +} + +// Deprecated: use SendStateEvent with mautrix.ReqSendEvent.Timestamp instead +func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { + return intent.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) +} + +func (intent *IntentAPI) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return err + } + return intent.Client.StateEvent(ctx, roomID, eventType, stateKey, outContent) +} + +func (intent *IntentAPI) State(ctx context.Context, roomID id.RoomID) (mautrix.RoomStateMap, error) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + return intent.Client.State(ctx, roomID) +} + +func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID id.RoomID, target id.UserID, membership event.Membership, reason string, extraContent ...map[string]interface{}) (*mautrix.RespSendEvent, error) { + content := &event.MemberEventContent{ + Membership: membership, + Reason: reason, + } + memberContent, err := intent.as.StateStore.TryGetMember(ctx, roomID, target) + if err != nil { + return nil, fmt.Errorf("failed to get old member content from state store: %w", err) + } else if memberContent == nil { + if intent.as.GetProfile != nil { + memberContent = intent.as.GetProfile(target, roomID) + } + if memberContent == nil { + profile, err := intent.GetProfile(ctx, target) + if err != nil { + intent.Log.Debug().Err(err). + Str("target_user_id", target.String()). + Str("membership", string(membership)). + Msg("Failed to get profile to fill new membership event") + } else { + content.Displayname = profile.DisplayName + content.AvatarURL = profile.AvatarURL.CUString() + } + } + } + if memberContent != nil { + content.Displayname = memberContent.Displayname + content.AvatarURL = memberContent.AvatarURL + } + var extra map[string]interface{} + if len(extraContent) > 0 { + extra = extraContent[0] + } + return intent.SendStateEvent(ctx, roomID, event.StateMember, target.String(), &event.Content{ + Parsed: content, + Raw: extra, + }) +} + +func (intent *IntentAPI) JoinRoomByID(ctx context.Context, roomID id.RoomID, extraContent ...map[string]interface{}) (resp *mautrix.RespJoinRoom, err error) { + if intent.IsCustomPuppet || len(extraContent) > 0 { + _, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipJoin, "", extraContent...) + return &mautrix.RespJoinRoom{RoomID: roomID}, err + } + return intent.Client.JoinRoomByID(ctx, roomID) +} + +func (intent *IntentAPI) LeaveRoom(ctx context.Context, roomID id.RoomID, extra ...interface{}) (resp *mautrix.RespLeaveRoom, err error) { + var extraContent map[string]interface{} + leaveReq := &mautrix.ReqLeave{} + for _, item := range extra { + switch val := item.(type) { + case map[string]interface{}: + extraContent = val + case *mautrix.ReqLeave: + leaveReq = val + } + } + if intent.IsCustomPuppet || extraContent != nil { + _, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipLeave, leaveReq.Reason, extraContent) + return &mautrix.RespLeaveRoom{}, err + } + return intent.Client.LeaveRoom(ctx, roomID, leaveReq) +} + +func (intent *IntentAPI) InviteUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqInviteUser, extraContent ...map[string]interface{}) (resp *mautrix.RespInviteUser, err error) { + if intent.IsCustomPuppet || len(extraContent) > 0 { + _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipInvite, req.Reason, extraContent...) + return &mautrix.RespInviteUser{}, err + } + return intent.Client.InviteUser(ctx, roomID, req) +} + +func (intent *IntentAPI) KickUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqKickUser, extraContent ...map[string]interface{}) (resp *mautrix.RespKickUser, err error) { + if intent.IsCustomPuppet || len(extraContent) > 0 { + _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...) + return &mautrix.RespKickUser{}, err + } + return intent.Client.KickUser(ctx, roomID, req) +} + +func (intent *IntentAPI) BanUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespBanUser, err error) { + if intent.IsCustomPuppet || len(extraContent) > 0 { + _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipBan, req.Reason, extraContent...) + return &mautrix.RespBanUser{}, err + } + return intent.Client.BanUser(ctx, roomID, req) +} + +func (intent *IntentAPI) UnbanUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqUnbanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespUnbanUser, err error) { + if intent.IsCustomPuppet || len(extraContent) > 0 { + _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...) + return &mautrix.RespUnbanUser{}, err + } + return intent.Client.UnbanUser(ctx, roomID, req) +} + +func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id.UserID) *event.MemberEventContent { + member, err := intent.as.StateStore.TryGetMember(ctx, roomID, userID) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err). + Str("room_id", roomID.String()). + Str("user_id", userID.String()). + Msg("Failed to get member from state store") + } + if member == nil { + _ = intent.StateEvent(ctx, roomID, event.StateMember, string(userID), &member) + } + return member +} + +func (intent *IntentAPI) FillPowerLevelCreateEvent(ctx context.Context, roomID id.RoomID, pl *event.PowerLevelsEventContent) error { + if pl.CreateEvent != nil { + return nil + } + var err error + pl.CreateEvent, err = intent.StateStore.GetCreate(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to get create event from cache: %w", err) + } else if pl.CreateEvent != nil { + return nil + } + pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "") + if err != nil { + return fmt.Errorf("failed to get create event from server: %w", err) + } + return nil +} + +func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) { + pl, err = intent.as.StateStore.GetPowerLevels(ctx, roomID) + if err != nil { + err = fmt.Errorf("failed to get cached power levels: %w", err) + return + } + if pl == nil { + pl = &event.PowerLevelsEventContent{} + err = intent.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl) + if err != nil { + return + } + } + if pl.CreateEvent == nil { + pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "") + } + return +} + +func (intent *IntentAPI) SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) (resp *mautrix.RespSendEvent, err error) { + return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &levels) +} + +func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, level int) (*mautrix.RespSendEvent, error) { + pl, err := intent.PowerLevels(ctx, roomID) + if err != nil { + return nil, err + } + + if pl.EnsureUserLevelAs(intent.UserID, userID, level) { + return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl) + } + return nil, nil +} + +func (intent *IntentAPI) SendText(ctx context.Context, roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + return intent.Client.SendText(ctx, roomID, text) +} + +func (intent *IntentAPI) SendNotice(ctx context.Context, roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + return intent.Client.SendNotice(ctx, roomID, text) +} + +func (intent *IntentAPI) RedactEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, extra ...mautrix.ReqRedact) (*mautrix.RespSendEvent, error) { + if err := intent.EnsureJoined(ctx, roomID); err != nil { + return nil, err + } + var req mautrix.ReqRedact + if len(extra) > 0 { + req = extra[0] + } + intent.AddDoublePuppetValue(&req.Extra) + return intent.Client.RedactEvent(ctx, roomID, eventID, req) +} + +func (intent *IntentAPI) SetRoomName(ctx context.Context, roomID id.RoomID, roomName string) (*mautrix.RespSendEvent, error) { + return intent.SendStateEvent(ctx, roomID, event.StateRoomName, "", map[string]interface{}{ + "name": roomName, + }) +} + +func (intent *IntentAPI) SetRoomAvatar(ctx context.Context, roomID id.RoomID, avatarURL id.ContentURI) (*mautrix.RespSendEvent, error) { + return intent.SendStateEvent(ctx, roomID, event.StateRoomAvatar, "", map[string]interface{}{ + "url": avatarURL.String(), + }) +} + +func (intent *IntentAPI) SetRoomTopic(ctx context.Context, roomID id.RoomID, topic string) (*mautrix.RespSendEvent, error) { + return intent.SendStateEvent(ctx, roomID, event.StateTopic, "", map[string]interface{}{ + "topic": topic, + }) +} + +func (intent *IntentAPI) UploadMedia(ctx context.Context, data mautrix.ReqUploadMedia) (*mautrix.RespMediaUpload, error) { + if err := intent.EnsureRegistered(ctx); err != nil { + return nil, err + } + return intent.Client.UploadMedia(ctx, data) +} + +func (intent *IntentAPI) UploadAsync(ctx context.Context, data mautrix.ReqUploadMedia) (*mautrix.RespCreateMXC, error) { + if err := intent.EnsureRegistered(ctx); err != nil { + return nil, err + } + return intent.Client.UploadAsync(ctx, data) +} + +func (intent *IntentAPI) SetDisplayName(ctx context.Context, displayName string) error { + if err := intent.EnsureRegistered(ctx); err != nil { + return err + } + resp, err := intent.Client.GetOwnDisplayName(ctx) + if err != nil { + return fmt.Errorf("failed to check current displayname: %w", err) + } else if resp.DisplayName == displayName { + // No need to update + return nil + } + return intent.Client.SetDisplayName(ctx, displayName) +} + +func (intent *IntentAPI) SetAvatarURL(ctx context.Context, avatarURL id.ContentURI) error { + if err := intent.EnsureRegistered(ctx); err != nil { + return err + } + resp, err := intent.Client.GetOwnAvatarURL(ctx) + if err != nil { + return fmt.Errorf("failed to check current avatar URL: %w", err) + } else if resp.FileID == avatarURL.FileID && resp.Homeserver == avatarURL.Homeserver { + // No need to update + return nil + } + if !avatarURL.IsEmpty() && !intent.SpecVersions.Supports(mautrix.BeeperFeatureHungry) { + // Some homeservers require the avatar to be downloaded before setting it + resp, _ := intent.Download(ctx, avatarURL) + if resp != nil { + _ = resp.Body.Close() + } + } + return intent.Client.SetAvatarURL(ctx, avatarURL) +} + +func (intent *IntentAPI) SetProfileField(ctx context.Context, key string, value any) error { + if err := intent.EnsureRegistered(ctx); err != nil { + return err + } + return intent.Client.SetProfileField(ctx, key, value) +} + +func (intent *IntentAPI) UnstableOverwriteProfile(ctx context.Context, data any) (err error) { + if err := intent.EnsureRegistered(ctx); err != nil { + return err + } + return intent.Client.UnstableOverwriteProfile(ctx, data) +} + +func (intent *IntentAPI) BeeperUpdateProfile(ctx context.Context, data any) error { + if err := intent.EnsureRegistered(ctx); err != nil { + return err + } + return intent.Client.BeeperUpdateProfile(ctx, data) +} + +func (intent *IntentAPI) Whoami(ctx context.Context) (*mautrix.RespWhoami, error) { + if err := intent.EnsureRegistered(ctx); err != nil { + return nil, err + } + return intent.Client.Whoami(ctx) +} + +func (intent *IntentAPI) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error { + if !intent.as.StateStore.IsInvited(ctx, roomID, userID) { + _, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{ + UserID: userID, + }) + if httpErr, ok := err.(mautrix.HTTPError); ok && + httpErr.RespError != nil && + (strings.Contains(httpErr.RespError.Err, "is already in the room") || strings.Contains(httpErr.RespError.Err, "is already joined to room")) { + return nil + } + return err + } + return nil +} diff --git a/mautrix-patched/appservice/ping.go b/mautrix-patched/appservice/ping.go new file mode 100644 index 00000000..8ea3c7ac --- /dev/null +++ b/mautrix-patched/appservice/ping.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" +) + +func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context) bool { + var pingResp *mautrix.RespAppservicePing + var txnID string + var retryCount int + var err error + const maxRetries = 6 + for { + txnID = intent.TxnID() + pingResp, err = intent.AppservicePing(ctx, intent.as.Registration.ID, txnID) + if err == nil { + break + } + var httpErr mautrix.HTTPError + var pingErrBody string + if errors.As(err, &httpErr) && httpErr.RespError != nil { + if val, ok := httpErr.RespError.ExtraData["body"].(string); ok { + pingErrBody = strings.TrimSpace(val) + } + } + outOfRetries := retryCount >= maxRetries + level := zerolog.ErrorLevel + if outOfRetries { + level = zerolog.FatalLevel + } + evt := zerolog.Ctx(ctx).WithLevel(level).Err(err).Str("txn_id", txnID) + if pingErrBody != "" { + bodyBytes := []byte(pingErrBody) + if json.Valid(bodyBytes) { + evt.RawJSON("body", bodyBytes) + } else { + evt.Str("body", pingErrBody) + } + } + if outOfRetries { + evt.Msg("Homeserver -> appservice connection is not working") + zerolog.Ctx(ctx).Info().Msg("See https://docs.mau.fi/faq/as-ping for more info") + return false + } + evt.Msg("Homeserver -> appservice connection is not working, retrying in 5 seconds...") + time.Sleep(5 * time.Second) + retryCount++ + } + zerolog.Ctx(ctx).Debug(). + Str("txn_id", txnID). + Int64("duration_ms", pingResp.DurationMS). + Msg("Homeserver -> appservice connection works") + return true +} diff --git a/mautrix-patched/appservice/protocol.go b/mautrix-patched/appservice/protocol.go new file mode 100644 index 00000000..7c493bcb --- /dev/null +++ b/mautrix-patched/appservice/protocol.go @@ -0,0 +1,103 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "fmt" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type OTKCountMap = map[id.UserID]map[id.DeviceID]mautrix.OTKCount +type FallbackKeyMap = map[id.UserID]map[id.DeviceID][]id.KeyAlgorithm + +// Transaction contains a list of events. +type Transaction struct { + Events []*event.Event `json:"events"` + EphemeralEvents []*event.Event `json:"ephemeral,omitempty"` + ToDeviceEvents []*event.Event `json:"to_device,omitempty"` + + DeviceLists *mautrix.DeviceLists `json:"device_lists,omitempty"` + DeviceOTKCount OTKCountMap `json:"device_one_time_keys_count,omitempty"` + FallbackKeys FallbackKeyMap `json:"device_unused_fallback_key_types,omitempty"` + + MSC2409EphemeralEvents []*event.Event `json:"de.sorunome.msc2409.ephemeral,omitempty"` + MSC2409ToDeviceEvents []*event.Event `json:"de.sorunome.msc2409.to_device,omitempty"` + MSC3202DeviceLists *mautrix.DeviceLists `json:"org.matrix.msc3202.device_lists,omitempty"` + MSC3202DeviceOTKCount OTKCountMap `json:"org.matrix.msc3202.device_one_time_keys_count,omitempty"` + MSC3202FallbackKeys FallbackKeyMap `json:"org.matrix.msc3202.device_unused_fallback_key_types,omitempty"` +} + +func (txn *Transaction) MarshalZerologObject(ctx *zerolog.Event) { + ctx.Int("pdu", len(txn.Events)) + if txn.EphemeralEvents != nil { + ctx.Int("edu", len(txn.EphemeralEvents)) + } else if txn.MSC2409EphemeralEvents != nil { + ctx.Int("unstable_edu", len(txn.MSC2409EphemeralEvents)) + } + if txn.ToDeviceEvents != nil { + ctx.Int("to_device", len(txn.ToDeviceEvents)) + } else if txn.MSC2409ToDeviceEvents != nil { + ctx.Int("unstable_to_device", len(txn.MSC2409ToDeviceEvents)) + } + if len(txn.DeviceOTKCount) > 0 { + ctx.Int("otk_count_users", len(txn.DeviceOTKCount)) + } else if len(txn.MSC3202DeviceOTKCount) > 0 { + ctx.Int("unstable_otk_count_users", len(txn.MSC3202DeviceOTKCount)) + } + if txn.DeviceLists != nil { + ctx.Int("device_changes", len(txn.DeviceLists.Changed)) + } else if txn.MSC3202DeviceLists != nil { + ctx.Int("unstable_device_changes", len(txn.MSC3202DeviceLists.Changed)) + } + if txn.FallbackKeys != nil { + ctx.Int("fallback_key_users", len(txn.FallbackKeys)) + } else if txn.MSC3202FallbackKeys != nil { + ctx.Int("unstable_fallback_key_users", len(txn.MSC3202FallbackKeys)) + } +} + +func (txn *Transaction) ContentString() string { + var parts []string + if len(txn.Events) > 0 { + parts = append(parts, fmt.Sprintf("%d PDUs", len(txn.Events))) + } + if len(txn.EphemeralEvents) > 0 { + parts = append(parts, fmt.Sprintf("%d EDUs", len(txn.EphemeralEvents))) + } else if len(txn.MSC2409EphemeralEvents) > 0 { + parts = append(parts, fmt.Sprintf("%d EDUs (unstable)", len(txn.MSC2409EphemeralEvents))) + } + if len(txn.ToDeviceEvents) > 0 { + parts = append(parts, fmt.Sprintf("%d to-device events", len(txn.ToDeviceEvents))) + } else if len(txn.MSC2409ToDeviceEvents) > 0 { + parts = append(parts, fmt.Sprintf("%d to-device events (unstable)", len(txn.MSC2409ToDeviceEvents))) + } + if len(txn.DeviceOTKCount) > 0 { + parts = append(parts, fmt.Sprintf("OTK counts for %d users", len(txn.DeviceOTKCount))) + } else if len(txn.MSC3202DeviceOTKCount) > 0 { + parts = append(parts, fmt.Sprintf("OTK counts for %d users (unstable)", len(txn.MSC3202DeviceOTKCount))) + } + if txn.DeviceLists != nil { + parts = append(parts, fmt.Sprintf("%d device list changes", len(txn.DeviceLists.Changed))) + } else if txn.MSC3202DeviceLists != nil { + parts = append(parts, fmt.Sprintf("%d device list changes (unstable)", len(txn.MSC3202DeviceLists.Changed))) + } + if txn.FallbackKeys != nil { + parts = append(parts, fmt.Sprintf("unused fallback key counts for %d users", len(txn.FallbackKeys))) + } else if txn.MSC3202FallbackKeys != nil { + parts = append(parts, fmt.Sprintf("unused fallback key counts for %d users (unstable)", len(txn.MSC3202FallbackKeys))) + } + return strings.Join(parts, ", ") +} + +// EventListener is a function that receives events. +type EventListener func(evt *event.Event) diff --git a/mautrix-patched/appservice/registration.go b/mautrix-patched/appservice/registration.go new file mode 100644 index 00000000..54eff716 --- /dev/null +++ b/mautrix-patched/appservice/registration.go @@ -0,0 +1,101 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "os" + "regexp" + + "go.mau.fi/util/random" + "gopkg.in/yaml.v3" +) + +// Registration contains the data in a Matrix appservice registration. +// See https://spec.matrix.org/v1.2/application-service-api/#registration +type Registration struct { + ID string `yaml:"id" json:"id"` + URL string `yaml:"url" json:"url"` + AppToken string `yaml:"as_token" json:"as_token"` + ServerToken string `yaml:"hs_token" json:"hs_token"` + SenderLocalpart string `yaml:"sender_localpart" json:"sender_localpart"` + RateLimited *bool `yaml:"rate_limited,omitempty" json:"rate_limited,omitempty"` + Namespaces Namespaces `yaml:"namespaces" json:"namespaces"` + Protocols []string `yaml:"protocols,omitempty" json:"protocols,omitempty"` + + SoruEphemeralEvents bool `yaml:"de.sorunome.msc2409.push_ephemeral,omitempty" json:"de.sorunome.msc2409.push_ephemeral,omitempty"` + EphemeralEvents bool `yaml:"receive_ephemeral,omitempty" json:"receive_ephemeral,omitempty"` + MSC3202 bool `yaml:"org.matrix.msc3202,omitempty" json:"org.matrix.msc3202,omitempty"` + MSC4190 bool `yaml:"io.element.msc4190,omitempty" json:"io.element.msc4190,omitempty"` +} + +// CreateRegistration creates a Registration with random appservice and homeserver tokens. +func CreateRegistration() *Registration { + return &Registration{ + AppToken: random.String(64), + ServerToken: random.String(64), + } +} + +// LoadRegistration loads a YAML file and turns it into a Registration. +func LoadRegistration(path string) (*Registration, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + reg := &Registration{} + err = yaml.Unmarshal(data, reg) + if err != nil { + return nil, err + } + return reg, nil +} + +// Save saves this Registration into a file at the given path. +func (reg *Registration) Save(path string) error { + data, err := yaml.Marshal(reg) + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +// YAML returns the registration in YAML format. +func (reg *Registration) YAML() (string, error) { + data, err := yaml.Marshal(reg) + if err != nil { + return "", err + } + return string(data), nil +} + +// Namespaces contains the three areas that appservices can reserve parts of. +type Namespaces struct { + UserIDs NamespaceList `yaml:"users,omitempty" json:"users,omitempty"` + RoomAliases NamespaceList `yaml:"aliases,omitempty" json:"aliases,omitempty"` + RoomIDs NamespaceList `yaml:"rooms,omitempty" json:"rooms,omitempty"` +} + +// Namespace is a reserved namespace in any area. +type Namespace struct { + Regex string `yaml:"regex" json:"regex"` + Exclusive bool `yaml:"exclusive" json:"exclusive"` +} + +type NamespaceList []Namespace + +func (nsl *NamespaceList) Register(regex *regexp.Regexp, exclusive bool) { + ns := Namespace{ + Regex: regex.String(), + Exclusive: exclusive, + } + if nsl == nil { + *nsl = []Namespace{ns} + } else { + *nsl = append(*nsl, ns) + } +} diff --git a/mautrix-patched/appservice/txnid.go b/mautrix-patched/appservice/txnid.go new file mode 100644 index 00000000..213703c5 --- /dev/null +++ b/mautrix-patched/appservice/txnid.go @@ -0,0 +1,43 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import "sync" + +type TransactionIDCache struct { + array []string + arrayPtr int + hash map[string]struct{} + lock sync.RWMutex +} + +func NewTransactionIDCache(size int) *TransactionIDCache { + return &TransactionIDCache{ + array: make([]string, size), + hash: make(map[string]struct{}), + } +} + +func (txnIDC *TransactionIDCache) IsProcessed(txnID string) bool { + txnIDC.lock.RLock() + _, exists := txnIDC.hash[txnID] + txnIDC.lock.RUnlock() + return exists +} + +func (txnIDC *TransactionIDCache) MarkProcessed(txnID string) { + txnIDC.lock.Lock() + txnIDC.hash[txnID] = struct{}{} + if txnIDC.array[txnIDC.arrayPtr] != "" { + for i := 0; i < len(txnIDC.array)/8; i++ { + delete(txnIDC.hash, txnIDC.array[txnIDC.arrayPtr+i]) + txnIDC.array[txnIDC.arrayPtr+i] = "" + } + } + txnIDC.array[txnIDC.arrayPtr] = txnID + txnIDC.lock.Unlock() +} diff --git a/mautrix-patched/appservice/websocket.go b/mautrix-patched/appservice/websocket.go new file mode 100644 index 00000000..ef65e65a --- /dev/null +++ b/mautrix-patched/appservice/websocket.go @@ -0,0 +1,455 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package appservice + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "sync" + "sync/atomic" + + "github.com/coder/websocket" + "github.com/rs/zerolog" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "maunium.net/go/mautrix" +) + +type WebsocketRequest struct { + ReqID int `json:"id,omitempty"` + Command string `json:"command"` + Data any `json:"data"` +} + +type WebsocketCommand struct { + ReqID int `json:"id,omitempty"` + Command string `json:"command"` + Data json.RawMessage `json:"data"` + + Ctx context.Context `json:"-"` +} + +func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest { + if wsc.ReqID == 0 || wsc.Command == "response" || wsc.Command == "error" { + return nil + } + cmd := "response" + if !ok { + cmd = "error" + } + if err, isError := data.(error); isError { + var errorData json.RawMessage + var jsonErr error + unwrappedErr := err + var prefixMessage string + for unwrappedErr != nil { + errorData, jsonErr = json.Marshal(unwrappedErr) + if len(errorData) > 2 && jsonErr == nil { + prefixMessage = strings.Replace(err.Error(), unwrappedErr.Error(), "", 1) + prefixMessage = strings.TrimRight(prefixMessage, ": ") + break + } + unwrappedErr = errors.Unwrap(unwrappedErr) + } + if errorData != nil { + if !gjson.GetBytes(errorData, "message").Exists() { + errorData, _ = sjson.SetBytes(errorData, "message", err.Error()) + } // else: marshaled error contains a message already + } else { + errorData, _ = sjson.SetBytes(nil, "message", err.Error()) + } + if len(prefixMessage) > 0 { + errorData, _ = sjson.SetBytes(errorData, "prefix_message", prefixMessage) + } + data = errorData + } + return &WebsocketRequest{ + ReqID: wsc.ReqID, + Command: cmd, + Data: data, + } +} + +type WebsocketTransaction struct { + Status string `json:"status"` + TxnID string `json:"txn_id"` + Transaction +} + +type WebsocketTransactionResponse struct { + TxnID string `json:"txn_id"` +} + +type WebsocketMessage struct { + WebsocketTransaction + WebsocketCommand +} + +const ( + WebsocketCloseConnReplaced websocket.StatusCode = 4001 + WebsocketCloseTxnNotAcknowledged websocket.StatusCode = 4002 +) + +type MeowWebsocketCloseCode string + +const ( + MeowServerShuttingDown MeowWebsocketCloseCode = "server_shutting_down" + MeowConnectionReplaced MeowWebsocketCloseCode = "conn_replaced" + MeowTxnNotAcknowledged MeowWebsocketCloseCode = "transactions_not_acknowledged" +) + +var ( + ErrWebsocketManualStop = errors.New("the websocket was disconnected manually") + ErrWebsocketOverridden = errors.New("a new call to StartWebsocket overrode the previous connection") + ErrWebsocketUnknownError = errors.New("an unknown error occurred") + + ErrWebsocketNotConnected = errors.New("websocket not connected") + ErrWebsocketClosed = errors.New("websocket closed before response received") +) + +func (mwcc MeowWebsocketCloseCode) String() string { + switch mwcc { + case MeowServerShuttingDown: + return "the server is shutting down" + case MeowConnectionReplaced: + return "the connection was replaced by another client" + case MeowTxnNotAcknowledged: + return "transactions were not acknowledged" + default: + return string(mwcc) + } +} + +type CloseCommand struct { + Code websocket.StatusCode `json:"-"` + Command string `json:"command"` + Status MeowWebsocketCloseCode `json:"status"` +} + +func (cc CloseCommand) Error() string { + return fmt.Sprintf("websocket: close %d: %s", cc.Code, cc.Status.String()) +} + +func parseCloseError(err error) error { + var closeError websocket.CloseError + if !errors.As(err, &closeError) { + return err + } + var closeCommand CloseCommand + closeCommand.Code = closeError.Code + closeCommand.Command = "disconnect" + if len(closeError.Reason) > 0 { + jsonErr := json.Unmarshal([]byte(closeError.Reason), &closeCommand) + if jsonErr != nil { + return err + } + } + if len(closeCommand.Status) == 0 { + if closeCommand.Code == WebsocketCloseConnReplaced { + closeCommand.Status = MeowConnectionReplaced + } else if closeCommand.Code == websocket.StatusServiceRestart { + closeCommand.Status = MeowServerShuttingDown + } + } + return &closeCommand +} + +func (as *AppService) HasWebsocket() bool { + return as.ws != nil +} + +func (as *AppService) SendWebsocket(ctx context.Context, cmd *WebsocketRequest) error { + ws := as.ws + if cmd == nil { + return nil + } else if ws == nil { + return ErrWebsocketNotConnected + } + wr, err := ws.Writer(ctx, websocket.MessageText) + if err != nil { + return err + } + err = json.NewEncoder(wr).Encode(cmd) + if err != nil { + _ = wr.Close() + return err + } + return wr.Close() +} + +func (as *AppService) clearWebsocketResponseWaiters() { + as.websocketRequestsLock.Lock() + for _, waiter := range as.websocketRequests { + waiter <- &WebsocketCommand{Command: "__websocket_closed"} + } + as.websocketRequests = make(map[int]chan<- *WebsocketCommand) + as.websocketRequestsLock.Unlock() +} + +func (as *AppService) addWebsocketResponseWaiter(reqID int, waiter chan<- *WebsocketCommand) { + as.websocketRequestsLock.Lock() + as.websocketRequests[reqID] = waiter + as.websocketRequestsLock.Unlock() +} + +func (as *AppService) removeWebsocketResponseWaiter(reqID int, waiter chan<- *WebsocketCommand) { + as.websocketRequestsLock.Lock() + existingWaiter, ok := as.websocketRequests[reqID] + if ok && existingWaiter == waiter { + delete(as.websocketRequests, reqID) + } + close(waiter) + as.websocketRequestsLock.Unlock() +} + +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (er *ErrorResponse) Error() string { + return fmt.Sprintf("%s: %s", er.Code, er.Message) +} + +func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response any) error { + cmd.ReqID = int(atomic.AddInt32(&as.websocketRequestID, 1)) + respChan := make(chan *WebsocketCommand, 1) + as.addWebsocketResponseWaiter(cmd.ReqID, respChan) + defer as.removeWebsocketResponseWaiter(cmd.ReqID, respChan) + err := as.SendWebsocket(ctx, cmd) + if err != nil { + return err + } + select { + case resp := <-respChan: + if resp.Command == "__websocket_closed" { + return ErrWebsocketClosed + } else if resp.Command == "error" { + var respErr ErrorResponse + err = json.Unmarshal(resp.Data, &respErr) + if err != nil { + return fmt.Errorf("failed to parse error JSON: %w", err) + } + return &respErr + } else if response != nil { + err = json.Unmarshal(resp.Data, &response) + if err != nil { + return fmt.Errorf("failed to parse response JSON: %w", err) + } + return nil + } else { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } +} + +func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, any) { + zerolog.Ctx(cmd.Ctx).Warn().Msg("No handler for websocket command") + return false, fmt.Errorf("unknown request type") +} + +func (as *AppService) SetWebsocketCommandHandler(cmd string, handler WebsocketHandler) { + as.websocketHandlersLock.Lock() + as.websocketHandlers[cmd] = handler + as.websocketHandlersLock.Unlock() +} + +type WebsocketTransactionHandler func(ctx context.Context, msg WebsocketMessage) (bool, any) + +func (as *AppService) defaultHandleWebsocketTransaction(ctx context.Context, msg WebsocketMessage) (bool, any) { + if msg.TxnID == "" || !as.txnIDC.IsProcessed(msg.TxnID) { + as.handleTransaction(ctx, msg.TxnID, &msg.Transaction) + } else { + zerolog.Ctx(ctx).Debug(). + Object("content", &msg.Transaction). + Msg("Ignoring duplicate transaction") + } + return true, &WebsocketTransactionResponse{TxnID: msg.TxnID} +} + +func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error), ws *websocket.Conn) { + defer stopFunc(ErrWebsocketUnknownError) + for { + msgType, reader, err := ws.Reader(ctx) + if err != nil { + as.Log.Debug().Err(err).Msg("Error getting reader from websocket") + stopFunc(parseCloseError(err)) + return + } else if msgType != websocket.MessageText { + as.Log.Debug().Msg("Ignoring non-text message from websocket") + continue + } + data, err := io.ReadAll(reader) + if err != nil { + as.Log.Debug().Err(err).Msg("Error reading data from websocket") + stopFunc(parseCloseError(err)) + return + } + var msg WebsocketMessage + err = json.Unmarshal(data, &msg) + if err != nil { + as.Log.Debug().Err(err).Msg("Error parsing JSON received from websocket") + stopFunc(parseCloseError(err)) + return + } + with := as.Log.With(). + Int("req_id", msg.ReqID). + Str("ws_command", msg.Command) + if msg.TxnID != "" { + with = with.Str("transaction_id", msg.TxnID) + } + log := with.Logger() + ctx := log.WithContext(ctx) + if msg.Command == "" || msg.Command == "transaction" { + ok, resp := as.WebsocketTransactionHandler(ctx, msg) + go func() { + err := as.SendWebsocket(ctx, msg.MakeResponse(ok, resp)) + if err != nil { + log.Warn().Err(err).Msg("Failed to send response to websocket transaction") + } else { + log.Debug().Msg("Sent response to transaction") + } + }() + } else if msg.Command == "connect" { + log.Debug().Msg("Websocket connect confirmation received") + } else if msg.Command == "response" || msg.Command == "error" { + as.websocketRequestsLock.RLock() + respChan, ok := as.websocketRequests[msg.ReqID] + if ok { + select { + case respChan <- &msg.WebsocketCommand: + default: + log.Warn().Msg("Failed to handle response: channel didn't accept response") + } + } else { + log.Warn().Msg("Dropping response to unknown request ID") + } + as.websocketRequestsLock.RUnlock() + } else { + log.Debug().Msg("Received websocket command") + as.websocketHandlersLock.RLock() + handler, ok := as.websocketHandlers[msg.Command] + as.websocketHandlersLock.RUnlock() + if !ok { + handler = as.unknownCommandHandler + } + go func() { + okResp, data := handler(msg.WebsocketCommand) + err := as.SendWebsocket(ctx, msg.MakeResponse(okResp, data)) + if err != nil { + log.Error().Err(err).Msg("Failed to send response to websocket command") + } else if okResp { + log.Debug().Msg("Sent success response to websocket command") + } else { + log.Debug().Msg("Sent error response to websocket command") + } + }() + } + } +} + +func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConnect func()) error { + var parsed *url.URL + if baseURL != "" { + var err error + parsed, err = url.Parse(baseURL) + if err != nil { + return fmt.Errorf("failed to parse URL: %w", err) + } + } else { + copiedURL := *as.hsURLForClient + parsed = &copiedURL + } + parsed.Path = path.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync") + if parsed.Scheme == "http" { + parsed.Scheme = "ws" + } else if parsed.Scheme == "https" { + parsed.Scheme = "wss" + } + ws, resp, err := websocket.Dial(ctx, parsed.String(), &websocket.DialOptions{ + HTTPClient: as.HTTPClient, + HTTPHeader: http.Header{ + "Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)}, + "User-Agent": []string{as.BotClient().UserAgent}, + + "X-Mautrix-Process-ID": []string{as.ProcessID}, + "X-Mautrix-Websocket-Version": []string{"3"}, + }, + }) + if resp != nil && resp.StatusCode >= 400 { + var errResp mautrix.RespError + err = json.NewDecoder(resp.Body).Decode(&errResp) + if err != nil { + return fmt.Errorf("websocket request returned HTTP %d with non-JSON body", resp.StatusCode) + } else { + return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrCode, resp.StatusCode, errResp.Err) + } + } else if err != nil { + return fmt.Errorf("failed to open websocket: %w", err) + } + if as.StopWebsocket != nil { + as.StopWebsocket(ErrWebsocketOverridden) + } + closeChan := make(chan error) + closeChanOnce := sync.Once{} + stopFunc := func(err error) { + closeChanOnce.Do(func() { + select { + case closeChan <- err: + default: + as.Log.Warn(). + AnErr("close_error", err). + Msg("Nothing is reading on close channel") + closeChan <- err + as.Log.Warn().Msg("Websocket close completed after being stuck") + } + }) + } + ws.SetReadLimit(50 * 1024 * 1024) + as.ws = ws + as.StopWebsocket = stopFunc + as.PrepareWebsocket() + as.Log.Debug().Msg("Appservice transaction websocket opened") + + go as.consumeWebsocket(ctx, stopFunc, ws) + + var onConnectDone atomic.Bool + if onConnect != nil { + go func() { + onConnect() + onConnectDone.Store(true) + }() + } else { + onConnectDone.Store(true) + } + + closeErr := <-closeChan + if !onConnectDone.Load() { + as.Log.Warn().Msg("Websocket closed before onConnect returned, things may explode") + } + + if as.ws == ws { + as.clearWebsocketResponseWaiters() + as.ws = nil + } + + err = ws.Close(websocket.StatusGoingAway, "") + if err != nil { + as.Log.Warn().Err(err).Msg("Error closing websocket") + } + return closeErr +} diff --git a/mautrix-patched/appservice/wshttp.go b/mautrix-patched/appservice/wshttp.go new file mode 100644 index 00000000..60741e67 --- /dev/null +++ b/mautrix-patched/appservice/wshttp.go @@ -0,0 +1,98 @@ +package appservice + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +const WebsocketCommandHTTPProxy = "http_proxy" + +type HTTPProxyRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Headers http.Header `json:"headers"` + Body json.RawMessage `json:"body"` + + EscapedPath bool `json:"escaped_path,omitempty"` +} + +type HTTPProxyResponse struct { + Status int `json:"status"` + Headers http.Header `json:"headers"` + Body json.RawMessage `json:"body"` + + bodyBuf bytes.Buffer +} + +func (p *HTTPProxyResponse) Header() http.Header { + return p.Headers +} + +func (p *HTTPProxyResponse) Write(bytes []byte) (int, error) { + if p.Status == 0 { + p.Status = http.StatusOK + } + return p.bodyBuf.Write(bytes) +} + +func (p *HTTPProxyResponse) WriteHeader(statusCode int) { + p.Status = statusCode +} + +func (as *AppService) WebsocketHTTPProxy(cmd WebsocketCommand) (bool, interface{}) { + var req HTTPProxyRequest + if err := json.Unmarshal(cmd.Data, &req); err != nil { + return false, fmt.Errorf("failed to parse proxy request: %w", err) + } + if cmd.Ctx == nil { + cmd.Ctx = context.Background() + } + reqURLStruct := &url.URL{ + Scheme: "http", + Host: "localhost", + Path: req.Path, + RawQuery: req.Query, + } + if req.EscapedPath { + reqURLStruct.RawPath = req.Path + var err error + reqURLStruct.Path, err = url.PathUnescape(req.Path) + if err != nil { + return false, fmt.Errorf("failed to unescape request path: %w", err) + } + } + httpReq, err := http.NewRequestWithContext(cmd.Ctx, req.Method, reqURLStruct.String(), bytes.NewReader(req.Body)) + if err != nil { + return false, fmt.Errorf("failed to create fake HTTP request: %w", err) + } + httpReq.RequestURI = req.Path + if req.Query != "" { + httpReq.RequestURI += "?" + req.Query + } + httpReq.RemoteAddr = "websocket" + httpReq.Header = req.Headers + + var resp HTTPProxyResponse + resp.Headers = make(http.Header) + + as.Router.ServeHTTP(&resp, httpReq) + + if resp.bodyBuf.Len() > 0 { + bodyData := resp.bodyBuf.Bytes() + if json.Valid(bodyData) { + resp.Body = bodyData + } else { + resp.Body = make([]byte, 2+base64.RawStdEncoding.EncodedLen(len(bodyData))) + resp.Body[0] = '"' + base64.RawStdEncoding.Encode(resp.Body[1:], bodyData) + resp.Body[len(resp.Body)-1] = '"' + } + } + return true, &resp +} diff --git a/mautrix-patched/beeperstream/crypto.go b/mautrix-patched/beeperstream/crypto.go new file mode 100644 index 00000000..f4af80e2 --- /dev/null +++ b/mautrix-patched/beeperstream/crypto.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Batuhan İçöz +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package beeperstream + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + + "go.mau.fi/util/jsonbytes" + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type innerPayload struct { + Type string `json:"type"` + Content json.RawMessage `json:"content"` +} + +func makeStreamKey() jsonbytes.UnpaddedBytes { + return random.Bytes(32) +} + +func newStreamGCM(key []byte) (cipher.AEAD, error) { + if len(key) != 32 { + return nil, fmt.Errorf("invalid stream key length %d", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func deriveStreamID(key []byte, roomID id.RoomID, eventID id.EventID) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(roomID)) + mac.Write([]byte(eventID)) + return base64.RawStdEncoding.EncodeToString(mac.Sum(nil)) +} + +func encryptLogicalEvent(logicalType event.Type, payload json.RawMessage, roomID id.RoomID, eventID id.EventID, key []byte) (*event.Content, error) { + if roomID == "" || eventID == "" { + return nil, fmt.Errorf("missing beeper stream identifiers") + } + gcm, err := newStreamGCM(key) + if err != nil { + return nil, err + } + plaintext, err := json.Marshal(innerPayload{ + Type: logicalType.Type, + Content: payload, + }) + if err != nil { + return nil, err + } + iv := random.Bytes(gcm.NonceSize()) + ciphertext := gcm.Seal(nil, iv, plaintext, nil) + return &event.Content{ + Parsed: &event.EncryptedEventContent{ + Algorithm: id.AlgorithmBeeperStreamV1, + StreamID: deriveStreamID(key, roomID, eventID), + IV: iv, + BeeperStreamCiphertext: ciphertext, + }, + }, nil +} + +func decryptLogicalEvent(content *event.Content, key []byte) (event.Type, json.RawMessage, error) { + gcm, err := newStreamGCM(key) + if err != nil { + return event.Type{}, nil, err + } + encrypted := content.AsEncrypted() + if encrypted.Algorithm == "" && content != nil && content.Parsed == nil && len(content.VeryRaw) > 0 { + if err = content.ParseRaw(event.ToDeviceEncrypted); err != nil { + return event.Type{}, nil, err + } + encrypted = content.AsEncrypted() + } + if len(encrypted.IV) != gcm.NonceSize() { + return event.Type{}, nil, fmt.Errorf("invalid beeper stream IV length %d", len(encrypted.IV)) + } + plaintext, err := gcm.Open(nil, encrypted.IV, encrypted.BeeperStreamCiphertext, nil) + if err != nil { + return event.Type{}, nil, err + } + var payload innerPayload + if err = json.Unmarshal(plaintext, &payload); err != nil { + return event.Type{}, nil, err + } + switch payload.Type { + case event.ToDeviceBeeperStreamSubscribe.Type: + return event.ToDeviceBeeperStreamSubscribe, payload.Content, nil + case event.ToDeviceBeeperStreamUpdate.Type: + return event.ToDeviceBeeperStreamUpdate, payload.Content, nil + default: + return event.Type{}, nil, fmt.Errorf("unknown beeper stream event type %q", payload.Type) + } +} diff --git a/mautrix-patched/beeperstream/helper.go b/mautrix-patched/beeperstream/helper.go new file mode 100644 index 00000000..672ec994 --- /dev/null +++ b/mautrix-patched/beeperstream/helper.go @@ -0,0 +1,462 @@ +// Copyright (c) 2026 Batuhan İçöz +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package beeperstream + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const ( + // DefaultDescriptorExpiry is the default lifetime of a beeper stream descriptor. + DefaultDescriptorExpiry = 30 * time.Minute + // DefaultSubscribeExpiry is the default lifetime of a beeper stream subscription. + DefaultSubscribeExpiry = 5 * time.Minute + + defaultRenewInterval = 30 * time.Second + cleanupGrace = 30 * time.Second + pendingSubscribeTTL = 5 * time.Second + maxPendingSubscriptions = 64 + maxStreamUpdatesPerStream = 1024 +) + +type Helper struct { + client *mautrix.Client + log zerolog.Logger + + lock sync.RWMutex + publishedStreams map[streamKey]*publishedStream + encryptedPubl map[string]streamKey + subscriptions map[streamKey]*subscription + encryptedSubs map[string]streamKey + + pendingLock sync.Mutex + pendingSubscribe []pendingSubscribeEvent + + initLock sync.Mutex + initialized bool + closed atomic.Bool + + now func() time.Time +} + +type streamKey struct { + roomID id.RoomID + eventID id.EventID +} + +func New(client *mautrix.Client) (*Helper, error) { + if client == nil { + return nil, fmt.Errorf("beeper stream client is nil") + } + return &Helper{ + client: client, + log: client.Log.With().Str("component", "beeper_stream").Logger(), + publishedStreams: make(map[streamKey]*publishedStream), + encryptedPubl: make(map[string]streamKey), + subscriptions: make(map[streamKey]*subscription), + encryptedSubs: make(map[string]streamKey), + now: time.Now, + }, nil +} + +// Init attaches beeper stream handling to a normal /sync-based client. +func (h *Helper) Init() error { + if h == nil { + return fmt.Errorf("beeper stream helper is nil") + } else if h.closed.Load() { + return fmt.Errorf("beeper stream helper is closed") + } + h.initLock.Lock() + defer h.initLock.Unlock() + if h.initialized { + return nil + } + syncer, ok := h.client.Syncer.(mautrix.ExtensibleSyncer) + if !ok { + return fmt.Errorf("the client syncer must implement ExtensibleSyncer") + } + h.registerIngressAdapter(syncer.OnEventType) + h.initialized = true + return nil +} + +// InitAppservice attaches beeper stream handling to an appservice event processor. +func (h *Helper) InitAppservice(ep interface { + On(event.Type, mautrix.EventHandler) +}) error { + if h == nil { + return fmt.Errorf("beeper stream helper is nil") + } else if h.closed.Load() { + return fmt.Errorf("beeper stream helper is closed") + } else if ep == nil { + return fmt.Errorf("beeper stream appservice event processor is nil") + } + h.initLock.Lock() + defer h.initLock.Unlock() + if h.initialized { + return nil + } + h.registerIngressAdapter(ep.On) + h.initialized = true + return nil +} + +func (h *Helper) registerIngressAdapter( + on func(event.Type, mautrix.EventHandler), +) { + on(event.ToDeviceBeeperStreamSubscribe, h.handleSubscribeEvent) + on(event.ToDeviceBeeperStreamUpdate, h.handleIngressEvent) + on(event.ToDeviceEncrypted, h.handleIngressEvent) +} + +func (h *Helper) Close() error { + if h == nil || !h.closed.CompareAndSwap(false, true) { + return nil + } + + h.lock.Lock() + for key, sub := range h.subscriptions { + sub.cancel() + delete(h.subscriptions, key) + } + clear(h.encryptedSubs) + for key, state := range h.publishedStreams { + if state.cleanup != nil { + state.cleanup.Stop() + } + delete(h.publishedStreams, key) + } + clear(h.encryptedPubl) + h.lock.Unlock() + + h.pendingLock.Lock() + h.pendingSubscribe = nil + h.pendingLock.Unlock() + + return nil +} + +func (h *Helper) NewDescriptor(ctx context.Context, roomID id.RoomID, streamType string) (*event.BeeperStreamInfo, error) { + if h == nil { + return nil, fmt.Errorf("beeper stream helper is nil") + } else if h.closed.Load() { + return nil, fmt.Errorf("beeper stream helper is closed") + } else if roomID == "" || streamType == "" { + return nil, fmt.Errorf("missing beeper stream descriptor request fields") + } + client, err := h.requireClient(false) + if err != nil { + return nil, err + } + info := &event.BeeperStreamInfo{ + UserID: client.UserID, + DeviceID: client.DeviceID, + Type: streamType, + ExpiryMS: DefaultDescriptorExpiry.Milliseconds(), + } + if h.isEncrypted(ctx, roomID) { + info.Encryption = &event.BeeperStreamEncryptionInfo{ + Algorithm: id.AlgorithmBeeperStreamV1, + Key: makeStreamKey(), + } + } + return info, nil +} + +func (h *Helper) HandleSyncResponse(ctx context.Context, resp *mautrix.RespSync) []*event.Event { + if h == nil || resp == nil { + return nil + } + var normalized []*event.Event + for _, evt := range resp.ToDevice.Events { + prepareToDeviceEvent(evt) + normalized = append(normalized, h.handleEvent(ctx, evt)...) + } + return normalized +} + +func (h *Helper) handleIngressEvent(ctx context.Context, evt *event.Event) { + h.handleEvent(ctx, evt) +} + +func prepareToDeviceEvent(evt *event.Event) { + if evt == nil { + return + } + evt.Type.Class = event.ToDeviceEventType + if len(evt.Content.VeryRaw) > 0 && evt.Content.Raw == nil { + _ = json.Unmarshal(evt.Content.VeryRaw, &evt.Content.Raw) + } + if evt.Content.Parsed != nil || len(evt.Content.VeryRaw) == 0 { + return + } + err := evt.Content.ParseRaw(evt.Type) + if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + evt.Content.Parsed = nil + } +} + +func (h *Helper) handleEvent(ctx context.Context, evt *event.Event) []*event.Event { + if h == nil || evt == nil || h.closed.Load() || h.isForDifferentTarget(evt) { + return nil + } + switch evt.Type { + case event.ToDeviceBeeperStreamSubscribe: + h.handleSubscribeEvent(ctx, evt) + case event.ToDeviceBeeperStreamUpdate: + return expandStreamUpdateEvent(evt) + case event.ToDeviceEncrypted: + return h.handleEncryptedEvent(ctx, evt) + } + return nil +} + +func (h *Helper) handleEncryptedEvent(ctx context.Context, evt *event.Event) []*event.Event { + content := evt.Content.AsEncrypted() + if content.Algorithm != id.AlgorithmBeeperStreamV1 { + return nil + } + if content.StreamID == "" || len(content.BeeperStreamCiphertext) == 0 { + return nil + } + h.lock.RLock() + publishedKey, hasPublished := h.encryptedPubl[content.StreamID] + published := h.publishedStreams[publishedKey] + subKey, hasSub := h.encryptedSubs[content.StreamID] + sub := h.subscriptions[subKey] + h.lock.RUnlock() + if hasPublished && published != nil { + return h.handleEncryptedForPublisher(ctx, evt, publishedKey, published) + } + if hasSub && sub != nil { + return h.handleEncryptedForSubscriber(ctx, evt, subKey, sub) + } + h.queuePendingSubscribe(ctx, evt) + return nil +} + +func decryptedLogicalEvent(ctx context.Context, evt *event.Event, key []byte, expectedKey streamKey, expectedTypes ...event.Type) *event.Event { + encrypted := evt.Content.AsEncrypted() + logicalType, payload, err := decryptLogicalEvent(&evt.Content, key) + if err != nil { + zerolog.Ctx(ctx).Debug().Err(err). + Str("stream_id", encrypted.StreamID). + Msg("Failed to decrypt beeper stream event") + return nil + } + if len(expectedTypes) > 0 && !containsType(expectedTypes, logicalType) { + return nil + } + parsed, err := contentFromRawJSON(payload) + if err != nil || parsed.ParseRaw(logicalType) != nil { + return nil + } + if !validateLogicalRouting(parsed, logicalType, expectedKey.roomID, expectedKey.eventID) { + return nil + } + normalized := *evt + normalized.RoomID = expectedKey.roomID + normalized.Type = logicalType + normalized.Type.Class = event.ToDeviceEventType + normalized.Content = *parsed + return &normalized +} + +func rawMapFromContent(content *event.Content) (map[string]any, error) { + if content == nil { + return map[string]any{}, nil + } + if content.Raw != nil { + return maps.Clone(content.Raw), nil + } + if len(content.VeryRaw) == 0 { + return map[string]any{}, nil + } + var raw map[string]any + err := json.Unmarshal(content.VeryRaw, &raw) + if raw == nil { + raw = make(map[string]any) + } + return raw, err +} + +func contentFromRawMap(raw map[string]any) (*event.Content, error) { + if raw == nil { + raw = make(map[string]any) + } + veryRaw, err := json.Marshal(raw) + if err != nil { + return nil, err + } + return &event.Content{VeryRaw: veryRaw, Raw: maps.Clone(raw)}, nil +} + +func contentFromRawJSON(veryRaw json.RawMessage) (*event.Content, error) { + content := &event.Content{VeryRaw: append(json.RawMessage(nil), veryRaw...)} + if len(content.VeryRaw) == 0 { + content.VeryRaw = []byte("{}") + } + if err := json.Unmarshal(content.VeryRaw, &content.Raw); err != nil { + return nil, err + } + if content.Raw == nil { + content.Raw = make(map[string]any) + } + return content, nil +} + +func marshalContent(content *event.Content) (json.RawMessage, error) { + if content == nil { + return json.RawMessage(`{}`), nil + } + raw, err := content.MarshalJSON() + if err != nil { + return nil, err + } + return append(json.RawMessage(nil), raw...), nil +} + +func normalizeUpdateContent(roomID id.RoomID, eventID id.EventID, content map[string]any) (*event.Content, error) { + if roomID == "" || eventID == "" { + return nil, fmt.Errorf("missing beeper stream identifiers") + } + raw := maps.Clone(content) + if _, ok := raw["room_id"]; ok { + return nil, fmt.Errorf("beeper stream payload may not override room_id") + } else if _, ok := raw["event_id"]; ok { + return nil, fmt.Errorf("beeper stream payload may not override event_id") + } + raw["room_id"] = roomID + raw["event_id"] = eventID + return contentFromRawMap(raw) +} + +func stripUpdateRouting(content map[string]any) map[string]any { + raw := maps.Clone(content) + delete(raw, "room_id") + delete(raw, "event_id") + delete(raw, "updates") + return raw +} + +func expandStreamUpdateEvent(evt *event.Event) []*event.Event { + if evt == nil { + return nil + } + update := evt.Content.AsBeeperStreamUpdate() + if len(update.Updates) == 0 { + return []*event.Event{evt} + } + expanded := make([]*event.Event, 0, len(update.Updates)) + for _, delta := range update.Updates { + content, err := normalizeUpdateContent(update.RoomID, update.EventID, delta) + if err != nil { + return nil + } + if err = content.ParseRaw(event.ToDeviceBeeperStreamUpdate); err != nil { + return nil + } + normalized := *evt + normalized.Content = *content + expanded = append(expanded, &normalized) + } + return expanded +} + +func containsType(types []event.Type, want event.Type) bool { + for _, candidate := range types { + if candidate == want { + return true + } + } + return false +} + +func validateLogicalRouting(content *event.Content, evtType event.Type, roomID id.RoomID, eventID id.EventID) bool { + switch evtType { + case event.ToDeviceBeeperStreamSubscribe: + subscribe := content.AsBeeperStreamSubscribe() + return subscribe.RoomID == roomID && subscribe.EventID == eventID + case event.ToDeviceBeeperStreamUpdate: + update := content.AsBeeperStreamUpdate() + return update.RoomID == roomID && update.EventID == eventID + default: + return false + } +} + +func (h *Helper) isEncrypted(ctx context.Context, roomID id.RoomID) bool { + if h == nil || h.client == nil || h.client.StateStore == nil { + return false + } + encrypted, err := h.client.StateStore.IsEncrypted(ctx, roomID) + return err == nil && encrypted +} + +func (h *Helper) isForDifferentTarget(evt *event.Event) bool { + if h == nil || h.client == nil || evt == nil { + return false + } + return (evt.ToUserID != "" && evt.ToUserID != h.client.UserID) || + (evt.ToDeviceID != "" && evt.ToDeviceID != h.client.DeviceID) +} + +func (h *Helper) requireClient(requireDevice bool) (*mautrix.Client, error) { + if h == nil || h.client == nil { + return nil, fmt.Errorf("beeper stream helper doesn't have a client") + } else if h.client.UserID == "" { + return nil, fmt.Errorf("beeper stream client isn't logged in") + } else if requireDevice && h.client.DeviceID == "" { + return nil, fmt.Errorf("beeper stream client doesn't have a device ID") + } + return h.client, nil +} + +func resolveSubscribeExpiry(descriptor *event.BeeperStreamInfo, fallback time.Duration) time.Duration { + if fallback <= 0 { + fallback = DefaultSubscribeExpiry + } + if descriptor != nil && descriptor.ExpiryMS > 0 { + return min(time.Duration(descriptor.ExpiryMS)*time.Millisecond, fallback) + } + return fallback +} + +func resolveMaxBufferedUpdates(descriptor *event.BeeperStreamInfo) int { + if descriptor != nil && descriptor.MaxBufferedUpdates > 0 { + return descriptor.MaxBufferedUpdates + } + return maxStreamUpdatesPerStream +} + +func descriptorEqual(a, b *event.BeeperStreamInfo) bool { + switch { + case a == nil || b == nil: + return a == b + case a.UserID != b.UserID || a.DeviceID != b.DeviceID || a.Type != b.Type || a.ExpiryMS != b.ExpiryMS || a.MaxBufferedUpdates != b.MaxBufferedUpdates: + return false + case a.Encryption == nil || b.Encryption == nil: + return a.Encryption == b.Encryption + default: + return a.Encryption.Algorithm == b.Encryption.Algorithm && + bytes.Equal(a.Encryption.Key, b.Encryption.Key) + } +} diff --git a/mautrix-patched/beeperstream/helper_test.go b/mautrix-patched/beeperstream/helper_test.go new file mode 100644 index 00000000..7ae7eb3f --- /dev/null +++ b/mautrix-patched/beeperstream/helper_test.go @@ -0,0 +1,824 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package beeperstream + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const ( + testStreamRoomID id.RoomID = "!room:example.com" + testStreamEventID id.EventID = "$event" + testStreamType = "com.beeper.llm" + testStreamBotUserID id.UserID = "@bot:example.com" + testStreamSubscriberID id.UserID = "@alice:example.com" + testStreamSubscriberDev id.DeviceID = "SUBDEVICE" + testStreamPublisherDev id.DeviceID = "PUBLISHER" + testStreamDeltaKey = "com.beeper.llm.deltas" +) + +type capturedSendToDeviceRequest struct { + path string + body []byte +} + +type sendToDeviceRecorder struct { + requests chan capturedSendToDeviceRequest +} + +type testEventProcessor struct { + handlers map[event.Type][]func(context.Context, *event.Event) +} + +func newTestEventProcessor() *testEventProcessor { + return &testEventProcessor{ + handlers: make(map[event.Type][]func(context.Context, *event.Event)), + } +} + +func (ep *testEventProcessor) On(evtType event.Type, handler func(context.Context, *event.Event)) { + ep.handlers[evtType] = append(ep.handlers[evtType], handler) +} + +func (ep *testEventProcessor) Dispatch(ctx context.Context, evt *event.Event) { + for _, handler := range ep.handlers[evt.Type] { + handler(ctx, evt) + } +} + +func newSendToDeviceRecorderServer(t *testing.T) (*httptest.Server, *sendToDeviceRecorder) { + t.Helper() + recorder := &sendToDeviceRecorder{ + requests: make(chan capturedSendToDeviceRequest, 8), + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + recorder.requests <- capturedSendToDeviceRequest{ + path: r.URL.Path, + body: body, + } + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(ts.Close) + return ts, recorder +} + +func (r *sendToDeviceRecorder) next(t *testing.T) capturedSendToDeviceRequest { + t.Helper() + select { + case req := <-r.requests: + return req + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for sendToDevice request") + return capturedSendToDeviceRequest{} + } +} + +func (r *sendToDeviceRecorder) rawContent(t *testing.T, req capturedSendToDeviceRequest, userID id.UserID, deviceID id.DeviceID) json.RawMessage { + t.Helper() + var payload struct { + Messages map[string]map[string]json.RawMessage `json:"messages"` + } + require.NoError(t, json.Unmarshal(req.body, &payload)) + require.Contains(t, payload.Messages, string(userID), "missing target user in request") + require.Contains(t, payload.Messages[string(userID)], string(deviceID), "missing target device in request") + return payload.Messages[string(userID)][string(deviceID)] +} + +func newTestStreamClient(t *testing.T, homeserverURL string, userID id.UserID, deviceID id.DeviceID) *mautrix.Client { + t.Helper() + client, err := mautrix.NewClient(homeserverURL, userID, "access-token") + require.NoError(t, err) + client.DeviceID = deviceID + client.StateStore = mautrix.NewMemoryStateStore() + return client +} + +func newTestHelper(t *testing.T, client *mautrix.Client) *Helper { + t.Helper() + helper, err := New(client) + require.NoError(t, err) + return helper +} + +func newTestPublishContent(delta string) map[string]any { + return map[string]any{ + testStreamDeltaKey: []map[string]any{{"delta": delta}}, + } +} + +func newTestSubscribeEvent(toUserID id.UserID, toDeviceID id.DeviceID) *event.Event { + return &event.Event{ + Sender: testStreamSubscriberID, + ToUserID: toUserID, + ToDeviceID: toDeviceID, + Type: event.ToDeviceBeeperStreamSubscribe, + Content: event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ + RoomID: testStreamRoomID, + EventID: testStreamEventID, + DeviceID: testStreamSubscriberDev, + ExpiryMS: 60_000, + }}, + } +} + +func newTestEncryptedSubscribeEvent(t *testing.T, descriptor *event.BeeperStreamInfo) *event.Event { + t.Helper() + payload, err := marshalContent(&event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ + RoomID: testStreamRoomID, + EventID: testStreamEventID, + DeviceID: testStreamSubscriberDev, + ExpiryMS: 60_000, + }}) + require.NoError(t, err) + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamSubscribe, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) + require.NoError(t, err) + return &event.Event{ + Sender: testStreamSubscriberID, + ToUserID: testStreamBotUserID, + ToDeviceID: testStreamPublisherDev, + Type: event.ToDeviceEncrypted, + Content: *encContent, + } +} + +func newTestDescriptor(encrypted bool) *event.BeeperStreamInfo { + descriptor := &event.BeeperStreamInfo{ + UserID: testStreamBotUserID, + DeviceID: testStreamPublisherDev, + Type: testStreamType, + ExpiryMS: DefaultDescriptorExpiry.Milliseconds(), + } + if encrypted { + descriptor.Encryption = &event.BeeperStreamEncryptionInfo{ + Algorithm: id.AlgorithmBeeperStreamV1, + Key: makeStreamKey(), + } + } + return descriptor +} + +func newTestEncryptedUpdateEvent(t *testing.T, descriptor *event.BeeperStreamInfo) *event.Event { + t.Helper() + content, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("hello")) + require.NoError(t, err) + payload, err := marshalContent(content) + require.NoError(t, err) + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) + require.NoError(t, err) + return &event.Event{ + Sender: testStreamBotUserID, + Type: event.ToDeviceEncrypted, + Content: *encContent, + } +} + +func rawifyEventContent(t *testing.T, evt *event.Event) *event.Event { + t.Helper() + require.NotNil(t, evt) + raw, err := evt.Content.MarshalJSON() + require.NoError(t, err) + return &event.Event{ + Sender: evt.Sender, + Type: evt.Type, + ToUserID: evt.ToUserID, + ToDeviceID: evt.ToDeviceID, + RoomID: evt.RoomID, + ID: evt.ID, + Content: event.Content{ + VeryRaw: raw, + }, + } +} + +func decodeJSONMap(t *testing.T, data []byte) map[string]any { + t.Helper() + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + return parsed +} + +func assertStreamUpdateMap(t *testing.T, parsed map[string]any) { + t.Helper() + require.Equal(t, string(testStreamRoomID), fmt.Sprint(parsed["room_id"])) + require.Equal(t, string(testStreamEventID), fmt.Sprint(parsed["event_id"])) + require.Contains(t, parsed, testStreamDeltaKey) +} + +func assertBatchedStreamUpdateMap(t *testing.T, parsed map[string]any, wantDeltas ...string) { + t.Helper() + require.Equal(t, string(testStreamRoomID), fmt.Sprint(parsed["room_id"])) + require.Equal(t, string(testStreamEventID), fmt.Sprint(parsed["event_id"])) + updates, ok := parsed["updates"].([]any) + require.True(t, ok) + require.Len(t, updates, len(wantDeltas)) + for i, want := range wantDeltas { + update, ok := updates[i].(map[string]any) + require.True(t, ok) + deltas, ok := update[testStreamDeltaKey].([]any) + require.True(t, ok) + require.Len(t, deltas, 1) + delta, ok := deltas[0].(map[string]any) + require.True(t, ok) + require.Equal(t, want, fmt.Sprint(delta["delta"])) + } +} + +func TestHelperNewDescriptor(t *testing.T) { + client := newTestStreamClient(t, "", testStreamBotUserID, testStreamPublisherDev) + require.NoError(t, client.StateStore.SetEncryptionEvent(context.Background(), testStreamRoomID, &event.EncryptionEventContent{ + Algorithm: id.AlgorithmMegolmV1, + })) + + info, err := newTestHelper(t, client).NewDescriptor(context.Background(), testStreamRoomID, testStreamType) + require.NoError(t, err) + require.Equal(t, testStreamBotUserID, info.UserID) + require.Equal(t, testStreamPublisherDev, info.DeviceID) + require.NotNil(t, info.Encryption) +} + +func TestHelperSubscribeTargetsDescriptorDevice(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(false) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.subscribe/") + _ = recorder.rawContent(t, req, testStreamBotUserID, testStreamPublisherDev) +} + +func TestHelperPublishAndUnregister(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) + require.NoError(t, err) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) + + require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") + assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) + + streams.Unregister(testStreamRoomID, testStreamEventID) + require.Error(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) +} + +func TestHelperRegisterSupportsLatestOnlyReplayBuffer(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(false) + descriptor.MaxBufferedUpdates = 1 + + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) + + require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) + + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") + rawContent := decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev)) + assertStreamUpdateMap(t, rawContent) + deltas, ok := rawContent[testStreamDeltaKey].([]any) + require.True(t, ok) + require.Len(t, deltas, 1) + delta, ok := deltas[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "bye", fmt.Sprint(delta["delta"])) + + select { + case extra := <-recorder.requests: + t.Fatalf("unexpected extra replay update: %s", extra.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperReplayPendingSubscribeBatchesBufferedUpdates(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) + + require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) + + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") + assertBatchedStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev)), "hello", "bye") + + select { + case extra := <-recorder.requests: + t.Fatalf("unexpected extra replay update: %s", extra.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperReplayPendingSubscribeOnRegister(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) + + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") + assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) +} + +func TestHelperReplayPendingEncryptedSubscribeOnRegister(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.Nil(t, streams.handleEvent(context.Background(), newTestEncryptedSubscribeEvent(t, descriptor))) + + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/m.room.encrypted/") + rawContent := recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev) + var encContent event.Content + require.NoError(t, json.Unmarshal(rawContent, &encContent)) + require.NoError(t, encContent.ParseRaw(event.ToDeviceEncrypted)) + require.Equal(t, deriveStreamID(descriptor.Encryption.Key, testStreamRoomID, testStreamEventID), encContent.AsEncrypted().StreamID) + logicalType, payloadContent, err := decryptLogicalEvent(&encContent, descriptor.Encryption.Key) + require.NoError(t, err) + require.Equal(t, event.ToDeviceBeeperStreamUpdate, logicalType) + var parsed map[string]any + require.NoError(t, json.Unmarshal(payloadContent, &parsed)) + assertStreamUpdateMap(t, parsed) +} + +func TestHelperReplayPendingEncryptedSubscribeBatchesBufferedUpdates(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) + + require.Nil(t, streams.handleEvent(context.Background(), newTestEncryptedSubscribeEvent(t, descriptor))) + + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/m.room.encrypted/") + rawContent := recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev) + var encContent event.Content + require.NoError(t, json.Unmarshal(rawContent, &encContent)) + require.NoError(t, encContent.ParseRaw(event.ToDeviceEncrypted)) + logicalType, payloadContent, err := decryptLogicalEvent(&encContent, descriptor.Encryption.Key) + require.NoError(t, err) + require.Equal(t, event.ToDeviceBeeperStreamUpdate, logicalType) + var parsed map[string]any + require.NoError(t, json.Unmarshal(payloadContent, &parsed)) + assertBatchedStreamUpdateMap(t, parsed, "hello", "bye") +} + +func TestHelperHandleSyncResponseForwardsBridgeSubscribe(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) + require.NoError(t, err) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) + + streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ + newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev), + }}, + }) + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") + assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) +} + +func TestHelperHandleSyncResponseIgnoresWrongDevice(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) + require.NoError(t, err) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) + + streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ + newTestSubscribeEvent(testStreamBotUserID, "OTHERDEVICE"), + }}, + }) + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + select { + case req := <-recorder.requests: + t.Fatalf("unexpected sendToDevice request: %s", req.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperInitAppserviceForwardsEncryptedBridgeSubscribe(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + processor := newTestEventProcessor() + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.InitAppservice(processor)) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + + processor.Dispatch(context.Background(), newTestEncryptedSubscribeEvent(t, descriptor)) + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/m.room.encrypted/") + rawContent := recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev) + var encContent event.Content + require.NoError(t, json.Unmarshal(rawContent, &encContent)) + require.NoError(t, encContent.ParseRaw(event.ToDeviceEncrypted)) + require.Equal(t, deriveStreamID(descriptor.Encryption.Key, testStreamRoomID, testStreamEventID), encContent.AsEncrypted().StreamID) + logicalType, payloadContent, err := decryptLogicalEvent(&encContent, descriptor.Encryption.Key) + require.NoError(t, err) + require.Equal(t, event.ToDeviceBeeperStreamUpdate, logicalType) + var parsed map[string]any + require.NoError(t, json.Unmarshal(payloadContent, &parsed)) + assertStreamUpdateMap(t, parsed) +} + +func TestHelperHandleSyncResponseIgnoresWrongUser(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) + require.NoError(t, err) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) + + streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ + newTestSubscribeEvent("@other:example.com", testStreamPublisherDev), + }}, + }) + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + select { + case req := <-recorder.requests: + t.Fatalf("unexpected sendToDevice request: %s", req.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperHandleSyncResponseIgnoresWrongDeviceWithoutUserID(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) + require.NoError(t, err) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) + + streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ + newTestSubscribeEvent("", "OTHERDEVICE"), + }}, + }) + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + select { + case req := <-recorder.requests: + t.Fatalf("unexpected sendToDevice request: %s", req.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperHandleSyncResponseReturnsNormalizedEvents(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{newTestEncryptedUpdateEvent(t, descriptor)}}, + }) + + require.Len(t, normalized, 1) + require.Equal(t, event.ToDeviceBeeperStreamUpdate, normalized[0].Type) + require.Equal(t, testStreamRoomID, normalized[0].RoomID) + update := normalized[0].Content.AsBeeperStreamUpdate() + require.Equal(t, testStreamRoomID, update.RoomID) + require.Equal(t, testStreamEventID, update.EventID) + require.Contains(t, normalized[0].Content.Raw, testStreamDeltaKey) +} + +func TestHelperHandleSyncResponseParsesRawEncryptedEvents(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + rawEvt := rawifyEventContent(t, newTestEncryptedUpdateEvent(t, descriptor)) + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{rawEvt}}, + }) + + require.Len(t, normalized, 1) + require.Equal(t, event.ToDeviceBeeperStreamUpdate, normalized[0].Type) + update := normalized[0].Content.AsBeeperStreamUpdate() + require.Equal(t, testStreamRoomID, update.RoomID) + require.Equal(t, testStreamEventID, update.EventID) +} + +func TestHelperHandleSyncResponseExpandsBatchedEncryptedReplay(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + first, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("hello")) + require.NoError(t, err) + second, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("bye")) + require.NoError(t, err) + batched, err := makeReplayUpdateContent([]*event.Content{first, second}) + require.NoError(t, err) + payload, err := marshalContent(batched) + require.NoError(t, err) + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) + require.NoError(t, err) + + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ + Sender: testStreamBotUserID, + Type: event.ToDeviceEncrypted, + Content: *encContent, + }}}, + }) + + require.Len(t, normalized, 2) + for i, want := range []string{"hello", "bye"} { + require.Equal(t, event.ToDeviceBeeperStreamUpdate, normalized[i].Type) + update := normalized[i].Content.AsBeeperStreamUpdate() + require.Equal(t, testStreamRoomID, update.RoomID) + require.Equal(t, testStreamEventID, update.EventID) + raw := normalized[i].Content.Raw + deltas, ok := raw[testStreamDeltaKey].([]any) + require.True(t, ok) + require.Len(t, deltas, 1) + delta, ok := deltas[0].(map[string]any) + require.True(t, ok) + require.Equal(t, want, fmt.Sprint(delta["delta"])) + } +} + +func TestHelperInitIsIdempotent(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(false) + + require.NoError(t, streams.Init()) + require.NoError(t, streams.Init()) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + + syncer := client.Syncer.(*mautrix.DefaultSyncer) + syncer.ProcessResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ + newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev), + }}, + }, "") + + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + req := recorder.next(t) + require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") + assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) + + select { + case extra := <-recorder.requests: + t.Fatalf("unexpected duplicate sendToDevice request after double init: %s", extra.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperRejectsMismatchedEncryptedRouting(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + update, err := normalizeUpdateContent(testStreamRoomID, id.EventID("$other"), newTestPublishContent("hello")) + require.NoError(t, err) + payload, err := marshalContent(update) + require.NoError(t, err) + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) + require.NoError(t, err) + + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ + Sender: testStreamBotUserID, + Type: event.ToDeviceEncrypted, + Content: *encContent, + }}}, + }) + require.Empty(t, normalized) +} + +func TestHelperRejectsUnknownEncryptedLogicalType(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + payload, err := marshalContent(&event.Content{Raw: newTestPublishContent("hello")}) + require.NoError(t, err) + encContent, err := encryptLogicalEvent(event.Type{Type: "com.beeper.stream.unknown", Class: event.ToDeviceEventType}, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) + require.NoError(t, err) + + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ + Sender: testStreamBotUserID, + Type: event.ToDeviceEncrypted, + Content: *encContent, + }}}, + }) + require.Empty(t, normalized) +} + +func TestHelperRejectsEncryptedEventMissingStreamID(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + evt := newTestEncryptedUpdateEvent(t, descriptor) + evt.Content.AsEncrypted().StreamID = "" + + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{evt}}, + }) + require.Empty(t, normalized) +} + +func TestHelperRejectsOldEncryptedRoutingShape(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + descriptor := newTestDescriptor(true) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) + t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) + _ = recorder.next(t) + + evt := newTestEncryptedUpdateEvent(t, descriptor) + raw, err := evt.Content.MarshalJSON() + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(raw, &parsed)) + delete(parsed, "stream_id") + parsed["room_id"] = string(testStreamRoomID) + parsed["event_id"] = string(testStreamEventID) + oldShape, err := json.Marshal(parsed) + require.NoError(t, err) + + normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ + Sender: testStreamBotUserID, + Type: event.ToDeviceEncrypted, + Content: event.Content{ + VeryRaw: oldShape, + }, + }}}, + }) + require.Empty(t, normalized) +} + +func TestHelperPendingSubscribeExpiresBeforeRegister(t *testing.T) { + ts, recorder := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + now := time.Unix(1_000, 0) + streams.now = func() time.Time { return now } + + require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) + + now = now.Add(pendingSubscribeTTL + time.Second) + require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) + require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) + + select { + case req := <-recorder.requests: + t.Fatalf("unexpected sendToDevice request: %s", req.path) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHelperPendingSubscribeQueueTrim(t *testing.T) { + client := newTestStreamClient(t, "", testStreamBotUserID, testStreamPublisherDev) + streams := newTestHelper(t, client) + + for i := 0; i < maxPendingSubscriptions+1; i++ { + evt := &event.Event{ + Sender: testStreamSubscriberID, + Type: event.ToDeviceBeeperStreamSubscribe, + Content: event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ + RoomID: id.RoomID(testStreamRoomID + id.RoomID(fmt.Sprintf("-%d", i))), + EventID: testStreamEventID, + DeviceID: testStreamSubscriberDev, + ExpiryMS: 60_000, + }}, + } + streams.queuePendingSubscribe(context.Background(), evt) + } + + require.Len(t, streams.pendingSubscribe, maxPendingSubscriptions) + require.Equal(t, id.RoomID(testStreamRoomID+"-1"), streams.pendingSubscribe[0].key.roomID) +} + +func TestHelperCloseCancelsSubscriptions(t *testing.T) { + ts, _ := newSendToDeviceRecorderServer(t) + client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") + streams := newTestHelper(t, client) + + require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) + require.Len(t, streams.subscriptions, 1) + + require.NoError(t, streams.Close()) + require.True(t, streams.closed.Load()) + require.Empty(t, streams.subscriptions) + require.Error(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) +} + +func TestDecryptLogicalEventRejectsInvalidIV(t *testing.T) { + descriptor := newTestDescriptor(true) + content, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("hello")) + require.NoError(t, err) + payload, err := marshalContent(content) + require.NoError(t, err) + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) + require.NoError(t, err) + + enc := encContent.AsEncrypted() + enc.IV = []byte("invalid") + _, _, err = decryptLogicalEvent(encContent, descriptor.Encryption.Key) + require.Error(t, err) +} diff --git a/mautrix-patched/beeperstream/publisher.go b/mautrix-patched/beeperstream/publisher.go new file mode 100644 index 00000000..aabc0dd5 --- /dev/null +++ b/mautrix-patched/beeperstream/publisher.go @@ -0,0 +1,429 @@ +// Copyright (c) 2026 Batuhan İçöz +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package beeperstream + +import ( + "context" + "fmt" + "slices" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type subscriber struct { + userID id.UserID + deviceID id.DeviceID +} + +type publishedStream struct { + descriptor *event.BeeperStreamInfo + streamID string + maxBufferedUpdates int + updates []*event.Content + + subscribers map[subscriber]time.Time + inactive bool + cleanup *time.Timer + lastEviction time.Time +} + +type pendingSubscribeEvent struct { + key streamKey + streamID string + evt *event.Event + receivedAt time.Time +} + +func (h *Helper) Register(ctx context.Context, roomID id.RoomID, eventID id.EventID, descriptor *event.BeeperStreamInfo) error { + if h == nil { + return fmt.Errorf("beeper stream helper is nil") + } else if h.closed.Load() { + return fmt.Errorf("beeper stream helper is closed") + } else if err := descriptor.Validate(); err != nil { + return err + } + key := streamKey{roomID: roomID, eventID: eventID} + state := &publishedStream{ + descriptor: descriptor.Clone(), + maxBufferedUpdates: resolveMaxBufferedUpdates(descriptor), + subscribers: make(map[subscriber]time.Time), + } + if descriptor.Encryption != nil { + state.streamID = deriveStreamID(descriptor.Encryption.Key, roomID, eventID) + } + h.lock.Lock() + if existing := h.publishedStreams[key]; existing != nil { + if descriptorEqual(existing.descriptor, state.descriptor) { + h.lock.Unlock() + h.replayPendingSubscribe(ctx, key) + return nil + } + if existing.streamID != "" { + delete(h.encryptedPubl, existing.streamID) + } + if existing.cleanup != nil { + existing.cleanup.Stop() + } + } + h.publishedStreams[key] = state + if state.streamID != "" { + h.encryptedPubl[state.streamID] = key + } + h.lock.Unlock() + h.replayPendingSubscribe(ctx, key) + return nil +} + +func (h *Helper) Unregister(roomID id.RoomID, eventID id.EventID) { + if h == nil || h.closed.Load() { + return + } + key := streamKey{roomID: roomID, eventID: eventID} + h.lock.Lock() + state := h.publishedStreams[key] + if state == nil { + h.lock.Unlock() + return + } + state.inactive = true + state.subscribers = nil + if state.streamID != "" { + delete(h.encryptedPubl, state.streamID) + } + if state.cleanup != nil { + state.cleanup.Stop() + } + state.cleanup = time.AfterFunc(cleanupGrace, func() { + h.lock.Lock() + delete(h.publishedStreams, key) + h.lock.Unlock() + }) + h.lock.Unlock() +} + +func streamUpdateIdentifiers(content *event.Content) (*event.BeeperStreamUpdateEventContent, error) { + update := content.AsBeeperStreamUpdate() + if update.RoomID != "" && update.EventID != "" { + return update, nil + } + raw, err := rawMapFromContent(content) + if err != nil { + return nil, err + } + return &event.BeeperStreamUpdateEventContent{ + RoomID: id.RoomID(fmt.Sprint(raw["room_id"])), + EventID: id.EventID(fmt.Sprint(raw["event_id"])), + }, nil +} + +func (h *Helper) Publish(ctx context.Context, roomID id.RoomID, eventID id.EventID, delta map[string]any) error { + if h == nil { + return fmt.Errorf("beeper stream helper is nil") + } else if h.closed.Load() { + return fmt.Errorf("beeper stream helper is closed") + } + update, err := normalizeUpdateContent(roomID, eventID, delta) + if err != nil { + return err + } + key := streamKey{roomID: roomID, eventID: eventID} + h.lock.Lock() + state := h.publishedStreams[key] + if state == nil { + h.lock.Unlock() + return fmt.Errorf("beeper stream %s/%s not found", roomID, eventID) + } else if state.inactive { + h.lock.Unlock() + return fmt.Errorf("beeper stream %s/%s is inactive", roomID, eventID) + } + state.updates = append(state.updates, update) + if len(state.updates) > state.maxBufferedUpdates { + state.updates = state.updates[len(state.updates)-state.maxBufferedUpdates:] + } + descriptor := state.descriptor.Clone() + subscribers := state.activeSubscribers(h.now()) + h.lock.Unlock() + return h.sendUpdate(ctx, descriptor, update, subscribers) +} + +func (h *Helper) handleSubscribeEvent(ctx context.Context, evt *event.Event) { + subscribe := evt.Content.AsBeeperStreamSubscribe() + if subscribe.RoomID == "" || subscribe.EventID == "" { + return + } + if h.handleSubscribe(ctx, evt.Sender, subscribe) { + return + } + h.queuePendingSubscribe(ctx, evt) +} + +func (h *Helper) handleEncryptedForPublisher(ctx context.Context, evt *event.Event, key streamKey, state *publishedStream) []*event.Event { + if state.descriptor == nil || state.descriptor.Encryption == nil { + return nil + } + normalized := decryptedLogicalEvent(ctx, evt, state.descriptor.Encryption.Key, key, event.ToDeviceBeeperStreamSubscribe) + if normalized == nil { + return nil + } + h.handleSubscribeEvent(ctx, normalized) + return nil +} + +func (h *Helper) handleSubscribe(ctx context.Context, sender id.UserID, subscribe *event.BeeperStreamSubscribeEventContent) bool { + if subscribe == nil { + return false + } + key := streamKey{roomID: subscribe.RoomID, eventID: subscribe.EventID} + h.lock.Lock() + state := h.publishedStreams[key] + if state == nil { + h.lock.Unlock() + return false + } else if state.inactive { + h.lock.Unlock() + return true + } + expiry := resolveSubscribeExpiry(state.descriptor, time.Duration(subscribe.ExpiryMS)*time.Millisecond) + sub := subscriber{userID: sender, deviceID: subscribe.DeviceID} + state.subscribers[sub] = h.now().Add(expiry) + descriptor := state.descriptor.Clone() + updates := slices.Clone(state.updates) + h.lock.Unlock() + + if err := h.sendReplayUpdates(ctx, descriptor, updates, []subscriber{sub}); err != nil { + h.lock.Lock() + state = h.publishedStreams[key] + if state != nil { + delete(state.subscribers, sub) + } + h.lock.Unlock() + return true + } + return true +} + +func makeReplayUpdateContent(updates []*event.Content) (*event.Content, error) { + if len(updates) == 0 { + return nil, nil + } else if len(updates) == 1 { + return updates[0], nil + } + updateInfo, err := streamUpdateIdentifiers(updates[0]) + if err != nil { + return nil, err + } + batched := make([]map[string]any, 0, len(updates)) + for _, update := range updates { + raw, err := rawMapFromContent(update) + if err != nil { + return nil, err + } + batched = append(batched, stripUpdateRouting(raw)) + } + return contentFromRawMap(map[string]any{ + "room_id": updateInfo.RoomID, + "event_id": updateInfo.EventID, + "updates": batched, + }) +} + +func (h *Helper) sendReplayUpdates(ctx context.Context, descriptor *event.BeeperStreamInfo, updates []*event.Content, subscribers []subscriber) error { + content, err := makeReplayUpdateContent(updates) + if err != nil || content == nil { + return err + } + return h.sendUpdate(ctx, descriptor, content, subscribers) +} + +func (h *Helper) sendUpdate(ctx context.Context, descriptor *event.BeeperStreamInfo, update *event.Content, subscribers []subscriber) error { + if len(subscribers) == 0 { + return nil + } + client, err := h.requireClient(false) + if err != nil { + return err + } + eventType := event.ToDeviceBeeperStreamUpdate + content := update + if descriptor != nil && descriptor.Encryption != nil { + updateInfo, err := streamUpdateIdentifiers(update) + if err != nil { + return err + } + payload, err := marshalContent(update) + if err != nil { + return err + } + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, updateInfo.RoomID, updateInfo.EventID, descriptor.Encryption.Key) + if err != nil { + return err + } + eventType = event.ToDeviceEncrypted + content = encContent + } + req := &mautrix.ReqSendToDevice{ + Messages: make(map[id.UserID]map[id.DeviceID]*event.Content, len(subscribers)), + } + for _, sub := range subscribers { + if req.Messages[sub.userID] == nil { + req.Messages[sub.userID] = make(map[id.DeviceID]*event.Content) + } + req.Messages[sub.userID][sub.deviceID] = content + } + _, err = client.SendToDevice(ctx, eventType, req) + return err +} + +func (h *Helper) queuePendingSubscribe(ctx context.Context, evt *event.Event) { + key, streamID, ok := pendingSubscribeKey(evt) + if !ok || h.closed.Load() { + return + } + now := h.now() + h.pendingLock.Lock() + h.pendingSubscribe = append(h.pendingSubscribe, pendingSubscribeEvent{ + key: key, + streamID: streamID, + evt: evt, + receivedAt: now, + }) + pendingCount := len(h.pendingSubscribe) + if pendingCount > maxPendingSubscriptions { + h.pendingSubscribe = h.pendingSubscribe[len(h.pendingSubscribe)-maxPendingSubscriptions:] + pendingCount = len(h.pendingSubscribe) + } + h.pendingLock.Unlock() + h.log.Debug(). + Int("pending_subscribes", pendingCount). + Stringer("sender", evt.Sender). + Str("event_type", evt.Type.Type). + Msg("Queued subscribe for possible future beeper stream registration") +} + +func (h *Helper) replayPendingSubscribe(ctx context.Context, key streamKey) { + now := h.now() + h.lock.RLock() + state := h.publishedStreams[key] + streamID := "" + if state != nil { + streamID = state.streamID + } + h.lock.RUnlock() + h.pendingLock.Lock() + if len(h.pendingSubscribe) == 0 { + h.pendingLock.Unlock() + return + } + var replay []pendingSubscribeEvent + filtered := h.pendingSubscribe[:0] + for _, candidate := range h.pendingSubscribe { + if candidate.evt == nil || now.Sub(candidate.receivedAt) > pendingSubscribeTTL { + continue + } + if candidate.key == key || (streamID != "" && candidate.streamID == streamID) { + replay = append(replay, candidate) + continue + } + filtered = append(filtered, candidate) + } + h.pendingSubscribe = filtered + h.pendingLock.Unlock() + if len(replay) == 0 { + return + } + var failed []pendingSubscribeEvent + for _, candidate := range replay { + if !h.tryPendingSubscribe(ctx, candidate) { + failed = append(failed, candidate) + } + } + if len(failed) == 0 { + return + } + h.pendingLock.Lock() + h.pendingSubscribe = append(h.pendingSubscribe, failed...) + if len(h.pendingSubscribe) > maxPendingSubscriptions { + h.pendingSubscribe = h.pendingSubscribe[len(h.pendingSubscribe)-maxPendingSubscriptions:] + } + h.pendingLock.Unlock() +} + +func (h *Helper) tryPendingSubscribe(ctx context.Context, candidate pendingSubscribeEvent) bool { + switch candidate.evt.Type { + case event.ToDeviceBeeperStreamSubscribe: + subscribe := candidate.evt.Content.AsBeeperStreamSubscribe() + if subscribe.RoomID == "" || subscribe.EventID == "" { + return false + } + return h.handleSubscribe(ctx, candidate.evt.Sender, subscribe) + case event.ToDeviceEncrypted: + content := candidate.evt.Content.AsEncrypted() + if content.Algorithm != id.AlgorithmBeeperStreamV1 { + return false + } + if content.StreamID == "" || len(content.BeeperStreamCiphertext) == 0 { + return false + } + h.lock.RLock() + key, ok := h.encryptedPubl[content.StreamID] + state := h.publishedStreams[key] + h.lock.RUnlock() + if !ok || state == nil { + return false + } + h.handleEncryptedForPublisher(ctx, candidate.evt, key, state) + return true + default: + return false + } +} + +func pendingSubscribeKey(evt *event.Event) (streamKey, string, bool) { + if evt == nil { + return streamKey{}, "", false + } + switch evt.Type { + case event.ToDeviceBeeperStreamSubscribe: + content := evt.Content.AsBeeperStreamSubscribe() + if content.RoomID == "" || content.EventID == "" { + return streamKey{}, "", false + } + return streamKey{roomID: content.RoomID, eventID: content.EventID}, "", true + case event.ToDeviceEncrypted: + content := evt.Content.AsEncrypted() + if content.Algorithm != id.AlgorithmBeeperStreamV1 { + return streamKey{}, "", false + } + if content.StreamID == "" || len(content.BeeperStreamCiphertext) == 0 { + return streamKey{}, "", false + } + return streamKey{}, content.StreamID, true + default: + return streamKey{}, "", false + } +} + +func (state *publishedStream) activeSubscribers(now time.Time) []subscriber { + var active []subscriber + doEvict := now.Sub(state.lastEviction) >= cleanupGrace + for sub, expiry := range state.subscribers { + if now.After(expiry) { + if doEvict { + delete(state.subscribers, sub) + } + continue + } + active = append(active, sub) + } + if doEvict { + state.lastEviction = now + } + return active +} diff --git a/mautrix-patched/beeperstream/receiver.go b/mautrix-patched/beeperstream/receiver.go new file mode 100644 index 00000000..23319137 --- /dev/null +++ b/mautrix-patched/beeperstream/receiver.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Batuhan İçöz +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package beeperstream + +import ( + "context" + "fmt" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type subscription struct { + key streamKey + streamID string + descriptor *event.BeeperStreamInfo + cancel context.CancelFunc +} + +func (h *Helper) Subscribe(ctx context.Context, roomID id.RoomID, eventID id.EventID, descriptor *event.BeeperStreamInfo) error { + if h == nil { + return fmt.Errorf("beeper stream helper is nil") + } else if h.closed.Load() { + return fmt.Errorf("beeper stream helper is closed") + } else if err := descriptor.Validate(); err != nil { + return err + } + key := streamKey{roomID: roomID, eventID: eventID} + h.lock.Lock() + if existing := h.subscriptions[key]; existing != nil { + if descriptorEqual(existing.descriptor, descriptor) { + h.lock.Unlock() + return nil + } + if existing.streamID != "" { + delete(h.encryptedSubs, existing.streamID) + } + existing.cancel() + delete(h.subscriptions, key) + } + subCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + sub := &subscription{ + key: key, + descriptor: descriptor.Clone(), + cancel: cancel, + } + if descriptor.Encryption != nil { + sub.streamID = deriveStreamID(descriptor.Encryption.Key, roomID, eventID) + } + h.subscriptions[key] = sub + if sub.streamID != "" { + h.encryptedSubs[sub.streamID] = key + } + h.lock.Unlock() + + go h.runSubscriptionLoop(subCtx, sub) + return nil +} + +func (h *Helper) Unsubscribe(roomID id.RoomID, eventID id.EventID) { + if h == nil || h.closed.Load() { + return + } + key := streamKey{roomID: roomID, eventID: eventID} + h.lock.Lock() + sub := h.subscriptions[key] + if sub != nil { + delete(h.subscriptions, key) + if sub.streamID != "" { + delete(h.encryptedSubs, sub.streamID) + } + } + h.lock.Unlock() + if sub != nil { + sub.cancel() + } +} + +func (h *Helper) runSubscriptionLoop(ctx context.Context, sub *subscription) { + expiry := resolveSubscribeExpiry(sub.descriptor, DefaultSubscribeExpiry) + renewInterval := max(expiry/2, defaultRenewInterval) + if err := h.sendSubscribe(ctx, sub.key, sub.descriptor, expiry); err != nil && ctx.Err() == nil { + h.log.Warn().Err(err). + Stringer("room_id", sub.key.roomID). + Stringer("event_id", sub.key.eventID). + Msg("Failed to send initial beeper stream subscribe") + } + ticker := time.NewTicker(renewInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := h.sendSubscribe(ctx, sub.key, sub.descriptor, expiry); err != nil && ctx.Err() == nil { + h.log.Warn().Err(err). + Stringer("room_id", sub.key.roomID). + Stringer("event_id", sub.key.eventID). + Msg("Failed to renew beeper stream subscribe") + } + } + } +} + +func (h *Helper) sendSubscribe(ctx context.Context, key streamKey, descriptor *event.BeeperStreamInfo, expiry time.Duration) error { + client, err := h.requireClient(true) + if err != nil { + return err + } + subscribeContent := &event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ + RoomID: key.roomID, + EventID: key.eventID, + DeviceID: client.DeviceID, + ExpiryMS: expiry.Milliseconds(), + }} + eventType := event.ToDeviceBeeperStreamSubscribe + content := subscribeContent + targetDevice := id.DeviceID("*") + if descriptor.DeviceID != "" { + targetDevice = descriptor.DeviceID + } + if descriptor.Encryption != nil { + payload, err := marshalContent(subscribeContent) + if err != nil { + return err + } + encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamSubscribe, payload, key.roomID, key.eventID, descriptor.Encryption.Key) + if err != nil { + return err + } + eventType = event.ToDeviceEncrypted + content = encContent + } + _, err = client.SendToDevice(ctx, eventType, &mautrix.ReqSendToDevice{ + Messages: map[id.UserID]map[id.DeviceID]*event.Content{ + descriptor.UserID: { + targetDevice: content, + }, + }, + }) + return err +} + +func (h *Helper) handleEncryptedForSubscriber(ctx context.Context, evt *event.Event, key streamKey, sub *subscription) []*event.Event { + if sub.descriptor.Encryption == nil { + return nil + } + normalized := decryptedLogicalEvent(ctx, evt, sub.descriptor.Encryption.Key, key, event.ToDeviceBeeperStreamUpdate) + if normalized == nil { + return nil + } + update := normalized.Content.AsBeeperStreamUpdate() + if update.RoomID != sub.key.roomID || update.EventID != sub.key.eventID { + return nil + } + if normalized.Sender != sub.descriptor.UserID { + h.log.Warn(). + Stringer("sender", normalized.Sender). + Stringer("expected_user_id", sub.descriptor.UserID). + Stringer("room_id", update.RoomID). + Stringer("event_id", update.EventID). + Msg("Encrypted beeper stream update from unexpected sender, dropping") + return nil + } + return expandStreamUpdateEvent(normalized) +} diff --git a/mautrix-patched/bridgev2/backfillqueue.go b/mautrix-patched/bridgev2/backfillqueue.go new file mode 100644 index 00000000..33c87541 --- /dev/null +++ b/mautrix-patched/bridgev2/backfillqueue.go @@ -0,0 +1,360 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2/database" +) + +const BackfillMinBackoffAfterRoomCreate = 1 * time.Minute +const BackfillQueueErrorBackoff = 1 * time.Minute +const BackfillQueueMaxEmptyBackoff = 10 * time.Minute + +func (br *Bridge) WakeupBackfillQueue(manualTask ...*ManualBackfill) { + if br.IsStopping() { + for _, task := range manualTask { + if task.DoneCallback != nil { + task.DoneCallback(errBackfillQueueStopped) + } + } + return + } + if !br.Config.Backfill.Queue.Enabled { + for _, task := range manualTask { + go task.addLogAndDo(task.Portal.Log.WithContext(br.BackgroundCtx)) + } + return + } + for _, task := range manualTask { + br.manualBackfills <- task + } + select { + case br.wakeupBackfillQueue <- struct{}{}: + default: + } +} + +type ManualBackfill struct { + Source *UserLogin + Portal *Portal + Data *FetchMessagesResponse + + DoneCallback func(error) +} + +var errBackfillQueueStopped = errors.New("backfill queue stopped") + +func (br *Bridge) flushManualBackfillQueue() { + for { + select { + case manualTask := <-br.manualBackfills: + if manualTask.DoneCallback != nil { + manualTask.DoneCallback(errBackfillQueueStopped) + } + default: + return + } + } +} + +func (br *Bridge) RunBackfillQueue() { + if !br.Config.Backfill.Queue.Enabled || !br.Config.Backfill.Enabled { + return + } + log := br.Log.With().Str("component", "backfill queue").Logger() + if !br.Matrix.GetCapabilities().BatchSending { + log.Warn().Msg("Backfill queue is enabled in config, but Matrix server doesn't support batch sending") + return + } + ctx, cancel := context.WithCancel(log.WithContext(context.Background())) + br.stopBackfillQueue.Clear() + stopChan := br.stopBackfillQueue.GetChan() + go func() { + <-stopChan + cancel() + }() + batchDelay := time.Duration(br.Config.Backfill.Queue.BatchDelay) * time.Second + log.Info().Stringer("batch_delay", batchDelay).Msg("Backfill queue starting") + noTasksFoundCount := 0 + for { + nextDelay := batchDelay + if noTasksFoundCount > 0 { + extraDelay := batchDelay * time.Duration(noTasksFoundCount) + nextDelay += min(BackfillQueueMaxEmptyBackoff, extraDelay) + } + select { + case <-br.wakeupBackfillQueue: + noTasksFoundCount = 0 + case <-stopChan: + log.Info().Msg("Stopping backfill queue") + br.flushManualBackfillQueue() + return + case <-time.After(nextDelay): + } + select { + case manualTask := <-br.manualBackfills: + manualTask.addLogAndDo(ctx) + default: + backfillTask, err := br.DB.BackfillTask.GetNext(ctx) + if err != nil { + log.Err(err).Msg("Failed to get next backfill queue entry") + time.Sleep(BackfillQueueErrorBackoff) + continue + } else if backfillTask != nil { + backfillTask.FromQueue = true + br.DoBackfillTask(ctx, backfillTask) + noTasksFoundCount = 0 + } + } + } +} + +func (mt *ManualBackfill) addLogAndDo(ctx context.Context) { + log := zerolog.Ctx(ctx).With(). + Object("portal_key", mt.Portal.PortalKey). + Str("login_id", string(mt.Source.ID)). + Str("task_type", "manual"). + Logger() + ctx = log.WithContext(ctx) + mt.Do(ctx) +} + +func (mt *ManualBackfill) Do(ctx context.Context) { + log := zerolog.Ctx(ctx) + var completed bool + var err error + if !mt.Portal.backfillLock.TryLock() { + log.Warn().Msg("Backfill already in progress") + mt.Portal.backfillLock.Lock() + } + defer mt.Portal.backfillLock.Unlock() + defer func() { + if !completed { + if mt.DoneCallback != nil { + if mt.Portal.nextBackfillDoneCallback != nil { + mt.Portal.nextBackfillDoneCallback(errors.New("done callback overridden")) + } + mt.Portal.nextBackfillDoneCallback = mt.DoneCallback + mt.DoneCallback = nil + } + return + } + if mt.DoneCallback != nil { + mt.DoneCallback(err) + } + if mt.Portal.nextBackfillDoneCallback != nil { + mt.Portal.nextBackfillDoneCallback(err) + mt.Portal.nextBackfillDoneCallback = nil + } + }() + var task *database.BackfillTask + updateTask := false + task, err = mt.Portal.Bridge.DB.BackfillTask.GetNextForPortal(ctx, mt.Portal.PortalKey, mt.Data != nil) + if err != nil { + log.Err(err).Msg("Failed to get backfill task from database") + } else if task == nil { + log.Warn().Msg("No backfill task found for portal") + } else if err = mt.Portal.Bridge.DB.BackfillTask.MarkDispatched(ctx, task); err != nil { + log.Err(err).Msg("Failed to mark backfill task as dispatched") + } else if completed, err = mt.Portal.doBackfillTask(ctx, mt.Source, task, mt.Data); err != nil { + log.Err(err).Msg("Failed to do backwards backfill from event") + updateTask = errors.Is(err, errNoMessagesLeftAfterCutoff) + } else { + log.Debug().Bool("completed", completed).Msg("Finished backfill from event") + updateTask = true + } + if updateTask { + err = mt.Portal.Bridge.DB.BackfillTask.Update(ctx, task) + if err != nil { + log.Err(err).Msg("Failed to update backfill task in database after backfill") + } + } +} + +func (br *Bridge) DoBackfillTask(ctx context.Context, task *database.BackfillTask) { + log := zerolog.Ctx(ctx).With(). + Object("portal_key", task.PortalKey). + Str("login_id", string(task.UserLoginID)). + Logger() + defer func() { + err := recover() + if err != nil { + logEvt := log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()) + if realErr, ok := err.(error); ok { + logEvt = logEvt.Err(realErr) + } else { + logEvt = logEvt.Any(zerolog.ErrorFieldName, err) + } + logEvt.Msg("Panic in backfill queue") + } + }() + ctx = log.WithContext(ctx) + err := br.DB.BackfillTask.MarkDispatched(ctx, task) + if err != nil { + log.Err(err).Msg("Failed to mark backfill task as dispatched") + time.Sleep(BackfillQueueErrorBackoff) + return + } + updateTask := true + completed, err := br.getPortalAndDoBackfillTask(ctx, task) + if err != nil { + log.Err(err).Msg("Failed to do backfill task") + updateTask = errors.Is(err, errNoMessagesLeftAfterCutoff) + time.Sleep(BackfillQueueErrorBackoff) + } else if completed { + log.Info(). + Int("batch_count", task.BatchCount). + Bool("is_done", task.IsDone). + Msg("Backfill task completed successfully") + } else { + log.Info(). + Int("batch_count", task.BatchCount). + Bool("is_done", task.IsDone). + Msg("Backfill task not completed") + } + if updateTask { + err = br.DB.BackfillTask.Update(ctx, task) + if err != nil { + log.Err(err).Msg("Failed to update backfill task") + time.Sleep(BackfillQueueErrorBackoff) + } + } +} + +func (portal *Portal) deleteBackfillQueueTaskIfRoomDoesNotExist(ctx context.Context) bool { + // Acquire the room create lock to ensure that task deletion doesn't race with room creation + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + if portal.MXID == "" { + zerolog.Ctx(ctx).Debug().Msg("Portal for backfill task doesn't exist, deleting entry") + err := portal.Bridge.DB.BackfillTask.Delete(ctx, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to delete backfill task after portal wasn't found") + } + return true + } + return false +} + +func (br *Bridge) getPortalAndDoBackfillTask(ctx context.Context, task *database.BackfillTask) (bool, error) { + log := zerolog.Ctx(ctx) + portal, err := br.GetExistingPortalByKey(ctx, task.PortalKey) + if err != nil { + return false, fmt.Errorf("failed to get portal for backfill task: %w", err) + } else if portal == nil { + log.Warn().Msg("Portal not found for backfill task") + err = br.DB.BackfillTask.Delete(ctx, task.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to delete backfill task after portal wasn't found") + time.Sleep(BackfillQueueErrorBackoff) + } + return false, nil + } else if portal.MXID == "" { + portal.deleteBackfillQueueTaskIfRoomDoesNotExist(ctx) + return false, nil + } + login, err := br.GetExistingUserLoginByID(ctx, task.UserLoginID) + if err != nil { + return false, fmt.Errorf("failed to get user login for backfill task: %w", err) + } else if login == nil || !login.Client.IsLoggedIn() { + if login == nil { + log.Warn().Msg("User login not found for backfill task") + } else { + log.Warn().Msg("User login not logged in for backfill task") + } + logins, err := br.GetUserLoginsInPortal(ctx, portal.PortalKey) + if err != nil { + return false, fmt.Errorf("failed to get user portals for backfill task: %w", err) + } else if len(logins) == 0 { + log.Debug().Msg("No user logins found for backfill task") + task.NextDispatchMinTS = database.BackfillNextDispatchNever + if login == nil { + task.UserLoginID = "" + } + return false, nil + } + if login == nil { + task.UserLoginID = "" + } + foundLogin := false + for _, login = range logins { + if login.Client.IsLoggedIn() { + foundLogin = true + task.UserLoginID = login.ID + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("overridden_login_id", string(login.ID)) + }) + log.Debug().Msg("Found user login for backfill task") + break + } + } + if !foundLogin { + log.Debug().Msg("No logged in user logins found for backfill task") + task.NextDispatchMinTS = database.BackfillNextDispatchNever + return false, nil + } + } + if task.BatchCount < 0 { + var msgCount int + msgCount, err = br.DB.Message.CountMessagesInPortal(ctx, task.PortalKey) + if err != nil { + return false, fmt.Errorf("failed to count messages in portal: %w", err) + } + task.BatchCount = msgCount / br.Config.Backfill.Queue.BatchSize + log.Debug(). + Int("message_count", msgCount). + Int("batch_count", task.BatchCount). + Msg("Calculated existing batch count") + } + if !portal.backfillLock.TryLock() { + zerolog.Ctx(ctx).Warn().Msg("Backfill already in progress") + portal.backfillLock.Lock() + } + defer portal.backfillLock.Unlock() + return portal.doBackfillTask(ctx, login, task, nil) +} + +func (portal *Portal) doBackfillTask(ctx context.Context, source *UserLogin, task *database.BackfillTask, resp *FetchMessagesResponse) (bool, error) { + maxBatches := portal.Bridge.Config.Backfill.Queue.MaxBatches + api, ok := source.Client.(BackfillingNetworkAPI) + if !ok { + return false, fmt.Errorf("network API does not support backfilling") + } + limiterAPI, ok := api.(BackfillingNetworkAPIWithLimits) + if ok { + maxBatches = limiterAPI.GetBackfillMaxBatchCount(ctx, portal, task) + } + if maxBatches < 0 || maxBatches > task.BatchCount { + pending, err := portal.doBackwardsBackfill(ctx, source, task, resp) + if err != nil { + return false, fmt.Errorf("failed to backfill: %w", err) + } else if pending { + return false, nil + } + task.BatchCount++ + } else { + zerolog.Ctx(ctx).Debug(). + Int("max_batches", maxBatches). + Int("batch_count", task.BatchCount). + Msg("Not actually backfilling: max batches reached") + } + task.IsDone = task.IsDone || (maxBatches > 0 && task.BatchCount >= maxBatches) + task.QueueDone = task.IsDone || task.QueueDone + batchDelay := time.Duration(portal.Bridge.Config.Backfill.Queue.BatchDelay) * time.Second + task.CompletedAt = time.Now() + task.NextDispatchMinTS = task.CompletedAt.Add(batchDelay) + return true, nil +} diff --git a/mautrix-patched/bridgev2/bridge.go b/mautrix-patched/bridgev2/bridge.go new file mode 100644 index 00000000..9b51a88a --- /dev/null +++ b/mautrix-patched/bridgev2/bridge.go @@ -0,0 +1,470 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/exsync" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/id" +) + +type CommandProcessor interface { + Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *User, message string, replyTo id.EventID) +} + +type Bridge struct { + ID networkid.BridgeID + DB *database.Database + Log zerolog.Logger + + Matrix MatrixConnector + Bot MatrixAPI + Network NetworkConnector + Commands CommandProcessor + Config *bridgeconfig.BridgeConfig + + DisappearLoop *DisappearLoop + + usersByMXID map[id.UserID]*User + userLoginsByID map[networkid.UserLoginID]*UserLogin + portalsByKey map[networkid.PortalKey]*Portal + portalsByMXID map[id.RoomID]*Portal + ghostsByID map[networkid.UserID]*Ghost + cacheLock sync.Mutex + + didSplitPortals bool + + Background bool + ExternallyManagedDB bool + stopping atomic.Bool + + wakeupBackfillQueue chan struct{} + stopBackfillQueue *exsync.Event + manualBackfills chan *ManualBackfill + + BackgroundCtx context.Context + cancelBackgroundCtx context.CancelFunc +} + +func NewBridge( + bridgeID networkid.BridgeID, + db *dbutil.Database, + log zerolog.Logger, + cfg *bridgeconfig.BridgeConfig, + matrix MatrixConnector, + network NetworkConnector, + newCommandProcessor func(*Bridge) CommandProcessor, +) *Bridge { + br := &Bridge{ + ID: bridgeID, + DB: database.New(bridgeID, network.GetDBMetaTypes(), db), + Log: log, + + Matrix: matrix, + Network: network, + Config: cfg, + + usersByMXID: make(map[id.UserID]*User), + userLoginsByID: make(map[networkid.UserLoginID]*UserLogin), + portalsByKey: make(map[networkid.PortalKey]*Portal), + portalsByMXID: make(map[id.RoomID]*Portal), + ghostsByID: make(map[networkid.UserID]*Ghost), + + wakeupBackfillQueue: make(chan struct{}), + manualBackfills: make(chan *ManualBackfill, 64), + stopBackfillQueue: exsync.NewEvent(), + } + if br.Config == nil { + br.Config = &bridgeconfig.BridgeConfig{CommandPrefix: "!bridge"} + } + br.Commands = newCommandProcessor(br) + br.Matrix.Init(br) + br.Bot = br.Matrix.BotIntent() + br.Network.Init(br) + br.DisappearLoop = &DisappearLoop{br: br} + return br +} + +type DBUpgradeError struct { + Err error + Section string +} + +func (e DBUpgradeError) Error() string { + return e.Err.Error() +} + +func (e DBUpgradeError) Unwrap() error { + return e.Err +} + +func (br *Bridge) Start(ctx context.Context) error { + ctx = br.Log.WithContext(ctx) + err := br.StartConnectors(ctx) + if err != nil { + return err + } + err = br.StartLogins(ctx) + if err != nil { + return err + } + go br.PostStart(ctx) + return nil +} + +func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, params *ConnectBackgroundParams) error { + br.Background = true + br.stopping.Store(false) + err := br.StartConnectors(ctx) + if err != nil { + return err + } + + if loginID == "" { + br.Log.Info().Msg("No login ID provided to RunOnce, running all logins for 20 seconds") + err = br.StartLogins(ctx) + if err != nil { + return err + } + defer br.StopWithTimeout(5 * time.Second) + select { + case <-time.After(20 * time.Second): + case <-ctx.Done(): + } + return nil + } + + defer br.stop(true, 5*time.Second) + login, err := br.GetExistingUserLoginByID(ctx, loginID) + if err != nil { + return fmt.Errorf("failed to get user login: %w", err) + } else if login == nil { + return ErrNotLoggedIn + } + syncClient, ok := login.Client.(BackgroundSyncingNetworkAPI) + if !ok { + br.Log.Warn().Msg("Network connector doesn't implement background mode, using fallback mechanism for RunOnce") + login.Client.Connect(ctx) + defer login.DisconnectWithTimeout(5 * time.Second) + select { + case <-time.After(20 * time.Second): + case <-ctx.Done(): + } + br.stopping.Store(true) + return nil + } else { + br.Log.Info().Str("user_login_id", string(login.ID)).Msg("Starting individual user login in background mode") + return syncClient.ConnectBackground(login.Log.WithContext(ctx), params) + } +} + +func (br *Bridge) StartConnectors(ctx context.Context) error { + br.Log.Info().Msg("Starting bridge") + br.stopping.Store(false) + if br.BackgroundCtx == nil || br.BackgroundCtx.Err() != nil { + br.BackgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) + br.BackgroundCtx = br.Log.WithContext(br.BackgroundCtx) + } + + if !br.ExternallyManagedDB { + err := br.DB.Upgrade(ctx) + if err != nil { + return DBUpgradeError{Err: err, Section: "main"} + } + } + if !br.Background { + didSplitPortals, postMigrate, err := br.MigrateToSplitPortals(ctx) + if err != nil { + return fmt.Errorf("%w: %w", ErrSplitPortalMigrationFailed, err) + } + br.didSplitPortals = didSplitPortals + if postMigrate != nil { + defer postMigrate() + } + } + br.Log.Info().Msg("Starting Matrix connector") + err := br.Matrix.Start(ctx) + if err != nil { + return fmt.Errorf("failed to start Matrix connector: %w", err) + } + br.Log.Info().Msg("Starting network connector") + err = br.Network.Start(ctx) + if err != nil { + return fmt.Errorf("failed to start network connector: %w", err) + } + if br.Network.GetCapabilities().DisappearingMessages && !br.Background { + go br.DisappearLoop.Start() + } + return nil +} + +func (br *Bridge) PostStart(ctx context.Context) { + if br.Background { + return + } + rawBridgeInfoVer := br.DB.KV.Get(ctx, database.KeyBridgeInfoVersion) + bridgeInfoVer, capVer, err := parseBridgeInfoVersion(rawBridgeInfoVer) + if err != nil { + br.Log.Err(err).Str("db_bridge_info_version", rawBridgeInfoVer).Msg("Failed to parse bridge info version") + return + } + expectedBridgeInfoVer, expectedCapVer := br.Network.GetBridgeInfoVersion() + doResendBridgeInfo := bridgeInfoVer != expectedBridgeInfoVer || br.didSplitPortals || br.Config.ResendBridgeInfo + doResendCapabilities := capVer != expectedCapVer || br.didSplitPortals + if doResendBridgeInfo || doResendCapabilities { + br.ResendBridgeInfo(ctx, doResendBridgeInfo, doResendCapabilities) + } + br.DB.KV.Set(ctx, database.KeyBridgeInfoVersion, fmt.Sprintf("%d,%d", expectedBridgeInfoVer, expectedCapVer)) +} + +func (br *Bridge) GetBeeperStreamPublisher() BeeperStreamPublisher { + if br == nil || br.Matrix == nil { + return nil + } + withStreams, ok := br.Matrix.(MatrixConnectorWithBeeperStreams) + if !ok { + return nil + } + return withStreams.GetBeeperStreamPublisher() +} + +func parseBridgeInfoVersion(version string) (info, capabilities int, err error) { + _, err = fmt.Sscanf(version, "%d,%d", &info, &capabilities) + if version == "" { + err = nil + } + return +} + +func (br *Bridge) ResendBridgeInfo(ctx context.Context, resendInfo, resendCaps bool) { + log := zerolog.Ctx(ctx).With().Str("action", "resend bridge info").Logger() + portals, err := br.GetAllPortalsWithMXID(ctx) + if err != nil { + log.Err(err).Msg("Failed to get portals") + return + } + for _, portal := range portals { + if resendInfo { + portal.UpdateBridgeInfo(ctx) + } + if resendCaps { + logins, err := br.GetUserLoginsInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err). + Stringer("room_id", portal.MXID). + Object("portal_key", portal.PortalKey). + Msg("Failed to get user logins in portal") + } else { + found := false + for _, login := range logins { + if portal.CapState.ID == "" || login.ID == portal.CapState.Source { + portal.UpdateCapabilities(ctx, login, true) + found = true + } + } + if !found && len(logins) > 0 { + portal.CapState.Source = "" + portal.UpdateCapabilities(ctx, logins[0], true) + } else if !found { + log.Warn(). + Stringer("room_id", portal.MXID). + Object("portal_key", portal.PortalKey). + Msg("No user login found to update capabilities") + } + } + } + } + log.Info(). + Bool("capabilities", resendCaps). + Bool("info", resendInfo). + Msg("Resent bridge info to all portals") +} + +func (br *Bridge) MigrateToSplitPortals(ctx context.Context) (bool, func(), error) { + log := zerolog.Ctx(ctx).With().Str("action", "migrate to split portals").Logger() + ctx = log.WithContext(ctx) + if !br.Config.SplitPortals || br.DB.KV.Get(ctx, database.KeySplitPortalsEnabled) == "true" { + return false, nil, nil + } + affected, err := br.DB.Portal.MigrateToSplitPortals(ctx) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to migrate portals") + return false, nil, fmt.Errorf("failed to migrate database: %w", err) + } + log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals") + affected2, err := br.DB.Portal.FixParentsAfterSplitPortalMigration(ctx) + if err != nil { + log.Err(err).Msg("Failed to fix parent portals after split portal migration") + return false, nil, fmt.Errorf("failed to fix parent portals: %w", err) + } + log.Info().Int64("rows_affected", affected2).Msg("Updated parent receivers after split portal migration") + withoutReceiver, err := br.DB.Portal.GetAllWithoutReceiver(ctx) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get portals that failed to migrate") + return false, nil, fmt.Errorf("failed to get portals without receiver: %w", err) + } + var roomsToDelete []id.RoomID + log.Info().Int("remaining_portals", len(withoutReceiver)).Msg("Deleting remaining portals without receiver") + for _, portal := range withoutReceiver { + if err = br.DB.Portal.Delete(ctx, portal.PortalKey); err != nil { + log.Err(err). + Str("portal_id", string(portal.ID)). + Stringer("mxid", portal.MXID). + Msg("Failed to delete portal database row that failed to migrate") + } else if portal.MXID != "" { + log.Debug(). + Str("portal_id", string(portal.ID)). + Stringer("mxid", portal.MXID). + Msg("Marked portal room for deletion from homeserver") + roomsToDelete = append(roomsToDelete, portal.MXID) + } else { + log.Debug(). + Str("portal_id", string(portal.ID)). + Msg("Deleted portal row with no Matrix room") + } + } + br.DB.KV.Set(ctx, database.KeySplitPortalsEnabled, "true") + log.Info().Msg("Finished split portal migration successfully") + return affected > 0, func() { + for _, roomID := range roomsToDelete { + if err = br.Bot.DeleteRoom(ctx, roomID, true); err != nil { + log.Err(err). + Stringer("mxid", roomID). + Msg("Failed to delete portal room that failed to migrate") + } + } + log.Info().Int("room_count", len(roomsToDelete)).Msg("Finished deleting rooms that failed to migrate") + }, nil +} + +func (br *Bridge) StartLogins(ctx context.Context) error { + userIDs, err := br.DB.UserLogin.GetAllUserIDsWithLogins(ctx) + if err != nil { + return fmt.Errorf("failed to get users with logins: %w", err) + } + startedAny := false + for _, userID := range userIDs { + br.Log.Info().Stringer("user_id", userID).Msg("Loading user") + var user *User + user, err = br.GetUserByMXID(ctx, userID) + if err != nil { + br.Log.Err(err).Stringer("user_id", userID).Msg("Failed to load user") + } else { + for _, login := range user.GetUserLogins() { + startedAny = true + br.Log.Info().Str("id", string(login.ID)).Msg("Starting user login") + login.Client.Connect(login.Log.WithContext(ctx)) + } + } + } + if !startedAny { + br.Log.Info().Msg("No user logins found") + br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}) + } + if !br.Background { + go br.RunBackfillQueue() + } + + br.Log.Info().Msg("Bridge started") + return nil +} + +func (br *Bridge) ResetNetworkConnections() { + nrn, ok := br.Network.(NetworkResettingNetwork) + if ok { + br.Log.Info().Msg("Resetting network connections with NetworkConnector.ResetNetworkConnections") + nrn.ResetNetworkConnections() + return + } + + br.Log.Info().Msg("Network connector doesn't support ResetNetworkConnections, recreating clients manually") + for _, login := range br.GetAllCachedUserLogins() { + login.Log.Debug().Msg("Disconnecting and recreating client for network reset") + ctx := login.Log.WithContext(br.BackgroundCtx) + login.Client.Disconnect() + err := login.recreateClient(ctx) + if err != nil { + login.Log.Err(err).Msg("Failed to recreate client during network reset") + login.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: "bridgev2-network-reset-fail", + Info: map[string]any{"go_error": err.Error()}, + }) + } else { + login.Client.Connect(ctx) + } + } + br.Log.Info().Msg("Finished resetting all user logins") +} + +func (br *Bridge) GetHTTPClientSettings() exhttp.ClientSettings { + mchs, ok := br.Matrix.(MatrixConnectorWithHTTPSettings) + if ok { + return mchs.GetHTTPClientSettings() + } + return exhttp.SensibleClientSettings +} + +func (br *Bridge) IsStopping() bool { + return br.stopping.Load() +} + +func (br *Bridge) Stop() { + br.stop(false, 0) +} + +func (br *Bridge) StopWithTimeout(timeout time.Duration) { + br.stop(false, timeout) +} + +func (br *Bridge) stop(isRunOnce bool, timeout time.Duration) { + br.Log.Info().Msg("Shutting down bridge") + br.stopping.Store(true) + br.DisappearLoop.Stop() + br.stopBackfillQueue.Set() + br.Matrix.PreStop() + if !isRunOnce { + br.cacheLock.Lock() + var wg sync.WaitGroup + wg.Add(len(br.userLoginsByID)) + for _, login := range br.userLoginsByID { + go func() { + login.DisconnectWithTimeout(timeout) + wg.Done() + }() + } + br.cacheLock.Unlock() + wg.Wait() + } + br.Matrix.Stop() + if br.cancelBackgroundCtx != nil { + br.cancelBackgroundCtx() + } + if stopNet, ok := br.Network.(StoppableNetwork); ok { + stopNet.Stop() + } + if !br.ExternallyManagedDB { + err := br.DB.Close() + if err != nil { + br.Log.Warn().Err(err).Msg("Failed to close database") + } + } + br.Log.Info().Msg("Shutdown complete") +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/appservice.go b/mautrix-patched/bridgev2/bridgeconfig/appservice.go new file mode 100644 index 00000000..d1ab38f4 --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/appservice.go @@ -0,0 +1,142 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "fmt" + "regexp" + "strings" + "text/template" + + "go.mau.fi/util/exerrors" + "go.mau.fi/util/random" + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/id" +) + +type AppserviceConfig struct { + Address string `yaml:"address"` + PublicAddress string `yaml:"public_address"` + Hostname string `yaml:"hostname"` + Port uint16 `yaml:"port"` + NoServer bool `yaml:"-"` + + ID string `yaml:"id"` + Bot BotUserConfig `yaml:"bot"` + + ASToken string `yaml:"as_token"` + HSToken string `yaml:"hs_token"` + + EphemeralEvents bool `yaml:"ephemeral_events"` + AsyncTransactions bool `yaml:"async_transactions"` + + UsernameTemplate string `yaml:"username_template"` + usernameTemplate *template.Template `yaml:"-"` +} + +func (asc *AppserviceConfig) FormatUsername(username string) string { + if asc.usernameTemplate == nil { + asc.usernameTemplate = exerrors.Must(template.New("username").Parse(asc.UsernameTemplate)) + } + var buf strings.Builder + _ = asc.usernameTemplate.Execute(&buf, username) + return buf.String() +} + +func (config *Config) MakeUserIDRegex(matcher string) *regexp.Regexp { + usernamePlaceholder := strings.ToLower(random.String(16)) + usernameTemplate := fmt.Sprintf("@%s:%s", + config.AppService.FormatUsername(usernamePlaceholder), + config.Homeserver.Domain) + usernameTemplate = regexp.QuoteMeta(usernameTemplate) + usernameTemplate = strings.Replace(usernameTemplate, usernamePlaceholder, matcher, 1) + usernameTemplate = fmt.Sprintf("^%s$", usernameTemplate) + return regexp.MustCompile(usernameTemplate) +} + +// GetRegistration copies the data from the bridge config into an *appservice.Registration struct. +// This can't be used with the homeserver, see GenerateRegistration for generating files for the homeserver. +func (asc *AppserviceConfig) GetRegistration() *appservice.Registration { + reg := &appservice.Registration{} + asc.copyToRegistration(reg) + reg.SenderLocalpart = asc.Bot.Username + reg.ServerToken = asc.HSToken + reg.AppToken = asc.ASToken + return reg +} + +func (asc *AppserviceConfig) copyToRegistration(registration *appservice.Registration) { + registration.ID = asc.ID + registration.URL = asc.Address + falseVal := false + registration.RateLimited = &falseVal + registration.EphemeralEvents = asc.EphemeralEvents + registration.SoruEphemeralEvents = asc.EphemeralEvents +} + +func (ec *EncryptionConfig) applyUnstableFlags(registration *appservice.Registration) { + registration.MSC4190 = ec.MSC4190 + registration.MSC3202 = ec.Appservice +} + +// GenerateRegistration generates a registration file for the homeserver. +func (config *Config) GenerateRegistration() *appservice.Registration { + registration := appservice.CreateRegistration() + config.AppService.HSToken = registration.ServerToken + config.AppService.ASToken = registration.AppToken + config.AppService.copyToRegistration(registration) + config.Encryption.applyUnstableFlags(registration) + + registration.SenderLocalpart = random.String(32) + botRegex := regexp.MustCompile(fmt.Sprintf("^@%s:%s$", + regexp.QuoteMeta(config.AppService.Bot.Username), + regexp.QuoteMeta(config.Homeserver.Domain))) + registration.Namespaces.UserIDs.Register(botRegex, true) + registration.Namespaces.UserIDs.Register(config.MakeUserIDRegex(".*"), true) + + return registration +} + +func (config *Config) MakeAppService() *appservice.AppService { + as := appservice.Create() + as.HomeserverDomain = config.Homeserver.Domain + _ = as.SetHomeserverURL(config.Homeserver.Address) + as.Host.Hostname = config.AppService.Hostname + as.Host.Port = config.AppService.Port + as.Registration = config.AppService.GetRegistration() + as.DefaultHTTPRetries = config.Homeserver.RetryLimit + config.Encryption.applyUnstableFlags(as.Registration) + return as +} + +type BotUserConfig struct { + Username string `yaml:"username"` + Displayname string `yaml:"displayname"` + Avatar string `yaml:"avatar"` + + ParsedAvatar id.ContentURI `yaml:"-"` +} + +type serializableBUC BotUserConfig + +func (buc *BotUserConfig) UnmarshalYAML(node *yaml.Node) error { + var sbuc serializableBUC + err := node.Decode(&sbuc) + if err != nil { + return err + } + *buc = (BotUserConfig)(sbuc) + if buc.Avatar != "" && buc.Avatar != "remove" { + buc.ParsedAvatar, err = id.ParseContentURI(buc.Avatar) + if err != nil { + return fmt.Errorf("%w in bot avatar", err) + } + } + return nil +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/backfill.go b/mautrix-patched/bridgev2/bridgeconfig/backfill.go new file mode 100644 index 00000000..ab3ff1aa --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/backfill.go @@ -0,0 +1,45 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +type BackfillConfig struct { + Enabled bool `yaml:"enabled"` + MaxInitialMessages int `yaml:"max_initial_messages"` + MaxCatchupMessages int `yaml:"max_catchup_messages"` + UnreadHoursThreshold int `yaml:"unread_hours_threshold"` + + Threads BackfillThreadsConfig `yaml:"threads"` + Queue BackfillQueueConfig `yaml:"queue"` +} + +type BackfillThreadsConfig struct { + MaxInitialMessages int `yaml:"max_initial_messages"` +} + +type BackfillQueueConfig struct { + Enabled bool `yaml:"enabled"` + Manual bool `yaml:"manual"` + BatchSize int `yaml:"batch_size"` + BatchDelay int `yaml:"batch_delay"` + MaxBatches int `yaml:"max_batches"` + + MaxBatchesOverride map[string]int `yaml:"max_batches_override"` +} + +func (bcq *BackfillQueueConfig) AnyEnabled() bool { + return bcq.Enabled || bcq.Manual +} + +func (bqc *BackfillQueueConfig) GetOverride(names ...string) int { + for _, name := range names { + override, ok := bqc.MaxBatchesOverride[name] + if ok { + return override + } + } + return bqc.MaxBatches +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/config.go b/mautrix-patched/bridgev2/bridgeconfig/config.go new file mode 100644 index 00000000..24cc02fa --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/config.go @@ -0,0 +1,211 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "fmt" + "slices" + "time" + + "go.mau.fi/util/dbutil" + "go.mau.fi/zeroconfig" + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/mediaproxy" +) + +type Config struct { + Network yaml.Node `yaml:"network"` + Bridge BridgeConfig `yaml:"bridge"` + Database dbutil.Config `yaml:"database"` + Homeserver HomeserverConfig `yaml:"homeserver"` + AppService AppserviceConfig `yaml:"appservice"` + Matrix MatrixConfig `yaml:"matrix"` + Analytics AnalyticsConfig `yaml:"analytics"` + Provisioning ProvisioningConfig `yaml:"provisioning"` + PublicMedia PublicMediaConfig `yaml:"public_media"` + DirectMedia DirectMediaConfig `yaml:"direct_media"` + Backfill BackfillConfig `yaml:"backfill"` + DoublePuppet DoublePuppetConfig `yaml:"double_puppet"` + Encryption EncryptionConfig `yaml:"encryption"` + Logging zeroconfig.Config `yaml:"logging"` + + EnvConfigPrefix string `yaml:"env_config_prefix"` + + ManagementRoomTexts ManagementRoomTexts `yaml:"management_room_texts"` +} + +type CleanupAction string + +const ( + CleanupActionNull CleanupAction = "" + CleanupActionNothing CleanupAction = "nothing" + CleanupActionKick CleanupAction = "kick" + CleanupActionUnbridge CleanupAction = "unbridge" + CleanupActionDelete CleanupAction = "delete" +) + +type CleanupOnLogout struct { + Private CleanupAction `yaml:"private"` + Relayed CleanupAction `yaml:"relayed"` + SharedNoUsers CleanupAction `yaml:"shared_no_users"` + SharedHasUsers CleanupAction `yaml:"shared_has_users"` +} + +type CleanupOnLogouts struct { + Enabled bool `yaml:"enabled"` + Manual CleanupOnLogout `yaml:"manual"` + BadCredentials CleanupOnLogout `yaml:"bad_credentials"` +} + +type BridgeConfig struct { + CommandPrefix string `yaml:"command_prefix"` + PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` + PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` + AsyncEvents bool `yaml:"async_events"` + SplitPortals bool `yaml:"split_portals"` + ResendBridgeInfo bool `yaml:"resend_bridge_info"` + NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` + BridgeStatusNotices string `yaml:"bridge_status_notices"` + UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"` + UnknownErrorMaxAutoReconnects int `yaml:"unknown_error_max_auto_reconnects"` + BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` + BridgeNotices bool `yaml:"bridge_notices"` + TagOnlyOnCreate bool `yaml:"tag_only_on_create"` + OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` + MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` + DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` + CrossRoomReplies bool `yaml:"cross_room_replies"` + OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` + RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` + KickMatrixUsers bool `yaml:"kick_matrix_users"` + EnableSendStateRequests bool `yaml:"enable_send_state_requests"` + PhoneNumbersInProfile bool `yaml:"phone_numbers_in_profile"` + CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` + Relay RelayConfig `yaml:"relay"` + PortalCreateFilter PortalCreateFilter `yaml:"portal_create_filter"` + Permissions PermissionConfig `yaml:"permissions"` + Backfill BackfillConfig `yaml:"backfill"` +} + +type MatrixConfig struct { + MessageStatusEvents bool `yaml:"message_status_events"` + DeliveryReceipts bool `yaml:"delivery_receipts"` + MessageErrorNotices bool `yaml:"message_error_notices"` + SyncDirectChatList bool `yaml:"sync_direct_chat_list"` + FederateRooms bool `yaml:"federate_rooms"` + UploadFileThreshold int64 `yaml:"upload_file_threshold"` + GhostExtraProfileInfo bool `yaml:"ghost_extra_profile_info"` +} + +type AnalyticsConfig struct { + Token string `yaml:"token"` + URL string `yaml:"url"` + UserID string `yaml:"user_id"` +} + +type ProvisioningConfig struct { + SharedSecret string `yaml:"shared_secret"` + AllowMatrixAuth bool `yaml:"allow_matrix_auth"` + DebugEndpoints bool `yaml:"debug_endpoints"` + EnableSessionTransfers bool `yaml:"enable_session_transfers"` + FailOnWebAuthn bool `yaml:"fail_on_webauthn"` +} + +type DirectMediaConfig struct { + Enabled bool `yaml:"enabled"` + MediaIDPrefix string `yaml:"media_id_prefix"` + mediaproxy.BasicConfig `yaml:",inline"` +} + +type PublicMediaConfig struct { + Enabled bool `yaml:"enabled"` + SigningKey string `yaml:"signing_key"` + Expiry int `yaml:"expiry"` + HashLength int `yaml:"hash_length"` + PathPrefix string `yaml:"path_prefix"` + UseDatabase bool `yaml:"use_database"` +} + +type DoublePuppetConfig struct { + Servers map[string]string `yaml:"servers"` + AllowDiscovery bool `yaml:"allow_discovery"` + Secrets map[string]string `yaml:"secrets"` +} + +type ManagementRoomTexts struct { + Welcome string `yaml:"welcome"` + WelcomeConnected string `yaml:"welcome_connected"` + WelcomeUnconnected string `yaml:"welcome_unconnected"` + AdditionalHelp string `yaml:"additional_help"` +} + +type PortalCreateFilterItem struct { + ID networkid.PortalID `yaml:"id"` + Receiver *networkid.UserLoginID `yaml:"receiver"` +} + +func (pcfi *PortalCreateFilterItem) Equals(other *PortalCreateFilterItem) bool { + if pcfi == nil || other == nil { + return pcfi == other + } else if pcfi.ID != other.ID { + return false + } else if pcfi.Receiver == nil || other.Receiver == nil { + return pcfi.Receiver == other.Receiver + } + return *pcfi.Receiver == *other.Receiver +} + +func (pcfi *PortalCreateFilterItem) Matches(key networkid.PortalKey) bool { + return pcfi != nil && pcfi.ID == key.ID && (pcfi.Receiver == nil || *pcfi.Receiver == key.Receiver) +} + +type umPortalCreateFilterItem PortalCreateFilterItem + +func (pcfi *PortalCreateFilterItem) UnmarshalYAML(node *yaml.Node) error { + err := node.Decode((*umPortalCreateFilterItem)(pcfi)) + if err != nil { + err2 := node.Decode(&pcfi.ID) + if err2 != nil { + return fmt.Errorf("both decode attempts failed: %w / %w", err, err2) + } + } + return nil +} + +type PortalCreateFilterMode string + +const ( + PortalCreateFilterModeAllow PortalCreateFilterMode = "allow" + PortalCreateFilterModeDeny PortalCreateFilterMode = "deny" +) + +type PortalCreateFilter struct { + Mode PortalCreateFilterMode `yaml:"mode"` + List []*PortalCreateFilterItem `yaml:"list"` + + AlwaysDenyFromLogin []networkid.UserLoginID `yaml:"always_deny_from_login"` +} + +func (pcf *PortalCreateFilter) ShouldAllow(source networkid.UserLoginID, key networkid.PortalKey) bool { + if slices.Contains(pcf.AlwaysDenyFromLogin, source) { + return false + } + match := slices.ContainsFunc(pcf.List, func(item *PortalCreateFilterItem) bool { + return item.Matches(key) + }) + switch pcf.Mode { + case PortalCreateFilterModeAllow: + return match + case PortalCreateFilterModeDeny: + return !match + default: + return true + } +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/encryption.go b/mautrix-patched/bridgev2/bridgeconfig/encryption.go new file mode 100644 index 00000000..934613ca --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/encryption.go @@ -0,0 +1,51 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "maunium.net/go/mautrix/id" +) + +type EncryptionConfig struct { + Allow bool `yaml:"allow"` + Default bool `yaml:"default"` + Require bool `yaml:"require"` + Appservice bool `yaml:"appservice"` + MSC4190 bool `yaml:"msc4190"` + MSC4392 bool `yaml:"msc4392"` + SelfSign bool `yaml:"self_sign"` + + PlaintextMentions bool `yaml:"plaintext_mentions"` + + PickleKey string `yaml:"pickle_key"` + + DeleteKeys struct { + DeleteOutboundOnAck bool `yaml:"delete_outbound_on_ack"` + DontStoreOutbound bool `yaml:"dont_store_outbound"` + RatchetOnDecrypt bool `yaml:"ratchet_on_decrypt"` + DeleteFullyUsedOnDecrypt bool `yaml:"delete_fully_used_on_decrypt"` + DeletePrevOnNewSession bool `yaml:"delete_prev_on_new_session"` + DeleteOnDeviceDelete bool `yaml:"delete_on_device_delete"` + PeriodicallyDeleteExpired bool `yaml:"periodically_delete_expired"` + DeleteOutdatedInbound bool `yaml:"delete_outdated_inbound"` + } `yaml:"delete_keys"` + + VerificationLevels struct { + Receive id.TrustState `yaml:"receive"` + Send id.TrustState `yaml:"send"` + Share id.TrustState `yaml:"share"` + } `yaml:"verification_levels"` + AllowKeySharing bool `yaml:"allow_key_sharing"` + + Rotation struct { + EnableCustom bool `yaml:"enable_custom"` + Milliseconds int64 `yaml:"milliseconds"` + Messages int `yaml:"messages"` + + DisableDeviceChangeKeyRotation bool `yaml:"disable_device_change_key_rotation"` + } `yaml:"rotation"` +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/homeserver.go b/mautrix-patched/bridgev2/bridgeconfig/homeserver.go new file mode 100644 index 00000000..4fe417c7 --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/homeserver.go @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +type HomeserverSoftware string + +const ( + SoftwareStandard HomeserverSoftware = "standard" + SoftwareAsmux HomeserverSoftware = "asmux" + SoftwareHungry HomeserverSoftware = "hungry" +) + +var AllowedHomeserverSoftware = map[HomeserverSoftware]bool{ + SoftwareStandard: true, + SoftwareAsmux: true, + SoftwareHungry: true, +} + +type HomeserverConfig struct { + Address string `yaml:"address"` + Domain string `yaml:"domain"` + AsyncMedia bool `yaml:"async_media"` + + PublicAddress string `yaml:"public_address,omitempty"` + + Software HomeserverSoftware `yaml:"software"` + + StatusEndpoint string `yaml:"status_endpoint"` + MessageSendCheckpointEndpoint string `yaml:"message_send_checkpoint_endpoint"` + + Websocket bool `yaml:"websocket"` + WSProxy string `yaml:"websocket_proxy"` + WSPingInterval int `yaml:"ping_interval_seconds"` + + RetryLimit int `yaml:"retry_limit"` +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go b/mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go new file mode 100644 index 00000000..add9fc04 --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go @@ -0,0 +1,176 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "fmt" + "net/url" + "os" + "strings" + + up "go.mau.fi/util/configupgrade" +) + +var HackyMigrateLegacyNetworkConfig func(up.Helper) + +func CopyToOtherLocation(helper up.Helper, fieldType up.YAMLType, source, dest []string) { + val, ok := helper.Get(fieldType, source...) + if ok { + helper.Set(fieldType, val, dest...) + } +} + +func CopyMapToOtherLocation(helper up.Helper, source, dest []string) { + val := helper.GetNode(source...) + if val != nil && val.Map != nil { + helper.SetMap(val.Map, dest...) + } +} + +func doMigrateLegacy(helper up.Helper, python bool) { + if HackyMigrateLegacyNetworkConfig == nil { + _, _ = fmt.Fprintln(os.Stderr, "Legacy bridge config detected, but hacky network config migrator is not set") + os.Exit(1) + } + _, _ = fmt.Fprintln(os.Stderr, "Migrating legacy bridge config") + + helper.Copy(up.Str, "homeserver", "address") + helper.Copy(up.Str, "homeserver", "domain") + helper.Copy(up.Str, "homeserver", "software") + helper.Copy(up.Str|up.Null, "homeserver", "status_endpoint") + helper.Copy(up.Str|up.Null, "homeserver", "message_send_checkpoint_endpoint") + helper.Copy(up.Bool, "homeserver", "async_media") + helper.Copy(up.Str|up.Null, "homeserver", "websocket_proxy") + helper.Copy(up.Bool, "homeserver", "websocket") + helper.Copy(up.Int, "homeserver", "ping_interval_seconds") + + helper.Copy(up.Str|up.Null, "appservice", "address") + helper.Copy(up.Str|up.Null, "appservice", "hostname") + helper.Copy(up.Int|up.Null, "appservice", "port") + helper.Copy(up.Str, "appservice", "id") + if python { + CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_username"}, []string{"appservice", "bot", "username"}) + CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_displayname"}, []string{"appservice", "bot", "displayname"}) + CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_avatar"}, []string{"appservice", "bot", "avatar"}) + } else { + helper.Copy(up.Str, "appservice", "bot", "username") + helper.Copy(up.Str, "appservice", "bot", "displayname") + helper.Copy(up.Str, "appservice", "bot", "avatar") + } + helper.Copy(up.Bool, "appservice", "ephemeral_events") + helper.Copy(up.Bool, "appservice", "async_transactions") + helper.Copy(up.Str, "appservice", "as_token") + helper.Copy(up.Str, "appservice", "hs_token") + + helper.Copy(up.Str, "bridge", "command_prefix") + helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") + if oldPM, ok := helper.Get(up.Str, "bridge", "private_chat_portal_meta"); ok && (oldPM == "default" || oldPM == "always") { + helper.Set(up.Bool, "true", "bridge", "private_chat_portal_meta") + } else { + helper.Set(up.Bool, "false", "bridge", "private_chat_portal_meta") + } + helper.Copy(up.Bool, "bridge", "relay", "enabled") + helper.Copy(up.Bool, "bridge", "relay", "admin_only") + helper.Copy(up.Map, "bridge", "permissions") + + if python { + legacyDB, ok := helper.Get(up.Str, "appservice", "database") + if ok { + if strings.HasPrefix(legacyDB, "postgres") { + parsedDB, err := url.Parse(legacyDB) + if err != nil { + panic(err) + } + q := parsedDB.Query() + if parsedDB.Host == "" && !q.Has("host") { + q.Set("host", "/var/run/postgresql") + } else if !q.Has("sslmode") { + q.Set("sslmode", "disable") + } + parsedDB.RawQuery = q.Encode() + helper.Set(up.Str, parsedDB.String(), "database", "uri") + helper.Set(up.Str, "postgres", "database", "type") + } else { + dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///") + helper.Set(up.Str, fmt.Sprintf("file:%s?_txlock=immediate", dbPath), "database", "uri") + helper.Set(up.Str, "sqlite3-fk-wal", "database", "type") + } + } + if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok { + helper.Set(up.Int, legacyDBMinSize, "database", "max_idle_conns") + } + if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok { + helper.Set(up.Int, legacyDBMaxSize, "database", "max_open_conns") + } + } else { + if dbType, ok := helper.Get(up.Str, "appservice", "database", "type"); ok && dbType == "sqlite3" { + helper.Set(up.Str, "sqlite3-fk-wal", "database", "type") + } else { + CopyToOtherLocation(helper, up.Str, []string{"appservice", "database", "type"}, []string{"database", "type"}) + } + CopyToOtherLocation(helper, up.Str, []string{"appservice", "database", "uri"}, []string{"database", "uri"}) + CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_open_conns"}, []string{"database", "max_open_conns"}) + CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_idle_conns"}, []string{"database", "max_idle_conns"}) + CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_conn_idle_time"}, []string{"database", "max_conn_idle_time"}) + CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_conn_lifetime"}, []string{"database", "max_conn_lifetime"}) + } + + if python { + if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") { + helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "appservice", "username_template") + } + } else { + CopyToOtherLocation(helper, up.Str, []string{"bridge", "username_template"}, []string{"appservice", "username_template"}) + } + + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "message_status_events"}, []string{"matrix", "message_status_events"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "delivery_receipts"}, []string{"matrix", "delivery_receipts"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "message_error_notices"}, []string{"matrix", "message_error_notices"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chat_list"}, []string{"matrix", "sync_direct_chat_list"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "federate_rooms"}, []string{"matrix", "federate_rooms"}) + + CopyToOtherLocation(helper, up.Str, []string{"bridge", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"}) + CopyToOtherLocation(helper, up.Str, []string{"appservice", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "provisioning", "debug_endpoints"}, []string{"provisioning", "debug_endpoints"}) + + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "double_puppet_allow_discovery"}, []string{"double_puppet", "allow_discovery"}) + CopyMapToOtherLocation(helper, []string{"bridge", "double_puppet_server_map"}, []string{"double_puppet", "servers"}) + CopyMapToOtherLocation(helper, []string{"bridge", "login_shared_secret_map"}, []string{"double_puppet", "secrets"}) + + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "allow"}, []string{"encryption", "allow"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "default"}, []string{"encryption", "default"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "require"}, []string{"encryption", "require"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "appservice"}, []string{"encryption", "appservice"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "msc4190"}, []string{"encryption", "msc4190"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "self_sign"}, []string{"encryption", "self_sign"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "allow_key_sharing"}, []string{"encryption", "allow_key_sharing"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_outbound_on_ack"}, []string{"encryption", "delete_keys", "delete_outbound_on_ack"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "dont_store_outbound"}, []string{"encryption", "delete_keys", "dont_store_outbound"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "ratchet_on_decrypt"}, []string{"encryption", "delete_keys", "ratchet_on_decrypt"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt"}, []string{"encryption", "delete_keys", "delete_fully_used_on_decrypt"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_prev_on_new_session"}, []string{"encryption", "delete_keys", "delete_prev_on_new_session"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_on_device_delete"}, []string{"encryption", "delete_keys", "delete_on_device_delete"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "periodically_delete_expired"}, []string{"encryption", "delete_keys", "periodically_delete_expired"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_outdated_inbound"}, []string{"encryption", "delete_keys", "delete_outdated_inbound"}) + CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "receive"}, []string{"encryption", "verification_levels", "receive"}) + CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "send"}, []string{"encryption", "verification_levels", "send"}) + CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "share"}, []string{"encryption", "verification_levels", "share"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "rotation", "enable_custom"}, []string{"encryption", "rotation", "enable_custom"}) + CopyToOtherLocation(helper, up.Int, []string{"bridge", "encryption", "rotation", "milliseconds"}, []string{"encryption", "rotation", "milliseconds"}) + CopyToOtherLocation(helper, up.Int, []string{"bridge", "encryption", "rotation", "messages"}, []string{"encryption", "rotation", "messages"}) + CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "rotation", "disable_device_change_key_rotation"}, []string{"encryption", "rotation", "disable_device_change_key_rotation"}) + + if helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "print_level") != nil || helper.GetNode("logging", "file_name_format") != nil) { + _, _ = fmt.Fprintln(os.Stderr, "Migrating maulogger configs is not supported") + } else if (helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "handlers") != nil)) || python { + _, _ = fmt.Fprintln(os.Stderr, "Migrating Python log configs is not supported") + } else { + helper.Copy(up.Map, "logging") + } + + HackyMigrateLegacyNetworkConfig(helper) +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/permissions.go b/mautrix-patched/bridgev2/bridgeconfig/permissions.go new file mode 100644 index 00000000..9efe068e --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/permissions.go @@ -0,0 +1,124 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "fmt" + "os" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix/id" +) + +type Permissions struct { + SendEvents bool `yaml:"send_events"` + Commands bool `yaml:"commands"` + Login bool `yaml:"login"` + DoublePuppet bool `yaml:"double_puppet"` + Admin bool `yaml:"admin"` + ManageRelay bool `yaml:"manage_relay"` + MaxLogins int `yaml:"max_logins"` +} + +type PermissionConfig map[string]*Permissions + +func boolToInt(val bool) int { + if val { + return 1 + } + return 0 +} + +func (pc PermissionConfig) IsConfigured() bool { + _, hasWildcard := pc["*"] + _, hasExampleDomain := pc["example.com"] + _, hasExampleUser := pc["@admin:example.com"] + exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) + return len(pc) > exampleLen +} + +func (pc PermissionConfig) Get(userID id.UserID) Permissions { + if level, ok := pc[string(userID)]; ok { + return *level + } else if level, ok = pc[userID.Homeserver()]; len(userID.Homeserver()) > 0 && ok { + return *level + } else if level, ok = pc["*"]; ok { + return *level + } else { + return PermissionLevelBlock + } +} + +var ( + PermissionLevelBlock = Permissions{} + PermissionLevelRelay = Permissions{SendEvents: true} + PermissionLevelCommands = Permissions{SendEvents: true, Commands: true, ManageRelay: true} + PermissionLevelUser = Permissions{SendEvents: true, Commands: true, ManageRelay: true, Login: true, DoublePuppet: true} + PermissionLevelAdmin = Permissions{SendEvents: true, Commands: true, ManageRelay: true, Login: true, DoublePuppet: true, Admin: true} +) + +var namesToLevels = map[string]Permissions{ + "block": PermissionLevelBlock, + "relay": PermissionLevelRelay, + "commands": PermissionLevelCommands, + "user": PermissionLevelUser, + "admin": PermissionLevelAdmin, +} + +var levelsToNames = map[Permissions]string{ + PermissionLevelBlock: "block", + PermissionLevelRelay: "relay", + PermissionLevelCommands: "commands", + PermissionLevelUser: "user", + PermissionLevelAdmin: "admin", +} + +type umPerm Permissions + +func (p *Permissions) UnmarshalYAML(perm *yaml.Node) error { + switch perm.Tag { + case "!!str": + var ok bool + *p, ok = namesToLevels[strings.ToLower(perm.Value)] + if !ok { + return fmt.Errorf("invalid permissions level %s", perm.Value) + } + return nil + case "!!map": + err := perm.Decode((*umPerm)(p)) + return err + case "!!int": + val, err := strconv.Atoi(perm.Value) + if err != nil { + return fmt.Errorf("invalid permissions level %s", perm.Value) + } + _, _ = fmt.Fprintln(os.Stderr, "Warning: config contains deprecated integer permission values") + // Integer values are deprecated, so they're hardcoded + if val < 5 { + *p = PermissionLevelBlock + } else if val < 10 { + *p = PermissionLevelRelay + } else if val < 100 { + *p = PermissionLevelUser + } else { + *p = PermissionLevelAdmin + } + return nil + default: + return fmt.Errorf("invalid permissions type %s", perm.Tag) + } +} + +func (p *Permissions) MarshalYAML() (any, error) { + if level, ok := levelsToNames[*p]; ok { + return level, nil + } + return umPerm(*p), nil +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/relay.go b/mautrix-patched/bridgev2/bridgeconfig/relay.go new file mode 100644 index 00000000..0ea8eb2a --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/relay.go @@ -0,0 +1,111 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "fmt" + "strings" + "text/template" + + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +type RelayConfig struct { + Enabled bool `yaml:"enabled"` + AdminOnly bool `yaml:"admin_only"` + PreferDefault bool `yaml:"prefer_default"` + AllowBridge bool `yaml:"allow_bridge"` + DefaultRelays []networkid.UserLoginID `yaml:"default_relays"` + MessageFormats map[event.MessageType]string `yaml:"message_formats"` + DisplaynameFormat string `yaml:"displayname_format"` + messageTemplates *template.Template `yaml:"-"` + nameTemplate *template.Template `yaml:"-"` +} + +type umRelayConfig RelayConfig + +func (rc *RelayConfig) UnmarshalYAML(node *yaml.Node) error { + err := node.Decode((*umRelayConfig)(rc)) + if err != nil { + return err + } + + rc.messageTemplates = template.New("messageTemplates") + for key, template := range rc.MessageFormats { + _, err = rc.messageTemplates.New(string(key)).Parse(template) + if err != nil { + return err + } + } + + rc.nameTemplate, err = template.New("nameTemplate").Parse(rc.DisplaynameFormat) + if err != nil { + return err + } + + return nil +} + +type formatData struct { + Sender any + Content *event.MessageEventContent + Caption string + Message string + FileName string +} + +func isMedia(msgType event.MessageType) bool { + switch msgType { + case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: + return true + default: + return false + } +} + +func (rc *RelayConfig) FormatMessage(content *event.MessageEventContent, sender any) (*event.MessageEventContent, error) { + _, isSupported := rc.MessageFormats[content.MsgType] + if !isSupported { + return nil, fmt.Errorf("relay format for %q is not defined in config", content.MsgType) + } + contentCopy := *content + content = &contentCopy + content.EnsureHasHTML() + fd := &formatData{ + Sender: sender, + Content: content, + Message: content.FormattedBody, + } + fd.Message = content.FormattedBody + if content.FileName != "" { + fd.FileName = content.FileName + if content.FileName != content.Body { + fd.Caption = fd.Message + } + } else if isMedia(content.MsgType) { + content.FileName = content.Body + fd.FileName = content.Body + } + var output strings.Builder + err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), fd) + if err != nil { + return nil, err + } + content.FormattedBody = output.String() + content.Body = format.HTMLToText(content.FormattedBody) + return content, nil +} + +func (rc *RelayConfig) FormatName(sender any) string { + var buf strings.Builder + _ = rc.nameTemplate.Execute(&buf, sender) + return strings.TrimSpace(buf.String()) +} diff --git a/mautrix-patched/bridgev2/bridgeconfig/upgrade.go b/mautrix-patched/bridgev2/bridgeconfig/upgrade.go new file mode 100644 index 00000000..75ca76ea --- /dev/null +++ b/mautrix-patched/bridgev2/bridgeconfig/upgrade.go @@ -0,0 +1,240 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgeconfig + +import ( + "fmt" + + up "go.mau.fi/util/configupgrade" + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/federation" +) + +func doUpgrade(helper up.Helper) { + if _, isLegacyConfig := helper.Get(up.Str, "appservice", "database", "uri"); isLegacyConfig { + doMigrateLegacy(helper, false) + return + } else if _, isLegacyPython := helper.Get(up.Str, "appservice", "database"); isLegacyPython { + doMigrateLegacy(helper, true) + return + } + + helper.Copy(up.Str, "bridge", "command_prefix") + helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") + helper.Copy(up.Bool, "bridge", "private_chat_portal_meta") + helper.Copy(up.Bool, "bridge", "async_events") + helper.Copy(up.Bool, "bridge", "split_portals") + helper.Copy(up.Bool, "bridge", "resend_bridge_info") + helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key") + helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices") + helper.Copy(up.Str|up.Int|up.Null, "bridge", "unknown_error_auto_reconnect") + helper.Copy(up.Int, "bridge", "unknown_error_max_auto_reconnects") + helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") + helper.Copy(up.Bool, "bridge", "bridge_notices") + helper.Copy(up.Bool, "bridge", "tag_only_on_create") + helper.Copy(up.List, "bridge", "only_bridge_tags") + helper.Copy(up.Bool, "bridge", "mute_only_on_create") + helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") + helper.Copy(up.Bool, "bridge", "cross_room_replies") + helper.Copy(up.Bool, "bridge", "revert_failed_state_changes") + helper.Copy(up.Bool, "bridge", "kick_matrix_users") + helper.Copy(up.Bool, "bridge", "enable_send_state_requests") + helper.Copy(up.Bool, "bridge", "phone_numbers_in_profile") + helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "shared_no_users") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "shared_has_users") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "private") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "relayed") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "shared_no_users") + helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "shared_has_users") + helper.Copy(up.Bool, "bridge", "relay", "enabled") + helper.Copy(up.Bool, "bridge", "relay", "admin_only") + helper.Copy(up.Bool, "bridge", "relay", "prefer_default") + helper.Copy(up.Bool, "bridge", "relay", "allow_bridge") + helper.Copy(up.List, "bridge", "relay", "default_relays") + helper.Copy(up.Map, "bridge", "relay", "message_formats") + helper.Copy(up.Str, "bridge", "relay", "displayname_format") + helper.Copy(up.Str, "bridge", "portal_create_filter", "mode") + helper.Copy(up.List, "bridge", "portal_create_filter", "list") + helper.Copy(up.List, "bridge", "portal_create_filter", "always_deny_from_login") + helper.Copy(up.Map, "bridge", "permissions") + + if dbType, ok := helper.Get(up.Str, "database", "type"); ok && dbType == "sqlite3" { + fmt.Println("Warning: invalid database type sqlite3 in config. Autocorrecting to sqlite3-fk-wal") + helper.Set(up.Str, "sqlite3-fk-wal", "database", "type") + } else { + helper.Copy(up.Str, "database", "type") + } + helper.Copy(up.Str, "database", "uri") + helper.Copy(up.Int, "database", "max_open_conns") + helper.Copy(up.Int, "database", "max_idle_conns") + helper.Copy(up.Str|up.Null, "database", "max_conn_idle_time") + helper.Copy(up.Str|up.Null, "database", "max_conn_lifetime") + + helper.Copy(up.Str, "homeserver", "address") + helper.Copy(up.Str, "homeserver", "domain") + helper.Copy(up.Str, "homeserver", "software") + helper.Copy(up.Str|up.Null, "homeserver", "status_endpoint") + helper.Copy(up.Str|up.Null, "homeserver", "message_send_checkpoint_endpoint") + helper.Copy(up.Bool, "homeserver", "async_media") + helper.Copy(up.Str|up.Null, "homeserver", "websocket_proxy") + helper.Copy(up.Bool, "homeserver", "websocket") + helper.Copy(up.Int, "homeserver", "ping_interval_seconds") + helper.Copy(up.Int, "homeserver", "retry_limit") + + helper.Copy(up.Str|up.Null, "appservice", "address") + helper.Copy(up.Str|up.Null, "appservice", "public_address") + helper.Copy(up.Str|up.Null, "appservice", "hostname") + helper.Copy(up.Int|up.Null, "appservice", "port") + helper.Copy(up.Str, "appservice", "id") + helper.Copy(up.Str, "appservice", "bot", "username") + helper.Copy(up.Str, "appservice", "bot", "displayname") + helper.Copy(up.Str, "appservice", "bot", "avatar") + helper.Copy(up.Bool, "appservice", "ephemeral_events") + helper.Copy(up.Bool, "appservice", "async_transactions") + helper.Copy(up.Str, "appservice", "as_token") + helper.Copy(up.Str, "appservice", "hs_token") + helper.Copy(up.Str, "appservice", "username_template") + + helper.Copy(up.Bool, "matrix", "message_status_events") + helper.Copy(up.Bool, "matrix", "delivery_receipts") + helper.Copy(up.Bool, "matrix", "message_error_notices") + helper.Copy(up.Bool, "matrix", "sync_direct_chat_list") + helper.Copy(up.Bool, "matrix", "federate_rooms") + helper.Copy(up.Int, "matrix", "upload_file_threshold") + helper.Copy(up.Bool, "matrix", "ghost_extra_profile_info") + + helper.Copy(up.Str|up.Null, "analytics", "token") + helper.Copy(up.Str|up.Null, "analytics", "url") + helper.Copy(up.Str|up.Null, "analytics", "user_id") + + if secret, ok := helper.Get(up.Str, "provisioning", "shared_secret"); !ok || secret == "generate" { + sharedSecret := random.String(64) + helper.Set(up.Str, sharedSecret, "provisioning", "shared_secret") + } else { + helper.Copy(up.Str, "provisioning", "shared_secret") + } + helper.Copy(up.Bool, "provisioning", "allow_matrix_auth") + helper.Copy(up.Bool, "provisioning", "debug_endpoints") + helper.Copy(up.Bool, "provisioning", "enable_session_transfers") + helper.Copy(up.Bool, "provisioning", "fail_on_webauthn") + + helper.Copy(up.Bool, "direct_media", "enabled") + helper.Copy(up.Str|up.Null, "direct_media", "media_id_prefix") + helper.Copy(up.Str, "direct_media", "server_name") + helper.Copy(up.Str|up.Null, "direct_media", "well_known_response") + helper.Copy(up.Bool, "direct_media", "allow_proxy") + if serverKey, ok := helper.Get(up.Str, "direct_media", "server_key"); !ok || serverKey == "generate" { + serverKey = federation.GenerateSigningKey().SynapseString() + helper.Set(up.Str, serverKey, "direct_media", "server_key") + } else { + helper.Copy(up.Str, "direct_media", "server_key") + } + + helper.Copy(up.Bool, "public_media", "enabled") + if signingKey, ok := helper.Get(up.Str, "public_media", "signing_key"); !ok || signingKey == "generate" { + helper.Set(up.Str, random.String(64), "public_media", "signing_key") + } else { + helper.Copy(up.Str, "public_media", "signing_key") + } + helper.Copy(up.Int, "public_media", "expiry") + helper.Copy(up.Int, "public_media", "hash_length") + helper.Copy(up.Str|up.Null, "public_media", "path_prefix") + helper.Copy(up.Bool, "public_media", "use_database") + + helper.Copy(up.Bool, "backfill", "enabled") + helper.Copy(up.Int, "backfill", "max_initial_messages") + helper.Copy(up.Int, "backfill", "max_catchup_messages") + helper.Copy(up.Int, "backfill", "unread_hours_threshold") + helper.Copy(up.Int, "backfill", "threads", "max_initial_messages") + helper.Copy(up.Bool, "backfill", "queue", "enabled") + helper.Copy(up.Bool, "backfill", "queue", "manual") + helper.Copy(up.Int, "backfill", "queue", "batch_size") + helper.Copy(up.Int, "backfill", "queue", "batch_delay") + helper.Copy(up.Int, "backfill", "queue", "max_batches") + helper.Copy(up.Map, "backfill", "queue", "max_batches_override") + + helper.Copy(up.Map, "double_puppet", "servers") + helper.Copy(up.Bool, "double_puppet", "allow_discovery") + helper.Copy(up.Map, "double_puppet", "secrets") + + helper.Copy(up.Bool, "encryption", "allow") + helper.Copy(up.Bool, "encryption", "default") + helper.Copy(up.Bool, "encryption", "require") + helper.Copy(up.Bool, "encryption", "appservice") + if val, ok := helper.Get(up.Bool, "appservice", "msc4190"); ok { + helper.Set(up.Bool, val, "encryption", "msc4190") + } else { + helper.Copy(up.Bool, "encryption", "msc4190") + } + helper.Copy(up.Bool, "encryption", "msc4392") + helper.Copy(up.Bool, "encryption", "self_sign") + helper.Copy(up.Bool, "encryption", "allow_key_sharing") + helper.Copy(up.Bool, "encryption", "plaintext_mentions") + if secret, ok := helper.Get(up.Str, "encryption", "pickle_key"); !ok || secret == "generate" { + helper.Set(up.Str, random.String(64), "encryption", "pickle_key") + } else { + helper.Copy(up.Str, "encryption", "pickle_key") + } + helper.Copy(up.Bool, "encryption", "delete_keys", "delete_outbound_on_ack") + helper.Copy(up.Bool, "encryption", "delete_keys", "dont_store_outbound") + helper.Copy(up.Bool, "encryption", "delete_keys", "ratchet_on_decrypt") + helper.Copy(up.Bool, "encryption", "delete_keys", "delete_fully_used_on_decrypt") + helper.Copy(up.Bool, "encryption", "delete_keys", "delete_prev_on_new_session") + helper.Copy(up.Bool, "encryption", "delete_keys", "delete_on_device_delete") + helper.Copy(up.Bool, "encryption", "delete_keys", "periodically_delete_expired") + helper.Copy(up.Bool, "encryption", "delete_keys", "delete_outdated_inbound") + helper.Copy(up.Str, "encryption", "verification_levels", "receive") + helper.Copy(up.Str, "encryption", "verification_levels", "send") + helper.Copy(up.Str, "encryption", "verification_levels", "share") + helper.Copy(up.Bool, "encryption", "rotation", "enable_custom") + helper.Copy(up.Int, "encryption", "rotation", "milliseconds") + helper.Copy(up.Int, "encryption", "rotation", "messages") + helper.Copy(up.Bool, "encryption", "rotation", "disable_device_change_key_rotation") + + helper.Copy(up.Str|up.Null, "env_config_prefix") + + helper.Copy(up.Map, "logging") +} + +var SpacedBlocks = [][]string{ + {"bridge"}, + {"bridge", "bridge_matrix_leave"}, + {"bridge", "cleanup_on_logout"}, + {"bridge", "relay"}, + {"bridge", "portal_create_filter"}, + {"bridge", "permissions"}, + {"database"}, + {"homeserver"}, + {"homeserver", "software"}, + {"homeserver", "websocket"}, + {"appservice"}, + {"appservice", "hostname"}, + {"appservice", "id"}, + {"appservice", "ephemeral_events"}, + {"appservice", "as_token"}, + {"appservice", "username_template"}, + {"matrix"}, + {"analytics"}, + {"provisioning"}, + {"public_media"}, + {"direct_media"}, + {"backfill"}, + {"double_puppet"}, + {"encryption"}, + {"env_config_prefix"}, + {"logging"}, +} + +// Upgrader is a config upgrader that copies the default fields in the homeserver, appservice and logging blocks. +var Upgrader up.SpacedUpgrader = &up.StructUpgrader{ + SimpleUpgrader: up.SimpleUpgrader(doUpgrade), + Blocks: SpacedBlocks, +} diff --git a/mautrix-patched/bridgev2/bridgestate.go b/mautrix-patched/bridgev2/bridgestate.go new file mode 100644 index 00000000..96d9fd5c --- /dev/null +++ b/mautrix-patched/bridgev2/bridgestate.go @@ -0,0 +1,333 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "fmt" + "math/rand/v2" + "runtime/debug" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exfmt" + + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +var CatchBridgeStateQueuePanics = true + +type BridgeStateQueue struct { + prevUnsent *status.BridgeState + prevSent *status.BridgeState + errorSent bool + ch chan status.BridgeState + bridge *Bridge + login *UserLogin + + firstTransientDisconnect time.Time + cancelScheduledNotice atomic.Pointer[context.CancelFunc] + + stopChan chan struct{} + stopReconnect atomic.Pointer[context.CancelFunc] + + unknownErrorReconnects int +} + +func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { + state = state.Fill(nil) + for { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := br.Matrix.SendBridgeStatus(ctx, &state); err != nil { + br.Log.Warn().Err(err).Msg("Failed to update global bridge state") + cancel() + time.Sleep(5 * time.Second) + continue + } else { + br.Log.Debug().Any("bridge_state", state).Msg("Sent new global bridge state") + cancel() + break + } + } +} + +func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue { + bsq := &BridgeStateQueue{ + ch: make(chan status.BridgeState, 10), + stopChan: make(chan struct{}), + bridge: br, + login: login, + } + go bsq.loop() + return bsq +} + +func (bsq *BridgeStateQueue) Destroy() { + close(bsq.stopChan) + close(bsq.ch) + bsq.StopUnknownErrorReconnect() +} + +func (bsq *BridgeStateQueue) StopUnknownErrorReconnect() { + if bsq == nil { + return + } + if cancelFn := bsq.stopReconnect.Swap(nil); cancelFn != nil { + (*cancelFn)() + } + if cancelFn := bsq.cancelScheduledNotice.Swap(nil); cancelFn != nil { + (*cancelFn)() + } +} + +func (bsq *BridgeStateQueue) loop() { + if CatchBridgeStateQueuePanics { + defer func() { + err := recover() + if err != nil { + bsq.login.Log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()). + Any(zerolog.ErrorFieldName, err). + Msg("Panic in bridge state loop") + } + }() + } + for state := range bsq.ch { + bsq.immediateSendBridgeState(state) + } +} + +func (bsq *BridgeStateQueue) scheduleNotice(triggeredBy status.BridgeState) { + log := bsq.login.Log.With().Str("action", "transient disconnect notice").Logger() + ctx := log.WithContext(bsq.bridge.BackgroundCtx) + if !bsq.waitForTransientDisconnectReconnect(ctx) { + return + } + prevUnsent := bsq.GetPrevUnsent() + prev := bsq.GetPrev() + if triggeredBy.Timestamp != prev.Timestamp || len(bsq.ch) > 0 || bsq.errorSent || + prevUnsent.StateEvent != status.StateTransientDisconnect || prev.StateEvent != status.StateTransientDisconnect { + log.Trace().Any("triggered_by", triggeredBy).Msg("Not sending delayed transient disconnect notice") + return + } + log.Debug().Any("triggered_by", triggeredBy).Msg("Sending delayed transient disconnect notice") + bsq.sendNotice(ctx, triggeredBy, true) +} + +func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState, isDelayed bool) { + noticeConfig := bsq.bridge.Config.BridgeStatusNotices + isError := state.StateEvent == status.StateBadCredentials || + state.StateEvent == status.StateUnknownError || + state.UserAction == status.UserActionOpenNative || + (isDelayed && state.StateEvent == status.StateTransientDisconnect) + sendNotice := noticeConfig == "all" || (noticeConfig == "errors" && + (isError || (bsq.errorSent && state.StateEvent == status.StateConnected))) + if state.StateEvent != status.StateTransientDisconnect && state.StateEvent != status.StateUnknownError { + bsq.firstTransientDisconnect = time.Time{} + } + if !sendNotice { + if !bsq.errorSent && !isDelayed && noticeConfig == "errors" && state.StateEvent == status.StateTransientDisconnect { + if bsq.firstTransientDisconnect.IsZero() { + bsq.firstTransientDisconnect = time.Now() + } + go bsq.scheduleNotice(state) + } + return + } + managementRoom, err := bsq.login.User.GetManagementRoom(ctx) + if err != nil { + bsq.login.Log.Err(err).Msg("Failed to get management room") + return + } + name := bsq.login.RemoteName + if name == "" { + name = fmt.Sprintf("`%s`", bsq.login.ID) + } + message := fmt.Sprintf("State update for %s: `%s`", name, state.StateEvent) + if state.Error != "" { + message += fmt.Sprintf(" (`%s`)", state.Error) + } + if isDelayed { + message += fmt.Sprintf(" not resolved after waiting %s", exfmt.Duration(TransientDisconnectNoticeDelay)) + } + if state.Message != "" { + message += fmt.Sprintf(": %s", state.Message) + } + content := format.RenderMarkdown(message, true, false) + if !isError { + content.MsgType = event.MsgNotice + } + _, err = bsq.bridge.Bot.SendMessage(ctx, managementRoom, event.EventMessage, &event.Content{ + Parsed: content, + Raw: map[string]any{ + "fi.mau.bridge_state": state, + }, + }, nil) + if err != nil { + bsq.login.Log.Err(err).Msg("Failed to send bridge state notice") + } else { + bsq.errorSent = isError + } +} + +func (bsq *BridgeStateQueue) unknownErrorReconnect(triggeredBy status.BridgeState) { + log := bsq.login.Log.With().Str("action", "unknown error reconnect").Logger() + ctx := log.WithContext(bsq.bridge.BackgroundCtx) + if !bsq.waitForUnknownErrorReconnect(ctx) { + return + } + prevUnsent := bsq.GetPrevUnsent() + prev := bsq.GetPrev() + if triggeredBy.Timestamp != prev.Timestamp { + log.Debug().Msg("Not reconnecting as a new bridge state was sent after the unknown error") + return + } else if len(bsq.ch) > 0 { + log.Warn().Msg("Not reconnecting as there are unsent bridge states") + return + } else if prevUnsent.StateEvent != status.StateUnknownError || prev.StateEvent != status.StateUnknownError { + log.Debug().Msg("Not reconnecting as the previous state was not an unknown error") + return + } else if bsq.unknownErrorReconnects > bsq.bridge.Config.UnknownErrorMaxAutoReconnects { + log.Warn().Msg("Not reconnecting as the maximum number of unknown error reconnects has been reached") + return + } + bsq.unknownErrorReconnects++ + log.Info(). + Int("reconnect_num", bsq.unknownErrorReconnects). + Msg("Disconnecting and reconnecting login due to unknown error") + bsq.login.Disconnect() + log.Debug().Msg("Disconnection finished, recreating client and reconnecting") + err := bsq.login.recreateClient(ctx) + if err != nil { + log.Err(err).Msg("Failed to recreate client after unknown error") + return + } + bsq.login.Client.Connect(ctx) + log.Debug().Msg("Reconnection finished") +} + +func (bsq *BridgeStateQueue) waitForUnknownErrorReconnect(ctx context.Context) bool { + reconnectIn := bsq.bridge.Config.UnknownErrorAutoReconnect + // Don't allow too low values + if reconnectIn < 1*time.Minute { + return false + } + reconnectIn += time.Duration(rand.Int64N(int64(float64(reconnectIn)*0.4)) - int64(float64(reconnectIn)*0.2)) + return bsq.waitForReconnect(ctx, reconnectIn, &bsq.stopReconnect) +} + +const TransientDisconnectNoticeDelay = 3 * time.Minute + +func (bsq *BridgeStateQueue) waitForTransientDisconnectReconnect(ctx context.Context) bool { + timeUntilSchedule := time.Until(bsq.firstTransientDisconnect.Add(TransientDisconnectNoticeDelay)) + zerolog.Ctx(ctx).Trace(). + Stringer("duration", timeUntilSchedule). + Msg("Waiting before sending notice about transient disconnect") + return bsq.waitForReconnect(ctx, timeUntilSchedule, &bsq.cancelScheduledNotice) +} + +func (bsq *BridgeStateQueue) waitForReconnect( + ctx context.Context, reconnectIn time.Duration, ptr *atomic.Pointer[context.CancelFunc], +) bool { + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + if oldCancel := ptr.Swap(&cancel); oldCancel != nil { + (*oldCancel)() + } + select { + case <-time.After(reconnectIn): + return ptr.CompareAndSwap(&cancel, nil) + case <-cancelCtx.Done(): + return false + case <-bsq.stopChan: + return false + } +} + +func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) { + if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) { + bsq.login.Log.Debug(). + Str("state_event", string(state.StateEvent)). + Msg("Not sending bridge state as it's a duplicate") + return + } + if state.StateEvent == status.StateUnknownError { + go bsq.unknownErrorReconnect(state) + } + + ctx := bsq.login.Log.WithContext(context.Background()) + bsq.sendNotice(ctx, state, false) + + retryIn := 2 + for { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + err := bsq.bridge.Matrix.SendBridgeStatus(ctx, &state) + cancel() + + if err != nil { + bsq.login.Log.Warn().Err(err). + Int("retry_in_seconds", retryIn). + Msg("Failed to update bridge state") + time.Sleep(time.Duration(retryIn) * time.Second) + retryIn *= 2 + if retryIn > 64 { + retryIn = 64 + } + } else { + bsq.prevSent = &state + bsq.login.Log.Debug(). + Any("bridge_state", state). + Msg("Sent new bridge state") + return + } + } +} + +func (bsq *BridgeStateQueue) Send(state status.BridgeState) { + if bsq == nil { + return + } + + state = state.Fill(bsq.login) + bsq.prevUnsent = &state + + if len(bsq.ch) >= 8 { + bsq.login.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item") + select { + case <-bsq.ch: + default: + } + } + select { + case bsq.ch <- state: + default: + bsq.login.Log.Error().Msg("Bridge state queue is full, dropped new state") + } +} + +func (bsq *BridgeStateQueue) GetPrev() status.BridgeState { + if bsq != nil && bsq.prevSent != nil { + return *bsq.prevSent + } + return status.BridgeState{} +} + +func (bsq *BridgeStateQueue) GetPrevUnsent() status.BridgeState { + if bsq != nil && bsq.prevSent != nil { + return *bsq.prevUnsent + } + return status.BridgeState{} +} + +func (bsq *BridgeStateQueue) SetPrev(prev status.BridgeState) { + if bsq != nil { + bsq.prevSent = &prev + } +} diff --git a/mautrix-patched/bridgev2/commands/cleanup.go b/mautrix-patched/bridgev2/commands/cleanup.go new file mode 100644 index 00000000..dc21a16e --- /dev/null +++ b/mautrix-patched/bridgev2/commands/cleanup.go @@ -0,0 +1,97 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "maunium.net/go/mautrix/bridgev2" +) + +var CommandDeletePortal = &FullHandler{ + Func: func(ce *Event) { + // TODO clean up child portals? + err := ce.Portal.Delete(ce.Ctx) + if err != nil { + ce.Reply("Failed to delete portal: %v", err) + return + } + err = ce.Bot.DeleteRoom(ce.Ctx, ce.Portal.MXID, false) + if err != nil { + ce.Reply("Failed to clean up room: %v", err) + } + ce.MessageStatus.DisableMSS = true + }, + Name: "delete-portal", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Delete the current portal room", + }, + RequiresAdmin: true, + RequiresPortal: true, +} + +var CommandDeleteAllPortals = &FullHandler{ + Func: func(ce *Event) { + portals, err := ce.Bridge.GetAllPortals(ce.Ctx) + if err != nil { + ce.Reply("Failed to get portals: %v", err) + return + } + bridgev2.DeleteManyPortals(ce.Ctx, portals, func(portal *bridgev2.Portal, delete bool, err error) { + if !delete { + ce.Reply("Failed to delete portal %s: %v", portal.MXID, err) + } else { + ce.Reply("Failed to clean up room %s: %v", portal.MXID, err) + } + }) + }, + Name: "delete-all-portals", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Delete all portals the bridge knows about", + }, + RequiresAdmin: true, +} + +var CommandSetManagementRoom = &FullHandler{ + Func: func(ce *Event) { + if ce.User.ManagementRoom == ce.RoomID { + ce.Reply("This room is already your management room") + return + } else if ce.Portal != nil { + ce.Reply("This is a portal room: you can't set this as your management room") + return + } + members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to get room members to check if room can be a management room") + ce.Reply("Failed to get room members") + return + } + _, hasBot := members[ce.Bot.GetMXID()] + if !hasBot { + // This reply will probably fail, but whatever + ce.Reply("The bridge bot must be in the room to set it as your management room") + return + } else if len(members) != 2 { + ce.Reply("Your management room must not have any members other than you and the bridge bot") + return + } + ce.User.ManagementRoom = ce.RoomID + err = ce.User.Save(ce.Ctx) + if err != nil { + ce.Log.Err(err).Msg("Failed to save management room") + ce.Reply("Failed to save management room") + } else { + ce.Reply("Management room updated") + } + }, + Name: "set-management-room", + Help: HelpMeta{ + Section: HelpSectionGeneral, + Description: "Mark this room as your management room", + }, +} diff --git a/mautrix-patched/bridgev2/commands/debug.go b/mautrix-patched/bridgev2/commands/debug.go new file mode 100644 index 00000000..1cae98fe --- /dev/null +++ b/mautrix-patched/bridgev2/commands/debug.go @@ -0,0 +1,125 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "encoding/json" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +var CommandRegisterPush = &FullHandler{ + Func: func(ce *Event) { + if len(ce.Args) < 3 { + ce.Reply("Usage: `$cmdprefix debug-register-push `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) + return + } + pushType := bridgev2.PushTypeFromString(ce.Args[1]) + if pushType == bridgev2.PushTypeUnknown { + ce.Reply("Unknown push type `%s`. Allowed types: `web`, `apns`, `fcm`", ce.Args[1]) + return + } + login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + if login == nil || login.UserMXID != ce.User.MXID { + ce.Reply("Login `%s` not found", ce.Args[0]) + return + } + pushable, ok := login.Client.(bridgev2.PushableNetworkAPI) + if !ok { + ce.Reply("This network connector does not support push registration") + return + } + pushToken := strings.Join(ce.Args[2:], " ") + if pushToken == "null" { + pushToken = "" + } + err := pushable.RegisterPushNotifications(ce.Ctx, pushType, pushToken) + if err != nil { + ce.Reply("Failed to register pusher: %v", err) + return + } + if pushToken == "" { + ce.Reply("Pusher de-registered successfully") + } else { + ce.Reply("Pusher registered successfully") + } + }, + Name: "debug-register-push", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Register a pusher", + Args: "<_login ID_> <_push type_> <_push token_>", + }, + RequiresAdmin: true, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.PushableNetworkAPI], +} + +var CommandSendAccountData = &FullHandler{ + Func: func(ce *Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `$cmdprefix debug-account-data ") + return + } + var content event.Content + evtType := event.Type{Type: ce.Args[0], Class: event.AccountDataEventType} + ce.RawArgs = strings.TrimSpace(strings.Trim(ce.RawArgs, ce.Args[0])) + err := json.Unmarshal([]byte(ce.RawArgs), &content) + if err != nil { + ce.Reply("Failed to parse JSON: %v", err) + return + } + err = content.ParseRaw(evtType) + if err != nil { + ce.Reply("Failed to deserialize content: %v", err) + return + } + res := ce.Bridge.QueueMatrixEvent(ce.Ctx, &event.Event{ + Sender: ce.User.MXID, + Type: evtType, + Timestamp: time.Now().UnixMilli(), + RoomID: ce.RoomID, + Content: content, + }) + ce.Reply("Result: %+v", res) + }, + Name: "debug-account-data", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Send a room account data event to the bridge", + Args: "<_type_> <_content_>", + }, + RequiresAdmin: true, + RequiresPortal: true, + RequiresLogin: true, +} + +var CommandResetNetwork = &FullHandler{ + Func: func(ce *Event) { + if strings.Contains(strings.ToLower(ce.RawArgs), "--reset-transport") { + nrn, ok := ce.Bridge.Network.(bridgev2.NetworkResettingNetwork) + if ok { + nrn.ResetHTTPTransport() + } else { + ce.Reply("Network connector does not support resetting HTTP transport") + } + } + ce.Bridge.ResetNetworkConnections() + ce.React("✅️") + }, + Name: "debug-reset-network", + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Reset network connections to the remote network", + Args: "[--reset-transport]", + }, + RequiresAdmin: true, +} diff --git a/mautrix-patched/bridgev2/commands/event.go b/mautrix-patched/bridgev2/commands/event.go new file mode 100644 index 00000000..13f8dae5 --- /dev/null +++ b/mautrix-patched/bridgev2/commands/event.go @@ -0,0 +1,105 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +// Event stores all data which might be used to handle commands +type Event struct { + Bot bridgev2.MatrixAPI + Bridge *bridgev2.Bridge + Portal *bridgev2.Portal + Processor *Processor + Handler MinimalCommandHandler + RoomID id.RoomID + OrigRoomID id.RoomID + EventID id.EventID + User *bridgev2.User + Command string + Args []string + RawArgs string + ReplyTo id.EventID + Ctx context.Context + Log *zerolog.Logger + Sudo bool + + MessageStatus *bridgev2.MessageStatus +} + +// Reply sends a reply to command as notice, with optional string formatting and automatic $cmdprefix replacement. +func (ce *Event) Reply(msg string, args ...any) id.EventID { + msg = strings.ReplaceAll(msg, "$cmdprefix", ce.Bridge.Config.CommandPrefix) + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + return ce.ReplyAdvanced(msg, true, false) +} + +// ReplyAdvanced sends a reply to command as notice. It allows using HTML and disabling markdown, +// but doesn't have built-in string formatting. +func (ce *Event) ReplyAdvanced(msg string, allowMarkdown, allowHTML bool) id.EventID { + content := format.RenderMarkdown(msg, allowMarkdown, allowHTML) + content.MsgType = event.MsgNotice + resp, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil) + if err != nil { + ce.Log.Err(err).Msg("Failed to reply to command") + return "" + } + return resp.EventID +} + +// React sends a reaction to the command. +func (ce *Event) React(key string) id.EventID { + resp, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventReaction, &event.Content{ + Parsed: &event.ReactionEventContent{ + RelatesTo: event.RelatesTo{ + Type: event.RelAnnotation, + EventID: ce.EventID, + Key: key, + }, + }, + }, nil) + if err != nil { + ce.Log.Err(err).Msg("Failed to react to command") + return "" + } + return resp.EventID +} + +// Redact redacts the command. +func (ce *Event) Redact(req ...mautrix.ReqRedact) { + _, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: ce.EventID, + }, + }, nil) + if err != nil { + ce.Log.Err(err).Msg("Failed to redact command") + } +} + +// MarkRead marks the command event as read. +func (ce *Event) MarkRead() { + err := ce.Bot.MarkRead(ce.Ctx, ce.RoomID, ce.EventID, time.Now()) + if err != nil { + ce.Log.Err(err).Msg("Failed to mark command as read") + } +} diff --git a/mautrix-patched/bridgev2/commands/handler.go b/mautrix-patched/bridgev2/commands/handler.go new file mode 100644 index 00000000..672c81dc --- /dev/null +++ b/mautrix-patched/bridgev2/commands/handler.go @@ -0,0 +1,118 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +type MinimalCommandHandler interface { + Run(*Event) +} + +type MinimalCommandHandlerFunc func(*Event) + +func (mhf MinimalCommandHandlerFunc) Run(ce *Event) { + mhf(ce) +} + +type CommandState struct { + Next MinimalCommandHandler + Action string + Meta any + Cancel func() +} + +type CommandHandler interface { + MinimalCommandHandler + GetName() string +} + +type AliasedCommandHandler interface { + CommandHandler + GetAliases() []string +} + +func NetworkAPIImplements[T bridgev2.NetworkAPI](val bridgev2.NetworkAPI) bool { + _, ok := val.(T) + return ok +} + +func NetworkConnectorImplements[T bridgev2.NetworkConnector](val bridgev2.NetworkConnector) bool { + _, ok := val.(T) + return ok +} + +type ImplementationChecker[T any] func(val T) bool + +type FullHandler struct { + Func func(*Event) + + Name string + Aliases []string + Help HelpMeta + + RequiresAdmin bool + RequiresPortal bool + RequiresLogin bool + RequiresEventLevel event.Type + RequiresLoginPermission bool + + NetworkAPI ImplementationChecker[bridgev2.NetworkAPI] + NetworkConnector ImplementationChecker[bridgev2.NetworkConnector] +} + +func (fh *FullHandler) GetHelp() HelpMeta { + fh.Help.Command = fh.Name + return fh.Help +} + +func (fh *FullHandler) GetName() string { + return fh.Name +} + +func (fh *FullHandler) GetAliases() []string { + return fh.Aliases +} + +func (fh *FullHandler) ImplementationsFulfilled(ce *Event) bool { + // TODO add dedicated method to get an empty NetworkAPI instead of getting default login + client := ce.User.GetDefaultLogin() + return (fh.NetworkAPI == nil || client == nil || fh.NetworkAPI(client.Client)) && + (fh.NetworkConnector == nil || fh.NetworkConnector(ce.Bridge.Network)) +} + +func (fh *FullHandler) ShowInHelp(ce *Event) bool { + return fh.ImplementationsFulfilled(ce) && (!fh.RequiresAdmin || ce.User.Permissions.Admin) +} + +func (fh *FullHandler) userHasRoomPermission(ce *Event) bool { + levels, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, ce.RoomID) + if err != nil { + ce.Log.Warn().Err(err).Msg("Failed to check room power levels") + ce.Reply("Failed to get room power levels to see if you're allowed to use that command") + return false + } + return levels.GetUserLevel(ce.User.MXID) >= levels.GetEventLevel(fh.RequiresEventLevel) +} + +func (fh *FullHandler) Run(ce *Event) { + if fh.RequiresAdmin && !ce.User.Permissions.Admin { + ce.Reply("That command is limited to bridge administrators.") + } else if fh.RequiresLoginPermission && !ce.User.Permissions.Login { + ce.Reply("You do not have permissions to log into this bridge.") + } else if fh.RequiresEventLevel.Type != "" && !ce.User.Permissions.Admin && !fh.userHasRoomPermission(ce) { + ce.Reply("That command requires room admin rights.") + } else if fh.RequiresPortal && ce.Portal == nil { + ce.Reply("That command can only be ran in portal rooms.") + } else if fh.RequiresLogin && ce.User.GetDefaultLogin() == nil { + ce.Reply("That command requires you to be logged in.") + } else { + fh.Func(ce) + } +} diff --git a/mautrix-patched/bridgev2/commands/help.go b/mautrix-patched/bridgev2/commands/help.go new file mode 100644 index 00000000..07cb6921 --- /dev/null +++ b/mautrix-patched/bridgev2/commands/help.go @@ -0,0 +1,142 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "fmt" + "sort" + "strings" +) + +type HelpfulHandler interface { + CommandHandler + GetHelp() HelpMeta + ShowInHelp(*Event) bool +} + +type HelpSection struct { + Name string + Order int +} + +var ( + // Deprecated: this should be used as a placeholder that needs to be fixed + HelpSectionUnclassified = HelpSection{"Unclassified", -1} + + HelpSectionGeneral = HelpSection{"General", 0} + HelpSectionAuth = HelpSection{"Authentication", 10} + HelpSectionChats = HelpSection{"Starting and managing chats", 20} + HelpSectionMisc = HelpSection{"Miscellaneous", 30} + HelpSectionAdmin = HelpSection{"Administration", 50} +) + +type HelpMeta struct { + Command string + Section HelpSection + Description string + Args string +} + +func (hm *HelpMeta) String() string { + if len(hm.Args) == 0 { + return fmt.Sprintf("**%s** - %s", hm.Command, hm.Description) + } + return fmt.Sprintf("**%s** %s - %s", hm.Command, hm.Args, hm.Description) +} + +type helpSectionList []HelpSection + +func (h helpSectionList) Len() int { + return len(h) +} + +func (h helpSectionList) Less(i, j int) bool { + return h[i].Order < h[j].Order +} + +func (h helpSectionList) Swap(i, j int) { + h[i], h[j] = h[j], h[i] +} + +type helpMetaList []HelpMeta + +func (h helpMetaList) Len() int { + return len(h) +} + +func (h helpMetaList) Less(i, j int) bool { + return h[i].Command < h[j].Command +} + +func (h helpMetaList) Swap(i, j int) { + h[i], h[j] = h[j], h[i] +} + +var _ sort.Interface = (helpSectionList)(nil) +var _ sort.Interface = (helpMetaList)(nil) + +func FormatHelp(ce *Event) string { + sections := make(map[HelpSection]helpMetaList) + for _, handler := range ce.Processor.handlers { + helpfulHandler, ok := handler.(HelpfulHandler) + if !ok || !helpfulHandler.ShowInHelp(ce) { + continue + } + help := helpfulHandler.GetHelp() + if help.Description == "" { + continue + } + sections[help.Section] = append(sections[help.Section], help) + } + + sortedSections := make(helpSectionList, 0, len(sections)) + for section := range sections { + sortedSections = append(sortedSections, section) + } + sort.Sort(sortedSections) + + var output strings.Builder + output.Grow(10240) + + var prefixMsg string + if ce.RoomID == ce.User.ManagementRoom { + prefixMsg = "This is your management room: prefixing commands with `%s` is not required." + } else if ce.Portal != nil { + prefixMsg = "**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged." + } else { + prefixMsg = "This is not your management room: prefixing commands with `%s` is required." + } + _, _ = fmt.Fprintf(&output, prefixMsg, ce.Bridge.Config.CommandPrefix) + output.WriteByte('\n') + output.WriteString("Parameters in [square brackets] are optional, while parameters in are required.") + output.WriteByte('\n') + output.WriteByte('\n') + + for _, section := range sortedSections { + output.WriteString("#### ") + output.WriteString(section.Name) + output.WriteByte('\n') + sort.Sort(sections[section]) + for _, command := range sections[section] { + output.WriteString(command.String()) + output.WriteByte('\n') + } + output.WriteByte('\n') + } + return output.String() +} + +var CommandHelp = &FullHandler{ + Func: func(ce *Event) { + ce.Reply(FormatHelp(ce)) + }, + Name: "help", + Help: HelpMeta{ + Section: HelpSectionGeneral, + Description: "Show this help message.", + }, +} diff --git a/mautrix-patched/bridgev2/commands/imagepack.go b/mautrix-patched/bridgev2/commands/imagepack.go new file mode 100644 index 00000000..35dfbe5b --- /dev/null +++ b/mautrix-patched/bridgev2/commands/imagepack.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "fmt" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/format" +) + +var CommandImportImagePack = &FullHandler{ + Func: fnImportImagePack, + Name: "import-image-pack", + Help: HelpMeta{ + Section: HelpSectionMisc, + Description: "Import a sticker or emoji pack from the remote network", + Args: "", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.StickerImportingNetworkAPI], +} + +func fnImportImagePack(ce *Event) { + login, _, args := getClientForStartingChat[bridgev2.StickerImportingNetworkAPI](ce, "importing pack") + if len(args) == 0 { + ce.Reply("Usage: `$cmdprefix import-image-pack `") + return + } + resp, err := provisionutil.ImportImagePack(ce.Ctx, login, args[0]) + if err != nil { + ce.Reply("Failed to import pack: %s", err) + return + } + var footer string + parts := len(resp.StateKeys) + if parts > 1 { + footer = fmt.Sprintf(". Note: the pack was large, so it had to be split up into %d parts", parts) + } + ce.Reply( + "Successfully bridged image pack to %s%s", + format.MarkdownLink("your personal filtering space", + resp.RoomID.URI(ce.Bridge.Matrix.ServerName()).MatrixToURL()), + footer, + ) +} diff --git a/mautrix-patched/bridgev2/commands/login.go b/mautrix-patched/bridgev2/commands/login.go new file mode 100644 index 00000000..ebd681ff --- /dev/null +++ b/mautrix-patched/bridgev2/commands/login.go @@ -0,0 +1,667 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "encoding/json" + "fmt" + "html" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/skip2/go-qrcode" + "go.mau.fi/util/curl" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var CommandLogin = &FullHandler{ + Func: fnLogin, + Name: "login", + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "Log into the bridge", + Args: "[_flow ID_]", + }, + RequiresLoginPermission: true, +} + +var CommandRelogin = &FullHandler{ + Func: fnLogin, + Name: "relogin", + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "Re-authenticate an existing login", + Args: "<_login ID_> [_flow ID_]", + }, + RequiresLoginPermission: true, +} + +func formatFlowsReply(flows []bridgev2.LoginFlow) string { + var buf strings.Builder + for _, flow := range flows { + _, _ = fmt.Fprintf(&buf, "* `%s` - %s\n", flow.ID, flow.Description) + } + return buf.String() +} + +func fnLogin(ce *Event) { + var reauth *bridgev2.UserLogin + if ce.Command == "relogin" { + if len(ce.Args) == 0 { + ce.Reply("Usage: `$cmdprefix relogin [_flow ID_]`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) + return + } + reauth = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + if reauth == nil { + ce.Reply("Login `%s` not found", ce.Args[0]) + return + } + ce.Args = ce.Args[1:] + } + if reauth == nil && ce.User.HasTooManyLogins() { + ce.Reply( + "You have reached the maximum number of logins (%d). "+ + "Please logout from an existing login before creating a new one. "+ + "If you want to re-authenticate an existing login, use the `$cmdprefix relogin` command.", + ce.User.Permissions.MaxLogins, + ) + return + } + if existingState := LoadCommandState(ce.User); existingState != nil { + ce.Reply("You already have an ongoing %s. You can use `$cmdprefix cancel` to cancel it.", strings.ToLower(existingState.Action)) + return + } + flows := ce.Bridge.Network.GetLoginFlows() + var chosenFlowID string + if len(ce.Args) > 0 { + inputFlowID := strings.ToLower(ce.Args[0]) + ce.Args = ce.Args[1:] + for _, flow := range flows { + if flow.ID == inputFlowID { + chosenFlowID = flow.ID + break + } + } + if chosenFlowID == "" { + ce.Reply("Invalid login flow `%s`. Available options:\n\n%s", inputFlowID, formatFlowsReply(flows)) + return + } + } else if len(flows) == 1 { + chosenFlowID = flows[0].ID + } else { + if reauth != nil { + ce.Reply("Please specify a login flow, e.g. `relogin %s %s`.\n\n%s", reauth.ID, flows[0].ID, formatFlowsReply(flows)) + } else { + ce.Reply("Please specify a login flow, e.g. `login %s`.\n\n%s", flows[0].ID, formatFlowsReply(flows)) + } + return + } + if !ce.Sudo && ce.RoomID != ce.User.ManagementRoom { + ce.Reply("\u26a0\ufe0f This is not your management room. Entering login info must be prefixed with `$cmdprefix` like other commands.") + } + + login, err := ce.Bridge.Network.CreateLogin(ce.Ctx, ce.User, chosenFlowID) + if err != nil { + ce.Reply("Failed to prepare login process: %v", err) + return + } + overridable, ok := login.(bridgev2.LoginProcessWithOverride) + var nextStep *bridgev2.LoginStep + if ok && reauth != nil { + nextStep, err = overridable.StartWithOverride(ce.Ctx, reauth) + } else { + nextStep, err = login.Start(ce.Ctx) + } + if err != nil { + ce.Reply("Failed to start login: %v", err) + return + } + ce.Log.Debug().Any("first_step", nextStep).Msg("Created login process") + + nextStep = checkLoginCommandDirectParams(ce, login, nextStep) + if nextStep != nil { + doLoginStep(ce, login, nextStep, reauth) + } +} + +func checkLoginCommandDirectParams(ce *Event, login bridgev2.LoginProcess, nextStep *bridgev2.LoginStep) *bridgev2.LoginStep { + if len(ce.Args) == 0 { + return nextStep + } + var ok bool + defer func() { + if !ok { + login.Cancel() + } + }() + var err error + switch nextStep.Type { + case bridgev2.LoginStepTypeDisplayAndWait: + ce.Reply("Invalid extra parameters for display and wait login step") + return nil + case bridgev2.LoginStepTypeWebAuthn: + ce.Reply("Invalid extra parameters for webauthn login step") + return nil + case bridgev2.LoginStepTypeUserInput: + if len(ce.Args) != len(nextStep.UserInputParams.Fields) { + ce.Reply("Invalid number of extra parameters (expected 0 or %d, got %d)", len(nextStep.UserInputParams.Fields), len(ce.Args)) + return nil + } + input := make(map[string]string) + var shouldRedact bool + for i, param := range nextStep.UserInputParams.Fields { + param.FillDefaultValidate() + input[param.ID], err = param.Validate(ce.Args[i]) + if err != nil { + ce.Reply("Invalid value for %s: %v", param.Name, err) + return nil + } + if param.Type == bridgev2.LoginInputFieldTypePassword || param.Type == bridgev2.LoginInputFieldTypeToken { + shouldRedact = true + } + } + if shouldRedact { + ce.Redact() + } + nextStep, err = login.(bridgev2.LoginProcessUserInput).SubmitUserInput(ce.Ctx, input) + case bridgev2.LoginStepTypeCookies: + if len(ce.Args) != len(nextStep.CookiesParams.Fields) { + ce.Reply("Invalid number of extra parameters (expected 0 or %d, got %d)", len(nextStep.CookiesParams.Fields), len(ce.Args)) + return nil + } + input := make(map[string]string) + for i, param := range nextStep.CookiesParams.Fields { + val := maybeURLDecodeCookie(ce.Args[i], ¶m) + if match, _ := regexp.MatchString(param.Pattern, val); !match { + ce.Reply("Invalid value for %s: `%s` doesn't match regex `%s`", param.ID, val, param.Pattern) + return nil + } + input[param.ID] = val + } + ce.Redact() + nextStep, err = login.(bridgev2.LoginProcessCookies).SubmitCookies(ce.Ctx, input) + } + if err != nil { + ce.Reply("Failed to submit input: %v", err) + return nil + } + ok = true + return nextStep +} + +type userInputLoginCommandState struct { + Login bridgev2.LoginProcessUserInput + Data map[string]string + RemainingFields []bridgev2.LoginInputDataField + Override *bridgev2.UserLogin +} + +func (uilcs *userInputLoginCommandState) promptNext(ce *Event) { + field := uilcs.RemainingFields[0] + parts := []string{fmt.Sprintf("Please enter your %s", field.Name)} + if field.Description != "" { + parts = append(parts, field.Description) + } + if len(field.Options) > 0 { + parts = append(parts, fmt.Sprintf("Options: `%s`", strings.Join(field.Options, "`, `"))) + } + ce.Reply(strings.Join(parts, "\n")) + StoreCommandState(ce.User, &CommandState{ + Next: MinimalCommandHandlerFunc(uilcs.submitNext), + Action: "Login", + Meta: uilcs, + Cancel: uilcs.Login.Cancel, + }) +} + +func (uilcs *userInputLoginCommandState) submitNext(ce *Event) { + field := uilcs.RemainingFields[0] + field.FillDefaultValidate() + if field.Type == bridgev2.LoginInputFieldTypePassword || field.Type == bridgev2.LoginInputFieldTypeToken { + ce.Redact() + } + var err error + uilcs.Data[field.ID], err = field.Validate(ce.RawArgs) + if err != nil { + ce.Reply("Invalid value: %v", err) + return + } else if len(uilcs.RemainingFields) > 1 { + uilcs.RemainingFields = uilcs.RemainingFields[1:] + uilcs.promptNext(ce) + return + } + StoreCommandState(ce.User, nil) + if nextStep, err := uilcs.Login.SubmitUserInput(ce.Ctx, uilcs.Data); err != nil { + ce.Reply("Failed to submit input: %v", err) + } else { + doLoginStep(ce, uilcs.Login, nextStep, uilcs.Override) + } +} + +const qrSizePx = 512 + +func sendQR(ce *Event, qr string, prevEventID *id.EventID) error { + qrData, err := qrcode.Encode(qr, qrcode.Low, qrSizePx) + if err != nil { + return fmt.Errorf("failed to encode QR code: %w", err) + } + qrMXC, qrFile, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, qrData, "qr.png", "image/png") + if err != nil { + return fmt.Errorf("failed to upload image: %w", err) + } + content := &event.MessageEventContent{ + MsgType: event.MsgImage, + FileName: "qr.png", + URL: qrMXC, + File: qrFile, + Body: qr, + Format: event.FormatHTML, + FormattedBody: fmt.Sprintf("
%s
", html.EscapeString(qr)), + Info: &event.FileInfo{ + MimeType: "image/png", + Width: qrSizePx, + Height: qrSizePx, + Size: len(qrData), + }, + } + if *prevEventID != "" { + content.SetEdit(*prevEventID) + } + newEventID, err := ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) + if err != nil { + return err + } + if *prevEventID == "" { + *prevEventID = newEventID.EventID + } + return nil +} + +func sendUserInputAttachments(ce *Event, atts []*bridgev2.LoginUserInputAttachment) error { + for _, att := range atts { + if att.FileName == "" { + return fmt.Errorf("missing attachment filename") + } + mxc, file, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, att.Content, att.FileName, att.Info.MimeType) + if err != nil { + return fmt.Errorf("failed to upload attachment %q: %w", att.FileName, err) + } + content := &event.MessageEventContent{ + MsgType: att.Type, + FileName: att.FileName, + URL: mxc, + File: file, + Info: &event.FileInfo{ + MimeType: att.Info.MimeType, + Width: att.Info.Width, + Height: att.Info.Height, + Size: att.Info.Size, + }, + Body: att.FileName, + } + _, err = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) + if err != nil { + return nil + } + } + return nil +} + +type contextKey int + +const ( + contextKeyPrevEventID contextKey = iota +) + +func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait, step *bridgev2.LoginStep, override *bridgev2.UserLogin) { + prevEvent, ok := ce.Ctx.Value(contextKeyPrevEventID).(*id.EventID) + if !ok { + prevEvent = new(id.EventID) + ce.Ctx = context.WithValue(ce.Ctx, contextKeyPrevEventID, prevEvent) + } + cancelCtx, cancelFunc := context.WithCancel(ce.Ctx) + defer cancelFunc() + StoreCommandState(ce.User, &CommandState{ + Action: "Login", + Cancel: cancelFunc, + }) + switch step.DisplayAndWaitParams.Type { + case bridgev2.LoginDisplayTypeQR: + err := sendQR(ce, step.DisplayAndWaitParams.Data, prevEvent) + if err != nil { + ce.Reply("Failed to send QR code: %v", err) + login.Cancel() + StoreCommandState(ce.User, nil) + return + } + case bridgev2.LoginDisplayTypeEmoji: + ce.ReplyAdvanced(step.DisplayAndWaitParams.Data, false, false) + case bridgev2.LoginDisplayTypeCode: + ce.ReplyAdvanced(fmt.Sprintf("%s", html.EscapeString(step.DisplayAndWaitParams.Data)), false, true) + case bridgev2.LoginDisplayTypeNothing: + // Do nothing + default: + ce.Reply("Unsupported display type %q", step.DisplayAndWaitParams.Type) + login.Cancel() + StoreCommandState(ce.User, nil) + return + } + nextStep, err := login.Wait(cancelCtx) + // Redact the QR code, unless the next step is refreshing the code (in which case the event is just edited) + if *prevEvent != "" && (nextStep == nil || nextStep.StepID != step.StepID) { + _, _ = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: *prevEvent, + }, + }, nil) + *prevEvent = "" + } + if err != nil { + ce.Reply("Login failed: %v", err) + StoreCommandState(ce.User, nil) + return + } + StoreCommandState(ce.User, nil) + doLoginStep(ce, login, nextStep, override) +} + +type cookieLoginCommandState struct { + Login bridgev2.LoginProcessCookies + Data *bridgev2.LoginCookiesParams + Override *bridgev2.UserLogin +} + +func (clcs *cookieLoginCommandState) prompt(ce *Event) { + ce.Reply("Login URL: <%s>", clcs.Data.URL) + StoreCommandState(ce.User, &CommandState{ + Next: MinimalCommandHandlerFunc(clcs.submit), + Action: "Login", + Meta: clcs, + Cancel: clcs.Login.Cancel, + }) +} + +func (clcs *cookieLoginCommandState) submit(ce *Event) { + ce.Redact() + + cookiesInput := make(map[string]string) + if strings.HasPrefix(strings.TrimSpace(ce.RawArgs), "curl") { + parsed, err := curl.Parse(ce.RawArgs) + if err != nil { + ce.Reply("Failed to parse curl: %v", err) + return + } + reqCookies := make(map[string]string) + for _, cookie := range parsed.Cookies() { + reqCookies[cookie.Name], err = url.PathUnescape(cookie.Value) + if err != nil { + ce.Reply("Failed to parse cookie %s: %v", cookie.Name, err) + return + } + } + var missingKeys, unsupportedKeys []string + for _, field := range clcs.Data.Fields { + var value string + var supported bool + for _, src := range field.Sources { + switch src.Type { + case bridgev2.LoginCookieTypeCookie: + supported = true + value = reqCookies[src.Name] + case bridgev2.LoginCookieTypeRequestHeader: + supported = true + value = parsed.Header.Get(src.Name) + case bridgev2.LoginCookieTypeRequestBody: + supported = true + switch { + case parsed.MultipartForm != nil: + values, ok := parsed.MultipartForm.Value[src.Name] + if ok && len(values) > 0 { + value = values[0] + } + case parsed.ParsedJSON != nil: + untypedValue, ok := parsed.ParsedJSON[src.Name] + if ok { + value = fmt.Sprintf("%v", untypedValue) + } + } + } + if value != "" { + cookiesInput[field.ID] = value + break + } + } + if value == "" && field.Required { + if supported { + missingKeys = append(missingKeys, field.ID) + } else { + unsupportedKeys = append(unsupportedKeys, field.ID) + } + } + } + if len(unsupportedKeys) > 0 { + ce.Reply("Some keys can't be extracted from a cURL request: %+v\n\nPlease provide a JSON object instead.", unsupportedKeys) + return + } else if len(missingKeys) > 0 { + ce.Reply("Missing some keys: %+v", missingKeys) + return + } + } else { + err := json.Unmarshal([]byte(ce.RawArgs), &cookiesInput) + if err != nil { + ce.Reply("Failed to parse input as JSON: %v", err) + return + } + for _, field := range clcs.Data.Fields { + val, ok := cookiesInput[field.ID] + if ok { + cookiesInput[field.ID] = maybeURLDecodeCookie(val, &field) + } + } + } + var missingKeys []string + for _, field := range clcs.Data.Fields { + val, ok := cookiesInput[field.ID] + if !ok && field.Required { + missingKeys = append(missingKeys, field.ID) + } + if match, _ := regexp.MatchString(field.Pattern, val); !match { + ce.Reply("Invalid value for %s: `%s` doesn't match regex `%s`", field.ID, val, field.Pattern) + return + } + } + if len(missingKeys) > 0 { + ce.Reply("Missing some keys: %+v", missingKeys) + return + } + StoreCommandState(ce.User, nil) + nextStep, err := clcs.Login.SubmitCookies(ce.Ctx, cookiesInput) + if err != nil { + ce.Reply("Login failed: %v", err) + return + } + doLoginStep(ce, clcs.Login, nextStep, clcs.Override) +} + +func maybeURLDecodeCookie(val string, field *bridgev2.LoginCookieField) string { + if val == "" { + return val + } + isCookie := slices.ContainsFunc(field.Sources, func(src bridgev2.LoginCookieFieldSource) bool { + return src.Type == bridgev2.LoginCookieTypeCookie + }) + if !isCookie { + return val + } + decoded, err := url.PathUnescape(val) + if err != nil { + return val + } + return decoded +} + +type webauthnLoginCommandState struct { + Login bridgev2.LoginProcessWebAuthn + Override *bridgev2.UserLogin +} + +const webauthnSnippet = "Run the following JS on <%s>:\n\n```js\nconsole.log((await navigator.credentials.get({\n publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(%s)\n})).toJSON())\n```\n\nThen paste the resulting JSON object here." + +func (wlcs *webauthnLoginCommandState) prompt(ce *Event, params *bridgev2.LoginWebAuthnParams) { + marshaledPubKey, _ := json.MarshalIndent(params.PublicKey, "", " ") + ce.Reply(webauthnSnippet, params.URL, marshaledPubKey) + StoreCommandState(ce.User, &CommandState{ + Next: MinimalCommandHandlerFunc(wlcs.submit), + Action: "Login", + Meta: wlcs, + Cancel: wlcs.Login.Cancel, + }) +} + +func (wlcs *webauthnLoginCommandState) submit(ce *Event) { + rawArgBytes := []byte(strings.TrimSpace(ce.RawArgs)) + if !json.Valid(rawArgBytes) { + ce.Reply("Input is not valid JSON") + return + } + StoreCommandState(ce.User, nil) + nextStep, err := wlcs.Login.SubmitWebAuthnResponse(ce.Ctx, rawArgBytes) + if err != nil { + ce.Reply("Login failed: %v", err) + return + } + doLoginStep(ce, wlcs.Login, nextStep, wlcs.Override) +} + +func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginStep, override *bridgev2.UserLogin) { + ce.Log.Debug().Any("next_step", step).Msg("Got next login step") + if step.Instructions != "" { + ce.Reply(step.Instructions) + } + + switch step.Type { + case bridgev2.LoginStepTypeDisplayAndWait: + doLoginDisplayAndWait(ce, login.(bridgev2.LoginProcessDisplayAndWait), step, override) + case bridgev2.LoginStepTypeCookies: + (&cookieLoginCommandState{ + Login: login.(bridgev2.LoginProcessCookies), + Data: step.CookiesParams, + Override: override, + }).prompt(ce) + case bridgev2.LoginStepTypeUserInput: + err := sendUserInputAttachments(ce, step.UserInputParams.Attachments) + if err != nil { + ce.Reply("Failed to send attachments: %v", err) + } + (&userInputLoginCommandState{ + Login: login.(bridgev2.LoginProcessUserInput), + RemainingFields: step.UserInputParams.Fields, + Data: make(map[string]string), + Override: override, + }).promptNext(ce) + case bridgev2.LoginStepTypeWebAuthn: + (&webauthnLoginCommandState{ + Login: login.(bridgev2.LoginProcessWebAuthn), + Override: override, + }).prompt(ce, step.WebAuthnParams) + case bridgev2.LoginStepTypeComplete: + if override != nil && override.ID != step.CompleteParams.UserLoginID { + ce.Log.Info(). + Str("old_login_id", string(override.ID)). + Str("new_login_id", string(step.CompleteParams.UserLoginID)). + Msg("Login resulted in different remote ID than what was being overridden. Deleting previous login") + override.Delete(ce.Ctx, status.BridgeState{ + StateEvent: status.StateLoggedOut, + Reason: "LOGIN_OVERRIDDEN", + }, bridgev2.DeleteOpts{LogoutRemote: true}) + } + default: + panic(fmt.Errorf("unknown login step type %q", step.Type)) + } +} + +var CommandListLogins = &FullHandler{ + Func: fnListLogins, + Name: "list-logins", + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "List your logins", + }, + RequiresLoginPermission: true, +} + +func fnListLogins(ce *Event) { + logins := ce.User.GetFormattedUserLogins() + if len(logins) == 0 { + ce.Reply("You're not logged in") + } else { + ce.Reply("%s", logins) + } +} + +var CommandLogout = &FullHandler{ + Func: fnLogout, + Name: "logout", + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "Log out of the bridge", + Args: "<_login ID_>", + }, +} + +func fnLogout(ce *Event) { + if len(ce.Args) == 0 { + ce.Reply("Usage: `$cmdprefix logout `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) + return + } + login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + if login == nil || login.UserMXID != ce.User.MXID { + ce.Reply("Login `%s` not found", ce.Args[0]) + return + } + login.Logout(ce.Ctx) + ce.Reply("Logged out") +} + +var CommandSetPreferredLogin = &FullHandler{ + Func: fnSetPreferredLogin, + Name: "set-preferred-login", + Aliases: []string{"prefer"}, + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "Set the preferred login ID for sending messages to this portal (only relevant when logged into multiple accounts via the bridge)", + Args: "<_login ID_>", + }, + RequiresPortal: true, + RequiresLoginPermission: true, +} + +func fnSetPreferredLogin(ce *Event) { + if len(ce.Args) == 0 { + ce.Reply("Usage: `$cmdprefix set-preferred-login `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) + return + } + login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + if login == nil || login.UserMXID != ce.User.MXID { + ce.Reply("Login `%s` not found", ce.Args[0]) + return + } + err := login.MarkAsPreferredIn(ce.Ctx, ce.Portal) + if err != nil { + ce.Reply("Failed to set preferred login: %v", err) + } else { + ce.Reply("Preferred login set") + } +} diff --git a/mautrix-patched/bridgev2/commands/managechat.go b/mautrix-patched/bridgev2/commands/managechat.go new file mode 100644 index 00000000..0f1c7e0a --- /dev/null +++ b/mautrix-patched/bridgev2/commands/managechat.go @@ -0,0 +1,358 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "fmt" + "slices" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +var CommandID = &FullHandler{ + Func: func(ce *Event) { + var receiver string + if ce.Portal.Receiver != "" { + receiver = fmt.Sprintf(" (receiver: %s)", format.SafeMarkdownCode(ce.Portal.Receiver)) + } + ce.Reply("This room is bridged to %s%s on %s", format.SafeMarkdownCode(ce.Portal.ID), receiver, ce.Bridge.Network.GetName().DisplayName) + }, + Name: "id", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "View the internal network ID of the current portal room", + }, + RequiresPortal: true, +} + +var CommandBridge = &FullHandler{ + Func: fnBridge, + Name: "bridge", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Bridge an existing chat on the remote network to this Matrix room", + Args: "[login ID] ", + }, + RequiresEventLevel: event.StateBridge, +} + +func alreadyBridged(ce *Event) bool { + if ce.Portal != nil { + var receiver string + if ce.Portal.Receiver != "" { + receiver = fmt.Sprintf(" (receiver: %s)", format.SafeMarkdownCode(ce.Portal.Receiver)) + } + ce.Reply("This room is already bridged to %s%s on %s", format.SafeMarkdownCode(ce.Portal.ID), receiver, ce.Bridge.Network.GetName().DisplayName) + return true + } + return false +} + +func fnBridge(ce *Event) { + if !canPlumb(ce) { + ce.Reply("You don't have permission to bridge this room") + return + } else if alreadyBridged(ce) { + return + } + var allowOverwrite, ignorePermissions bool + ce.Args = slices.DeleteFunc(ce.Args, func(s string) bool { + switch strings.ToLower(s) { + case "--overwrite": + allowOverwrite = true + return true + case "--ignore-permissions": + ignorePermissions = true + return true + default: + return false + } + }) + if len(ce.Args) == 0 || len(ce.Args) > 2 { + ce.Reply("Usage: `$cmdprefix bridge [login ID] `") + return + } + if !ignorePermissions { + pls, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, ce.RoomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to get power levels for plumbing") + ce.Reply("Failed to check power levels in room: %v", err) + return + } else if pls.GetUserLevel(ce.Bot.GetMXID()) < pls.GetEventLevel(event.StateBridge) { + ce.Reply("I don't have sufficient permissions for `m.bridge` events. Adjust power levels or use the `--ignore-permissions` flag to ignore.") + return + } + } + portal, login, ok := getCreatePortalInput( + ce, + ce.Bridge.Config.Relay.AllowBridge, + ce.Bridge.Config.Relay.PreferDefault && len(ce.Bridge.Config.Relay.DefaultRelays) > 0, + ) + if !ok { + return + } else if portal.MXID != "" { + // TODO check overwrite permissions + canOverwrite := ce.User.Permissions.Admin || hasRoomPermissions(ce, portal.MXID, fakeEvtPlumb) + if !canOverwrite { + ce.Reply("That chat is already bridged to another room, and you don't have the permission to delete it.") + return + } else if !allowOverwrite { + ce.Reply("That chat is already bridged to [%s](%s). Use `--overwrite` to delete the existing room.", portal.Name, portal.MXID.URI().MatrixToURL()) + return + } + } + if info, err := login.Client.GetChatInfo(ce.Ctx, portal); err != nil { + ce.Log.Err(err).Msg("Failed to get chat info for plumbing") + ce.Reply("Failed to get chat info: %v", err) + } else if info == nil { + ce.Reply("Chat info not found") + } else if err = portal.UpdateMatrixRoomID(ce.Ctx, ce.RoomID, bridgev2.UpdateMatrixRoomIDParams{ + FailIfMXIDSet: !allowOverwrite, + TombstoneOldRoom: true, + DeleteOldRoom: true, + ChatInfo: info, + ChatInfoSource: login, + }); err != nil { + ce.Log.Err(err).Msg("Failed to plumb room") + ce.Reply("Failed to plumb room: %v", err) + } else { + var relaySuffix string + if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, login.ID) { + err = portal.SetRelay(ce.Ctx, login) + if err != nil { + ce.Log.Err(err).Msg("Failed to set relay for portal after plumbing") + relaySuffix = fmt.Sprintf(", but failed to set %s as relay", format.SafeMarkdownCode(login.ID)) + } else { + relaySuffix = fmt.Sprintf(" and set %s as relay", format.SafeMarkdownCode(login.ID)) + } + } + ce.Reply( + "Successfully plumbed this room to %s on %s%s", + format.SafeMarkdownCode(portal.ID), + ce.Bridge.Network.GetName().DisplayName, + relaySuffix, + ) + } +} + +var CommandUnbridge = &FullHandler{ + Func: func(ce *Event) { + if !canPlumb(ce) { + ce.Reply("You don't have permission to unbridge this portal") + return + } + mxid := ce.Portal.MXID + err := ce.Portal.RemoveMXID(ce.Ctx) + if err != nil { + ce.Reply("Failed to remove portal mxid: %v", err) + return + } + err = ce.Bot.DeleteRoom(ce.Ctx, mxid, true) + if err != nil { + ce.Reply("Failed to clean up room: %v", err) + } + ce.MessageStatus.DisableMSS = true + }, + Name: "unbridge", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Unbridge the current portal room", + }, + RequiresPortal: true, +} + +var CommandSyncChat = &FullHandler{ + Func: fnSyncChat, + Name: "sync-portal", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Sync the current portal room", + }, + RequiresPortal: true, +} + +func fnSyncChat(ce *Event) { + login, _, err := ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, ce.Bridge.Config.Relay.AllowBridge) + if err != nil { + ce.Log.Err(err).Msg("Failed to find login for sync") + ce.Reply("Failed to find login: %v", err) + return + } else if login == nil { + if ce.Portal.Relay == nil { + ce.Reply("No login found for sync") + return + } else if !canManageRelay(ce) { + ce.Reply("Only users with relay management permissions can use sync-portal through the relay") + return + } + login = ce.Portal.Relay + } + info, err := login.Client.GetChatInfo(ce.Ctx, ce.Portal) + if err != nil { + ce.Log.Err(err).Msg("Failed to get chat info for sync") + ce.Reply("Failed to get chat info: %v", err) + return + } + ce.Portal.UpdateInfo(ce.Ctx, info, login, nil, time.Time{}) + ce.React("✅️") +} + +var CommandMute = &FullHandler{ + Func: fnMute, + Name: "mute", + Aliases: []string{"unmute"}, + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Mute or unmute a chat on the remote network", + Args: "[duration]", + }, + RequiresPortal: true, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.MuteHandlingNetworkAPI], +} + +func fnMute(ce *Event) { + _, api, _ := getClientForStartingChat[bridgev2.MuteHandlingNetworkAPI](ce, "muting chats") + var mutedUntil int64 + if ce.Command == "mute" { + mutedUntil = -1 + if len(ce.Args) > 0 { + duration, err := time.ParseDuration(ce.Args[0]) + if err != nil { + ce.Reply("Invalid duration: %v", err) + return + } + mutedUntil = time.Now().Add(duration).UnixMilli() + } + } + err := api.HandleMute(ce.Ctx, &bridgev2.MatrixMute{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.BeeperMuteEventContent]{ + Content: &event.BeeperMuteEventContent{MutedUntil: mutedUntil}, + Portal: ce.Portal, + }, + }) + if err != nil { + ce.Reply("Failed to %s chat: %v", ce.Command, err) + } else { + ce.React("✅️") + } +} + +var CommandDeleteChat = &FullHandler{ + Func: fnDeleteChat, + Name: "delete-chat", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Delete the current chat on the remote network", + Args: "[--for-everyone]", + }, + RequiresPortal: true, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.DeleteChatHandlingNetworkAPI], +} + +func fnDeleteChat(ce *Event) { + _, api, _ := getClientForStartingChat[bridgev2.DeleteChatHandlingNetworkAPI](ce, "deleting chats") + err := api.HandleMatrixDeleteChat(ce.Ctx, &bridgev2.MatrixDeleteChat{ + Event: nil, + Content: &event.BeeperChatDeleteEventContent{ + DeleteForEveryone: slices.Contains(ce.Args, "--for-everyone"), + FromMessageRequest: ce.Portal.MessageRequest, + }, + Portal: ce.Portal, + }) + if err != nil { + ce.Reply("Failed to delete chat: %v", err) + } else { + ce.React("✅️") + } +} + +var CommandFilter = &FullHandler{ + Func: fnFilter, + Name: "filter", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Manage the room creation filter. Changes are currently in-memory only", + Args: " [receiver]", + }, + RequiresAdmin: true, +} + +func markdownPCFI(pcfi *bridgeconfig.PortalCreateFilterItem) string { + if pcfi == nil { + return "" + } else if pcfi.Receiver == nil { + return format.SafeMarkdownCode(pcfi.ID) + } + return fmt.Sprintf("%s (receiver: %s)", format.SafeMarkdownCode(pcfi.ID), format.SafeMarkdownCode(*pcfi.Receiver)) +} + +func fnFilter(ce *Event) { + if len(ce.Args) < 2 || len(ce.Args) > 3 { + ce.Reply("Usage: %s [receiver]", ce.Command) + return + } + target := &bridgeconfig.PortalCreateFilterItem{ + ID: networkid.PortalID(ce.Args[1]), + } + if len(ce.Args) == 3 { + target.Receiver = (*networkid.UserLoginID)(&ce.Args[2]) + } + pcf := &ce.Bridge.Config.PortalCreateFilter + found := slices.ContainsFunc(pcf.List, func(item *bridgeconfig.PortalCreateFilterItem) bool { + return item.Equals(target) + }) + switch strings.ToLower(ce.Args[0]) { + case "allow": + switch pcf.Mode { + case bridgeconfig.PortalCreateFilterModeAllow: + if found { + ce.Reply("%s is already on the allow list", markdownPCFI(target)) + } else { + pcf.List = append(pcf.List, target) + ce.Reply("Added %s to allow list", markdownPCFI(target)) + } + case bridgeconfig.PortalCreateFilterModeDeny: + if !found { + ce.Reply("%s is not on the deny list", markdownPCFI(target)) + } else { + pcf.List = slices.DeleteFunc(pcf.List, func(item *bridgeconfig.PortalCreateFilterItem) bool { + return item.Equals(target) + }) + ce.Reply("Removed %s from deny list", markdownPCFI(target)) + } + } + case "disallow", "block", "deny": + switch pcf.Mode { + case bridgeconfig.PortalCreateFilterModeAllow: + if !found { + ce.Reply("%s is not on the allow list", markdownPCFI(target)) + } else { + pcf.List = slices.DeleteFunc(pcf.List, func(item *bridgeconfig.PortalCreateFilterItem) bool { + return item.Equals(target) + }) + ce.Reply("Removed %s from allow list", markdownPCFI(target)) + } + case bridgeconfig.PortalCreateFilterModeDeny: + if found { + ce.Reply("%s is already on the deny list", markdownPCFI(target)) + } else { + pcf.List = append(pcf.List, target) + ce.Reply("Added %s to deny list", markdownPCFI(target)) + } + } + default: + ce.Reply("Usage: %s [receiver]", ce.Command) + } +} diff --git a/mautrix-patched/bridgev2/commands/processor.go b/mautrix-patched/bridgev2/commands/processor.go new file mode 100644 index 00000000..5b63bbae --- /dev/null +++ b/mautrix-patched/bridgev2/commands/processor.go @@ -0,0 +1,208 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "fmt" + "runtime/debug" + "strings" + "sync/atomic" + "unsafe" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type Processor struct { + bridge *bridgev2.Bridge + log *zerolog.Logger + + handlers map[string]CommandHandler + aliases map[string]string +} + +// NewProcessor creates a Processor +func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { + proc := &Processor{ + bridge: bridge, + log: &bridge.Log, + + handlers: make(map[string]CommandHandler), + aliases: make(map[string]string), + } + proc.AddHandlers( + CommandHelp, CommandCancel, + CommandRegisterPush, CommandSendAccountData, CommandResetNetwork, + CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, + CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, + CommandSetRelay, CommandUnsetRelay, + CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandCreatePortal, + CommandID, CommandUnbridge, CommandBridge, CommandSyncChat, CommandMute, CommandDeleteChat, CommandFilter, + CommandSudo, CommandDoIn, + CommandImportImagePack, + ) + return proc +} + +func (proc *Processor) AddHandlers(handlers ...CommandHandler) { + for _, handler := range handlers { + proc.AddHandler(handler) + } +} + +func (proc *Processor) AddHandler(handler CommandHandler) { + proc.handlers[handler.GetName()] = handler + aliased, ok := handler.(AliasedCommandHandler) + if ok { + for _, alias := range aliased.GetAliases() { + proc.aliases[alias] = handler.GetName() + } + } +} + +// Handle handles messages to the bridge +func (proc *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) { + ms := &bridgev2.MessageStatus{ + Step: status.MsgStepCommand, + Status: event.MessageStatusSuccess, + } + logCopy := zerolog.Ctx(ctx).With().Logger() + log := &logCopy + defer func() { + statusInfo := &bridgev2.MessageStatusEventInfo{ + RoomID: roomID, + SourceEventID: eventID, + EventType: event.EventMessage, + Sender: user.MXID, + } + err := recover() + if err != nil { + logEvt := log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()) + if realErr, ok := err.(error); ok { + logEvt = logEvt.Err(realErr) + } else { + logEvt = logEvt.Any(zerolog.ErrorFieldName, err) + } + logEvt.Msg("Panic in Matrix command handler") + ms.Status = event.MessageStatusFail + ms.IsCertain = true + if realErr, ok := err.(error); ok { + ms.InternalError = realErr + } else { + ms.InternalError = fmt.Errorf("%v", err) + } + ms.ErrorAsMessage = true + } + proc.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo) + }() + args := strings.Fields(message) + if len(args) == 0 { + args = []string{"unknown-command"} + } + command := strings.ToLower(args[0]) + rawArgs := strings.TrimLeft(strings.TrimPrefix(message, command), " ") + portal, err := proc.bridge.GetPortalByMXID(ctx, roomID) + if err != nil { + log.Err(err).Msg("Failed to get portal") + // :( + } + ce := &Event{ + Bot: proc.bridge.Bot, + Bridge: proc.bridge, + Portal: portal, + Processor: proc, + RoomID: roomID, + OrigRoomID: roomID, + EventID: eventID, + User: user, + Command: command, + Args: args[1:], + RawArgs: rawArgs, + ReplyTo: replyTo, + Ctx: ctx, + Log: log, + + MessageStatus: ms, + } + proc.handleCommand(ctx, ce, message, args) +} + +func (proc *Processor) handleCommand(ctx context.Context, ce *Event, origMessage string, origArgs []string) { + realCommand, ok := proc.aliases[ce.Command] + if !ok { + realCommand = ce.Command + } + log := zerolog.Ctx(ctx) + + var handler MinimalCommandHandler + handler, ok = proc.handlers[realCommand] + if !ok { + state := LoadCommandState(ce.User) + if state != nil && state.Next != nil { + ce.Command = "" + ce.RawArgs = origMessage + ce.Args = origArgs + ce.Handler = state.Next + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("action", state.Action) + }) + log.Debug().Msg("Received reply to command state") + state.Next.Run(ce) + } else { + zerolog.Ctx(ctx).Debug().Str("mx_command", ce.Command).Msg("Received unknown command") + ce.Reply("Unknown command, use the `help` command for help.") + } + } else { + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("mx_command", ce.Command) + }) + log.Debug().Msg("Received command") + ce.Handler = handler + handler.Run(ce) + } +} + +func LoadCommandState(user *bridgev2.User) *CommandState { + return (*CommandState)(atomic.LoadPointer(&user.CommandState)) +} + +func StoreCommandState(user *bridgev2.User, cs *CommandState) { + atomic.StorePointer(&user.CommandState, unsafe.Pointer(cs)) +} + +func SwapCommandState(user *bridgev2.User, cs *CommandState) *CommandState { + return (*CommandState)(atomic.SwapPointer(&user.CommandState, unsafe.Pointer(cs))) +} + +var CommandCancel = &FullHandler{ + Func: func(ce *Event) { + state := SwapCommandState(ce.User, nil) + if state != nil { + action := state.Action + if action == "" { + action = "Unknown action" + } + if state.Cancel != nil { + state.Cancel() + } + ce.Reply("%s cancelled.", action) + } else { + ce.Reply("No ongoing command.") + } + }, + Name: "cancel", + Help: HelpMeta{ + Section: HelpSectionGeneral, + Description: "Cancel an ongoing action.", + }, +} diff --git a/mautrix-patched/bridgev2/commands/relay.go b/mautrix-patched/bridgev2/commands/relay.go new file mode 100644 index 00000000..3d86b375 --- /dev/null +++ b/mautrix-patched/bridgev2/commands/relay.go @@ -0,0 +1,170 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ( + fakeEvtSetRelay = event.Type{Type: "fi.mau.bridge.set_relay", Class: event.StateEventType} + fakeEvtPlumb = event.Type{Type: "fi.mau.bridge.plumb", Class: event.StateEventType} +) + +var CommandSetRelay = &FullHandler{ + Func: fnSetRelay, + Name: "set-relay", + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "Use your account to relay messages sent by users who haven't logged in", + Args: "[_login ID_]", + }, + RequiresPortal: true, +} + +func fnSetRelay(ce *Event) { + if !ce.Bridge.Config.Relay.Enabled { + ce.Reply("This bridge does not allow relay mode") + return + } else if !canManageRelay(ce) { + ce.Reply("You don't have permission to manage the relay in this room") + return + } + onlySetDefaultRelays := !ce.User.Permissions.Admin && ce.Bridge.Config.Relay.AdminOnly + preferDefaultRelay := ce.Bridge.Config.Relay.PreferDefault && len(ce.Bridge.Config.Relay.DefaultRelays) > 0 + var relay *bridgev2.UserLogin + if len(ce.Args) == 0 && ce.Portal.Receiver == "" { + relay = ce.User.GetDefaultLogin() + isLoggedIn := relay != nil + if onlySetDefaultRelays || preferDefaultRelay { + relay = nil + } + if relay == nil { + if len(ce.Bridge.Config.Relay.DefaultRelays) == 0 { + ce.Reply("You're not logged in and there are no default relay users configured") + return + } + logins, err := ce.Bridge.GetUserLoginsInPortal(ce.Ctx, ce.Portal.PortalKey) + if err != nil { + ce.Log.Err(err).Msg("Failed to get user logins in portal") + ce.Reply("Failed to get logins in portal to find default relay") + return + } + Outer: + for _, loginID := range ce.Bridge.Config.Relay.DefaultRelays { + for _, login := range logins { + if login.ID == loginID { + relay = login + break Outer + } + } + } + if relay == nil { + if isLoggedIn { + if onlySetDefaultRelays { + ce.Reply("You're not allowed to use yourself as relay and none of the default relay users are in the chat") + } else { + ce.Reply("None of the default relay users are in the chat. If you want to use yourself as the relay, specify your login ID explicitly") + } + } else { + ce.Reply("You're not logged in and none of the default relay users are in the chat") + } + return + } + } + } else { + var targetID networkid.UserLoginID + if ce.Portal.Receiver != "" { + targetID = ce.Portal.Receiver + if len(ce.Args) > 0 && ce.Args[0] != string(targetID) { + ce.Reply("In split portals, only the receiver (%s) can be set as relay", targetID) + return + } + } else { + targetID = networkid.UserLoginID(ce.Args[0]) + } + relay = ce.Bridge.GetCachedUserLoginByID(targetID) + if relay == nil { + ce.Reply("User login with ID `%s` not found", targetID) + return + } else if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, relay.ID) { + // All good + } else if relay.UserMXID != ce.User.MXID && !ce.User.Permissions.Admin { + ce.Reply("Only bridge admins can set another user's login as the relay") + return + } else if onlySetDefaultRelays { + ce.Reply("You're not allowed to use yourself as relay") + return + } + } + err := ce.Portal.SetRelay(ce.Ctx, relay) + if err != nil { + ce.Log.Err(err).Msg("Failed to unset relay") + ce.Reply("Failed to save relay settings") + } else { + ce.Reply( + "Messages sent by users who haven't logged in will now be relayed through %s ([%s](%s)'s login)", + relay.RemoteName, + relay.UserMXID, + // TODO this will need to stop linkifying if we ever allow UserLogins that aren't bound to a real user. + relay.UserMXID.URI().MatrixToURL(), + ) + } +} + +var CommandUnsetRelay = &FullHandler{ + Func: fnUnsetRelay, + Name: "unset-relay", + Help: HelpMeta{ + Section: HelpSectionAuth, + Description: "Stop relaying messages sent by users who haven't logged in", + }, + RequiresPortal: true, +} + +func fnUnsetRelay(ce *Event) { + if ce.Portal.Relay == nil { + ce.Reply("This portal doesn't have a relay set.") + return + } else if !canManageRelay(ce) { + ce.Reply("You don't have permission to manage the relay in this room") + return + } + err := ce.Portal.SetRelay(ce.Ctx, nil) + if err != nil { + ce.Log.Err(err).Msg("Failed to unset relay") + ce.Reply("Failed to save relay settings") + } else { + ce.Reply("Stopped relaying messages for users who haven't logged in") + } +} + +func canManageRelay(ce *Event) bool { + return ce.User.Permissions.ManageRelay && + (ce.User.Permissions.Admin || + (ce.Portal.Relay != nil && ce.Portal.Relay.UserMXID == ce.User.MXID) || + hasRoomPermissions(ce, ce.RoomID, fakeEvtSetRelay)) +} + +func canPlumb(ce *Event) bool { + return ce.User.Permissions.ManageRelay && + (ce.User.Permissions.Admin || hasRoomPermissions(ce, ce.RoomID, fakeEvtPlumb)) +} + +func hasRoomPermissions(ce *Event, roomID id.RoomID, evtType event.Type) bool { + levels, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, roomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to check room power levels") + return false + } + return levels.GetUserLevel(ce.User.MXID) >= levels.GetEventLevel(evtType) +} diff --git a/mautrix-patched/bridgev2/commands/startchat.go b/mautrix-patched/bridgev2/commands/startchat.go new file mode 100644 index 00000000..a317ed01 --- /dev/null +++ b/mautrix-patched/bridgev2/commands/startchat.go @@ -0,0 +1,366 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "errors" + "fmt" + "html" + "maps" + "slices" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +var CommandResolveIdentifier = &FullHandler{ + Func: fnResolveIdentifier, + Name: "resolve-identifier", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Check if a given identifier is on the remote network", + Args: "[_login ID_] <_identifier_>", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI], +} + +var CommandStartChat = &FullHandler{ + Func: fnResolveIdentifier, + Name: "start-chat", + Aliases: []string{"pm"}, + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Start a direct chat with the given user", + Args: "[_login ID_] <_identifier_>", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI], +} + +func getClientForStartingChat[T bridgev2.NetworkAPI](ce *Event, thing string) (login *bridgev2.UserLogin, api T, remainingArgs []string) { + if len(ce.Args) > 1 { + remainingArgs = ce.Args[1:] + } + if len(ce.Args) > 0 { + login = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + } + if login == nil || login.UserMXID != ce.User.MXID { + remainingArgs = ce.Args + login = ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("You're not logged in") + return + } + } + var ok bool + api, ok = login.Client.(T) + if !ok { + ce.Reply("This bridge does not support %s", thing) + } + return login, api, remainingArgs +} + +func formatResolveIdentifierResult(resp *provisionutil.RespResolveIdentifier) string { + if resp.MXID != "" { + return fmt.Sprintf("`%s` / [%s](%s)", resp.ID, resp.Name, resp.MXID.URI().MatrixToURL()) + } else if resp.Name != "" { + return fmt.Sprintf("`%s` / %s", resp.ID, resp.Name) + } else { + return fmt.Sprintf("`%s`", resp.ID) + } +} + +func fnResolveIdentifier(ce *Event) { + if len(ce.Args) == 0 { + ce.Reply("Usage: `$cmdprefix %s `", ce.Command) + return + } + login, api, identifierParts := getClientForStartingChat[bridgev2.IdentifierResolvingNetworkAPI](ce, "resolving identifiers") + if api == nil { + return + } + allLogins := ce.User.GetUserLogins() + createChat := ce.Command == "start-chat" || ce.Command == "pm" + identifier := strings.Join(identifierParts, " ") + resp, err := provisionutil.ResolveIdentifier(ce.Ctx, login, identifier, createChat) + for i := 0; i < len(allLogins) && errors.Is(err, bridgev2.ErrResolveIdentifierTryNext); i++ { + resp, err = provisionutil.ResolveIdentifier(ce.Ctx, allLogins[i], identifier, createChat) + } + if err != nil { + ce.Reply("Failed to resolve identifier: %v", err) + return + } else if resp == nil { + ce.ReplyAdvanced(fmt.Sprintf("Identifier %s not found", html.EscapeString(identifier)), false, true) + return + } + formattedName := formatResolveIdentifierResult(resp) + if createChat { + name := resp.Portal.Name + if name == "" { + name = resp.Portal.MXID.String() + } + if !resp.JustCreated { + ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL()) + } else { + ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL()) + } + } else { + ce.Reply("Found %s", formattedName) + } +} + +var CommandCreateGroup = &FullHandler{ + Func: fnCreateGroup, + Name: "create-group", + Aliases: []string{"create"}, + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Create a new group chat for the current Matrix room", + Args: "[_group type_]", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.GroupCreatingNetworkAPI], + RequiresEventLevel: event.StateBridge, +} + +func getState[T any](ctx context.Context, roomID id.RoomID, evtType event.Type, provider bridgev2.MatrixConnectorWithArbitraryRoomState) (content T) { + evt, err := provider.GetStateEvent(ctx, roomID, evtType, "") + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("event_type", evtType).Msg("Failed to get state event for group creation") + } else if evt != nil { + content, _ = evt.Content.Parsed.(T) + } + return +} + +func fnCreateGroup(ce *Event) { + if alreadyBridged(ce) { + return + } + login, api, remainingArgs := getClientForStartingChat[bridgev2.GroupCreatingNetworkAPI](ce, "creating group") + if api == nil { + return + } + stateProvider, ok := ce.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState) + if !ok { + ce.Reply("Matrix connector doesn't support fetching room state") + return + } + members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to get room members for group creation") + ce.Reply("Failed to get room members: %v", err) + return + } + caps := ce.Bridge.Network.GetCapabilities() + params := &bridgev2.GroupCreateParams{ + Username: "", + Participants: make([]networkid.UserID, 0, len(members)-2), + Parent: nil, // TODO check space parent event + Name: getState[*event.RoomNameEventContent](ce.Ctx, ce.RoomID, event.StateRoomName, stateProvider), + Avatar: getState[*event.RoomAvatarEventContent](ce.Ctx, ce.RoomID, event.StateRoomAvatar, stateProvider), + Topic: getState[*event.TopicEventContent](ce.Ctx, ce.RoomID, event.StateTopic, stateProvider), + Disappear: getState[*event.BeeperDisappearingTimer](ce.Ctx, ce.RoomID, event.StateBeeperDisappearingTimer, stateProvider), + RoomID: ce.RoomID, + } + for userID, member := range members { + if userID == ce.User.MXID || userID == ce.Bot.GetMXID() || !member.Membership.IsInviteOrJoin() { + continue + } + if parsedUserID, ok := ce.Bridge.Matrix.ParseGhostMXID(userID); ok { + params.Participants = append(params.Participants, parsedUserID) + } else if !ce.Bridge.Config.SplitPortals { + if user, err := ce.Bridge.GetExistingUserByMXID(ce.Ctx, userID); err != nil { + ce.Log.Err(err).Stringer("user_id", userID).Msg("Failed to get user for room member") + } else if user != nil { + for _, login := range user.GetUserLogins() { + nui, ok := login.Client.(bridgev2.NetworkAPIWithUserID) + if !ok { + continue + } + loginUserID := nui.GetUserID() + if loginUserID != "" && nui.IsLoggedIn() { + params.Participants = append(params.Participants, loginUserID) + } + } + } + } + } + + if len(caps.Provisioning.GroupCreation) == 0 { + ce.Reply("No group creation types defined in network capabilities") + return + } else if len(remainingArgs) > 0 { + params.Type = remainingArgs[0] + } else if len(caps.Provisioning.GroupCreation) == 1 { + for params.Type = range caps.Provisioning.GroupCreation { + // The loop assigns the variable we want + } + } else { + types := strings.Join(slices.Collect(maps.Keys(caps.Provisioning.GroupCreation)), "`, `") + ce.Reply("Please specify type of group to create: `%s`", types) + return + } + resp, err := provisionutil.CreateGroup(ce.Ctx, login, params) + if err != nil { + ce.Reply("Failed to create group: %v", err) + return + } + var postfix string + if len(resp.FailedParticipants) > 0 { + failedParticipantsStrings := make([]string, len(resp.FailedParticipants)) + i := 0 + for participantID, meta := range resp.FailedParticipants { + failedParticipantsStrings[i] = fmt.Sprintf("* %s: %s", format.SafeMarkdownCode(participantID), meta.Reason) + i++ + } + postfix += "\n\nFailed to add some participants:\n" + strings.Join(failedParticipantsStrings, "\n") + } + ce.Reply("Successfully created group `%s`%s", resp.ID, postfix) +} + +var CommandCreatePortal = &FullHandler{ + Func: fnCreatePortal, + Name: "create-portal", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Create a new Matrix room for an existing chat on the remote network", + Args: "[login ID] ", + }, +} + +func getCreatePortalInput(ce *Event, allowRelay, preferRelay bool) (portal *bridgev2.Portal, login *bridgev2.UserLogin, ok bool) { + portalID := networkid.PortalID(ce.Args[len(ce.Args)-1]) + if len(ce.Args) == 2 { + loginID := networkid.UserLoginID(ce.Args[0]) + login = ce.Bridge.GetCachedUserLoginByID(loginID) + if login == nil { + ce.Reply("No login found with ID %s", format.SafeMarkdownCode(loginID)) + return + } else if login.UserMXID != ce.User.MXID && + !(ce.User.Permissions.Admin || (allowRelay && slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, login.ID))) { + ce.Reply("Login %s does not belong to you", format.SafeMarkdownCode(loginID)) + return + } + } else if login = ce.User.GetDefaultLogin(); login == nil || preferRelay { + if !allowRelay || len(ce.Bridge.Config.Relay.DefaultRelays) == 0 { + ce.Reply("You're not logged in") + return + } + for _, relayID := range ce.Bridge.Config.Relay.DefaultRelays { + login = ce.Bridge.GetCachedUserLoginByID(relayID) + if login != nil { + break + } + } + if login == nil { + ce.Reply("You're not logged in and none of the default relays were found") + return + } + } + if !login.Client.IsLoggedIn() { + ce.Reply("Login %s is not logged in", format.SafeMarkdownCode(login.ID)) + return + } + var err error + portal, err = ce.Bridge.GetExistingPortalByKey(ce.Ctx, networkid.PortalKey{ + ID: portalID, + Receiver: login.ID, + }) + if err != nil { + ce.Log.Err(err).Msg("Failed to get portal") + ce.Reply("Failed to get portal") + } else if portal == nil { + if ce.Command == "create-portal" { + ce.Reply("No portal found with ID %s. Try `$cmdprefix filter allow` instead", format.SafeMarkdownCode(portalID)) + } else { + ce.Reply("No portal found with ID %s. You may need to receive a message in the chat first", format.SafeMarkdownCode(portalID)) + } + } else { + ok = true + } + return +} + +func fnCreatePortal(ce *Event) { + if len(ce.Args) == 0 || len(ce.Args) > 2 { + ce.Reply("Usage: `$cmdprefix create-portal [login ID] `") + return + } + portal, login, ok := getCreatePortalInput(ce, false, false) + if !ok { + return + } + if portal.MXID != "" { + // TODO allow showing room ID if the user is already in the room, even if they don't have admin permissions + if ce.User.Permissions.Admin { + ce.Reply("That chat already has a Matrix room at [%s](%s)", portal.Name, portal.MXID.URI().MatrixToURL()) + } else { + ce.Reply("That chat already has a Matrix room") + } + } else if info, err := login.Client.GetChatInfo(ce.Ctx, portal); err != nil { + ce.Log.Err(err).Msg("Failed to get chat info for creating portal") + ce.Reply("Failed to get chat info: %v", err) + } else if info == nil { + ce.Reply("Chat info not found") + } else if err = portal.CreateMatrixRoom(ce.Ctx, login, info); err != nil { + ce.Log.Err(err).Msg("Failed to create portal room") + ce.Reply("Failed to create portal room: %v", err) + } else { + ce.Reply("Successfully created portal room [%s](%s)", portal.Name, portal.MXID.URI().MatrixToURL()) + } +} + +var CommandSearch = &FullHandler{ + Func: fnSearch, + Name: "search", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Search for users on the remote network", + Args: "<_query_>", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.UserSearchingNetworkAPI], +} + +func fnSearch(ce *Event) { + if len(ce.Args) == 0 { + ce.Reply("Usage: `$cmdprefix search `") + return + } + login, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users") + if api == nil { + return + } + resp, err := provisionutil.SearchUsers(ce.Ctx, login, strings.Join(queryParts, " ")) + if err != nil { + ce.Reply("Failed to search for users: %v", err) + return + } + resultsString := make([]string, len(resp.Results)) + for i, res := range resp.Results { + formattedName := formatResolveIdentifierResult(res) + resultsString[i] = fmt.Sprintf("* %s", formattedName) + if res.Portal != nil && res.Portal.MXID != "" { + portalName := res.Portal.Name + if portalName == "" { + portalName = res.Portal.MXID.String() + } + resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Portal.MXID.URI().MatrixToURL()) + } + } + ce.Reply("Search results:\n\n%s", strings.Join(resultsString, "\n")) +} diff --git a/mautrix-patched/bridgev2/commands/sudo.go b/mautrix-patched/bridgev2/commands/sudo.go new file mode 100644 index 00000000..e0814ecb --- /dev/null +++ b/mautrix-patched/bridgev2/commands/sudo.go @@ -0,0 +1,108 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var CommandSudo = &FullHandler{ + Func: fnSudo, + Name: "sudo", + Aliases: []string{"doas", "do-as", "runas", "run-as"}, + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Run a command as a different user.", + Args: "[--create] <_user ID_> <_command_> [_args..._]", + }, + RequiresAdmin: true, +} + +func fnSudo(ce *Event) { + forceNonexistentUser := len(ce.Args) > 0 && strings.ToLower(ce.Args[0]) == "--create" + if forceNonexistentUser { + ce.Args = ce.Args[1:] + } + if len(ce.Args) < 2 { + ce.Reply("Usage: `$cmdprefix sudo [--create] [args...]`") + return + } + targetUserID := id.UserID(ce.Args[0]) + if _, _, err := targetUserID.Parse(); err != nil || len(targetUserID) > id.UserIDMaxLength { + ce.Reply("Invalid user ID `%s`", targetUserID) + return + } + var targetUser *bridgev2.User + var err error + if forceNonexistentUser { + targetUser, err = ce.Bridge.GetUserByMXID(ce.Ctx, targetUserID) + } else { + targetUser, err = ce.Bridge.GetExistingUserByMXID(ce.Ctx, targetUserID) + } + if err != nil { + ce.Log.Err(err).Msg("Failed to get user from database") + ce.Reply("Failed to get user") + return + } else if targetUser == nil { + ce.Reply("User not found. Use `--create` if you want to run commands as a user who has never used the bridge.") + return + } + ce.Sudo = true + ce.User = targetUser + origArgs := ce.Args[1:] + ce.Command = strings.ToLower(ce.Args[1]) + ce.Args = ce.Args[2:] + ce.RawArgs = strings.Join(ce.Args, " ") + ce.Processor.handleCommand(ce.Ctx, ce, strings.Join(origArgs, " "), origArgs) +} + +var CommandDoIn = &FullHandler{ + Func: fnDoIn, + Name: "doin", + Aliases: []string{"do-in", "runin", "run-in"}, + Help: HelpMeta{ + Section: HelpSectionAdmin, + Description: "Run a command in a different room.", + Args: "<_room ID_> <_command_> [_args..._]", + }, +} + +func fnDoIn(ce *Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `$cmdprefix doin [args...]`") + return + } + targetRoomID := id.RoomID(ce.Args[0]) + if !ce.User.Permissions.Admin { + memberInfo, err := ce.Bridge.Matrix.GetMemberInfo(ce.Ctx, targetRoomID, ce.User.MXID) + if err != nil { + ce.Log.Err(err).Msg("Failed to check if user is in doin target room") + ce.Reply("Failed to check if you're in the target room") + return + } else if memberInfo == nil || memberInfo.Membership != event.MembershipJoin { + ce.Reply("You must be in the target room to run commands there") + return + } + } + ce.RoomID = targetRoomID + var err error + ce.Portal, err = ce.Bridge.GetPortalByMXID(ce.Ctx, targetRoomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to get target portal") + ce.Reply("Failed to get portal") + return + } + origArgs := ce.Args[1:] + ce.Command = strings.ToLower(ce.Args[1]) + ce.Args = ce.Args[2:] + ce.RawArgs = strings.Join(ce.Args, " ") + ce.Processor.handleCommand(ce.Ctx, ce, strings.Join(origArgs, " "), origArgs) +} diff --git a/mautrix-patched/bridgev2/database/backfillqueue.go b/mautrix-patched/bridgev2/database/backfillqueue.go new file mode 100644 index 00000000..310b6822 --- /dev/null +++ b/mautrix-patched/bridgev2/database/backfillqueue.go @@ -0,0 +1,192 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "time" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type BackfillTaskQuery struct { + BridgeID networkid.BridgeID + *dbutil.QueryHelper[*BackfillTask] +} + +type BackfillTask struct { + BridgeID networkid.BridgeID + PortalKey networkid.PortalKey + UserLoginID networkid.UserLoginID + + BatchCount int + IsDone bool + QueueDone bool + Cursor networkid.PaginationCursor + OldestMessageID networkid.MessageID + DispatchedAt time.Time + CompletedAt time.Time + NextDispatchMinTS time.Time + + FromQueue bool +} + +var BackfillNextDispatchNever = time.Unix(0, (1<<63)-1) + +const ( + ensureBackfillExistsQuery = ` + INSERT INTO backfill_task (bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, next_dispatch_min_ts) + VALUES ($1, $2, $3, $4, -1, false, false, $5) + ON CONFLICT (bridge_id, portal_id, portal_receiver) DO UPDATE + SET user_login_id=CASE + WHEN backfill_task.user_login_id='' + THEN excluded.user_login_id + ELSE backfill_task.user_login_id + END, + next_dispatch_min_ts=CASE + WHEN backfill_task.next_dispatch_min_ts=9223372036854775807 + THEN excluded.next_dispatch_min_ts + ELSE backfill_task.next_dispatch_min_ts + END + ` + upsertBackfillQueueQuery = ` + INSERT INTO backfill_task ( + bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, cursor, + oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (bridge_id, portal_id, portal_receiver) DO UPDATE + SET user_login_id=excluded.user_login_id, + batch_count=excluded.batch_count, + is_done=excluded.is_done, + queue_done=excluded.queue_done, + cursor=excluded.cursor, + oldest_message_id=excluded.oldest_message_id, + dispatched_at=excluded.dispatched_at, + completed_at=excluded.completed_at, + next_dispatch_min_ts=excluded.next_dispatch_min_ts + ` + markBackfillDispatchedQuery = ` + UPDATE backfill_task SET dispatched_at=$4, completed_at=NULL, next_dispatch_min_ts=$5 + WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 + ` + updateBackfillQueueQuery = ` + UPDATE backfill_task + SET user_login_id=$4, batch_count=$5, is_done=$6, queue_done=$7, cursor=$8, oldest_message_id=$9, + dispatched_at=$10, completed_at=$11, next_dispatch_min_ts=$12 + WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 + ` + markBackfillTaskNotDoneQuery = ` + UPDATE backfill_task + SET is_done = false + WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 AND user_login_id = $4 + ` + getNextBackfillQuery = ` + SELECT + bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, + cursor, oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts + FROM backfill_task + WHERE bridge_id = $1 AND next_dispatch_min_ts < $2 AND is_done = false AND queue_done = false AND user_login_id <> '' + ORDER BY next_dispatch_min_ts LIMIT 1 + ` + getNextBackfillQueryForPortal = ` + SELECT + bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, + cursor, oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts + FROM backfill_task + WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 + ` + deleteBackfillQueueQuery = ` + DELETE FROM backfill_task + WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 + ` +) + +func (btq *BackfillTaskQuery) EnsureExists(ctx context.Context, portal networkid.PortalKey, loginID networkid.UserLoginID) error { + return btq.Exec(ctx, ensureBackfillExistsQuery, btq.BridgeID, portal.ID, portal.Receiver, loginID, time.Now().UnixNano()) +} + +func (btq *BackfillTaskQuery) Upsert(ctx context.Context, bq *BackfillTask) error { + ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID) + return btq.Exec(ctx, upsertBackfillQueueQuery, bq.sqlVariables()...) +} + +const UnfinishedBackfillBackoff = 1 * time.Hour + +func (btq *BackfillTaskQuery) MarkDispatched(ctx context.Context, bq *BackfillTask) error { + ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID) + bq.DispatchedAt = time.Now() + bq.CompletedAt = time.Time{} + bq.NextDispatchMinTS = bq.DispatchedAt.Add(UnfinishedBackfillBackoff) + return btq.Exec( + ctx, markBackfillDispatchedQuery, + bq.BridgeID, bq.PortalKey.ID, bq.PortalKey.Receiver, + bq.DispatchedAt.UnixNano(), bq.NextDispatchMinTS.UnixNano(), + ) +} + +func (btq *BackfillTaskQuery) Update(ctx context.Context, bq *BackfillTask) error { + ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID) + return btq.Exec(ctx, updateBackfillQueueQuery, bq.sqlVariables()...) +} + +func (btq *BackfillTaskQuery) MarkNotDone(ctx context.Context, portalKey networkid.PortalKey, userLoginID networkid.UserLoginID) error { + return btq.Exec(ctx, markBackfillTaskNotDoneQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver, userLoginID) +} + +func (btq *BackfillTaskQuery) GetNext(ctx context.Context) (*BackfillTask, error) { + return btq.QueryOne(ctx, getNextBackfillQuery, btq.BridgeID, time.Now().UnixNano()) +} + +func (btq *BackfillTaskQuery) GetNextForPortal(ctx context.Context, portalKey networkid.PortalKey, allowCompletedTask bool) (*BackfillTask, error) { + task, err := btq.QueryOne(ctx, getNextBackfillQueryForPortal, btq.BridgeID, portalKey.ID, portalKey.Receiver) + if err != nil { + return nil, err + } else if !allowCompletedTask && (task == nil || task.IsDone || task.UserLoginID == "") { + return nil, nil + } + return task, nil +} + +func (btq *BackfillTaskQuery) Delete(ctx context.Context, portalKey networkid.PortalKey) error { + return btq.Exec(ctx, deleteBackfillQueueQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver) +} + +func (bt *BackfillTask) Scan(row dbutil.Scannable) (*BackfillTask, error) { + var cursor, oldestMessageID sql.NullString + var dispatchedAt, completedAt, nextDispatchMinTS sql.NullInt64 + err := row.Scan( + &bt.BridgeID, &bt.PortalKey.ID, &bt.PortalKey.Receiver, &bt.UserLoginID, &bt.BatchCount, &bt.IsDone, &bt.QueueDone, + &cursor, &oldestMessageID, &dispatchedAt, &completedAt, &nextDispatchMinTS) + if err != nil { + return nil, err + } + bt.Cursor = networkid.PaginationCursor(cursor.String) + bt.OldestMessageID = networkid.MessageID(oldestMessageID.String) + if dispatchedAt.Valid { + bt.DispatchedAt = time.Unix(0, dispatchedAt.Int64) + } + if completedAt.Valid { + bt.CompletedAt = time.Unix(0, completedAt.Int64) + } + if nextDispatchMinTS.Valid { + bt.NextDispatchMinTS = time.Unix(0, nextDispatchMinTS.Int64) + } + return bt, nil +} + +func (bt *BackfillTask) sqlVariables() []any { + return []any{ + bt.BridgeID, bt.PortalKey.ID, bt.PortalKey.Receiver, bt.UserLoginID, bt.BatchCount, bt.IsDone, bt.QueueDone, + dbutil.StrPtr(bt.Cursor), dbutil.StrPtr(bt.OldestMessageID), + dbutil.ConvertedPtr(bt.DispatchedAt, time.Time.UnixNano), + dbutil.ConvertedPtr(bt.CompletedAt, time.Time.UnixNano), + bt.NextDispatchMinTS.UnixNano(), + } +} diff --git a/mautrix-patched/bridgev2/database/database.go b/mautrix-patched/bridgev2/database/database.go new file mode 100644 index 00000000..05abddf0 --- /dev/null +++ b/mautrix-patched/bridgev2/database/database.go @@ -0,0 +1,154 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + + "maunium.net/go/mautrix/bridgev2/database/upgrades" +) + +type Database struct { + *dbutil.Database + + BridgeID networkid.BridgeID + Portal *PortalQuery + Ghost *GhostQuery + Message *MessageQuery + DisappearingMessage *DisappearingMessageQuery + Reaction *ReactionQuery + User *UserQuery + UserLogin *UserLoginQuery + UserPortal *UserPortalQuery + BackfillTask *BackfillTaskQuery + KV *KVQuery + PublicMedia *PublicMediaQuery +} + +type MetaMerger interface { + CopyFrom(other any) +} + +type MetaTypeCreator func() any + +type MetaTypes struct { + Portal MetaTypeCreator + Ghost MetaTypeCreator + Message MetaTypeCreator + Reaction MetaTypeCreator + UserLogin MetaTypeCreator +} + +type blankMeta struct{} + +var blankMetaItem = &blankMeta{} + +func blankMetaCreator() any { + return blankMetaItem +} + +func New(bridgeID networkid.BridgeID, mt MetaTypes, db *dbutil.Database) *Database { + if mt.Portal == nil { + mt.Portal = blankMetaCreator + } + if mt.Ghost == nil { + mt.Ghost = blankMetaCreator + } + if mt.Message == nil { + mt.Message = blankMetaCreator + } + if mt.Reaction == nil { + mt.Reaction = blankMetaCreator + } + if mt.UserLogin == nil { + mt.UserLogin = blankMetaCreator + } + db.UpgradeTable = upgrades.Table + return &Database{ + Database: db, + BridgeID: bridgeID, + Portal: &PortalQuery{ + BridgeID: bridgeID, + MetaType: mt.Portal, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Portal]) *Portal { + return (&Portal{}).ensureHasMetadata(mt.Portal) + }), + }, + Ghost: &GhostQuery{ + BridgeID: bridgeID, + MetaType: mt.Ghost, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Ghost]) *Ghost { + return (&Ghost{}).ensureHasMetadata(mt.Ghost) + }), + }, + Message: &MessageQuery{ + BridgeID: bridgeID, + MetaType: mt.Message, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Message]) *Message { + return (&Message{}).ensureHasMetadata(mt.Message) + }), + }, + DisappearingMessage: &DisappearingMessageQuery{ + BridgeID: bridgeID, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage { + return &DisappearingMessage{} + }), + }, + Reaction: &ReactionQuery{ + BridgeID: bridgeID, + MetaType: mt.Reaction, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Reaction]) *Reaction { + return (&Reaction{}).ensureHasMetadata(mt.Reaction) + }), + }, + User: &UserQuery{ + BridgeID: bridgeID, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*User]) *User { + return &User{} + }), + }, + UserLogin: &UserLoginQuery{ + BridgeID: bridgeID, + MetaType: mt.UserLogin, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*UserLogin]) *UserLogin { + return (&UserLogin{}).ensureHasMetadata(mt.UserLogin) + }), + }, + UserPortal: &UserPortalQuery{ + BridgeID: bridgeID, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*UserPortal]) *UserPortal { + return &UserPortal{} + }), + }, + BackfillTask: &BackfillTaskQuery{ + BridgeID: bridgeID, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*BackfillTask]) *BackfillTask { + return &BackfillTask{} + }), + }, + KV: &KVQuery{ + BridgeID: bridgeID, + Database: db, + }, + PublicMedia: &PublicMediaQuery{ + BridgeID: bridgeID, + QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*PublicMedia]) *PublicMedia { + return &PublicMedia{} + }), + }, + } +} + +func ensureBridgeIDMatches(ptr *networkid.BridgeID, expected networkid.BridgeID) { + if *ptr == "" { + *ptr = expected + } else if *ptr != expected { + panic("bridge ID mismatch") + } +} diff --git a/mautrix-patched/bridgev2/database/disappear.go b/mautrix-patched/bridgev2/database/disappear.go new file mode 100644 index 00000000..df36b205 --- /dev/null +++ b/mautrix-patched/bridgev2/database/disappear.go @@ -0,0 +1,142 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "time" + + "go.mau.fi/util/dbutil" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Deprecated: use [event.DisappearingType] +type DisappearingType = event.DisappearingType + +// Deprecated: use constants in event package +const ( + DisappearingTypeNone = event.DisappearingTypeNone + DisappearingTypeAfterRead = event.DisappearingTypeAfterRead + DisappearingTypeAfterSend = event.DisappearingTypeAfterSend +) + +// DisappearingSetting represents a disappearing message timer setting +// by combining a type with a timer and an optional start timestamp. +type DisappearingSetting struct { + Type event.DisappearingType + Timer time.Duration + DisappearAt time.Time +} + +func DisappearingSettingFromEvent(evt *event.BeeperDisappearingTimer) DisappearingSetting { + if evt == nil || evt.Type == event.DisappearingTypeNone { + return DisappearingSetting{} + } + return DisappearingSetting{ + Type: evt.Type, + Timer: evt.Timer.Duration, + } +} + +func (ds DisappearingSetting) Normalize() DisappearingSetting { + if ds.Type == event.DisappearingTypeNone { + ds.Timer = 0 + } else if ds.Timer == 0 { + ds.Type = event.DisappearingTypeNone + } + return ds +} + +func (ds DisappearingSetting) StartingAt(start time.Time) DisappearingSetting { + ds.DisappearAt = start.Add(ds.Timer) + return ds +} + +func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer { + if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 { + return &event.BeeperDisappearingTimer{} + } + return &event.BeeperDisappearingTimer{ + Type: ds.Type, + Timer: jsontime.MS(ds.Timer), + } +} + +type DisappearingMessageQuery struct { + BridgeID networkid.BridgeID + *dbutil.QueryHelper[*DisappearingMessage] +} + +type DisappearingMessage struct { + BridgeID networkid.BridgeID + RoomID id.RoomID + EventID id.EventID + Timestamp time.Time + DisappearingSetting +} + +const ( + upsertDisappearingMessageQuery = ` + INSERT INTO disappearing_message (bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (bridge_id, mxid) DO UPDATE SET timer=excluded.timer, disappear_at=excluded.disappear_at + ` + startDisappearingMessagesQuery = ` + UPDATE disappearing_message + SET disappear_at=$1 + timer + WHERE bridge_id=$2 AND mx_room=$3 AND disappear_at IS NULL AND type='after_read' AND timestamp<=$4 + RETURNING bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at + ` + getUpcomingDisappearingMessagesQuery = ` + SELECT bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at + FROM disappearing_message WHERE bridge_id = $1 AND disappear_at IS NOT NULL AND disappear_at < $2 + ORDER BY disappear_at LIMIT $3 + ` + deleteDisappearingMessageQuery = ` + DELETE FROM disappearing_message WHERE bridge_id=$1 AND mxid=$2 + ` +) + +func (dmq *DisappearingMessageQuery) Put(ctx context.Context, dm *DisappearingMessage) error { + ensureBridgeIDMatches(&dm.BridgeID, dmq.BridgeID) + return dmq.Exec(ctx, upsertDisappearingMessageQuery, dm.sqlVariables()...) +} + +func (dmq *DisappearingMessageQuery) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) ([]*DisappearingMessage, error) { + return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID, beforeTS.UnixNano()) +} + +func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration, limit int) ([]*DisappearingMessage, error) { + return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).UnixNano(), limit) +} + +func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.EventID) error { + return dmq.Exec(ctx, deleteDisappearingMessageQuery, dmq.BridgeID, eventID) +} + +func (d *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) { + var timestamp int64 + var disappearAt sql.NullInt64 + err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, ×tamp, &d.Type, &d.Timer, &disappearAt) + if err != nil { + return nil, err + } + if disappearAt.Valid { + d.DisappearAt = time.Unix(0, disappearAt.Int64) + } + d.Timestamp = time.Unix(0, timestamp) + return d, nil +} + +func (d *DisappearingMessage) sqlVariables() []any { + return []any{d.BridgeID, d.RoomID, d.EventID, d.Timestamp.UnixNano(), d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)} +} diff --git a/mautrix-patched/bridgev2/database/ghost.go b/mautrix-patched/bridgev2/database/ghost.go new file mode 100644 index 00000000..b936e14a --- /dev/null +++ b/mautrix-patched/bridgev2/database/ghost.go @@ -0,0 +1,175 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/id" +) + +type GhostQuery struct { + BridgeID networkid.BridgeID + MetaType MetaTypeCreator + *dbutil.QueryHelper[*Ghost] +} + +type ExtraProfile map[string]json.RawMessage + +func (ep *ExtraProfile) Set(key string, value any) error { + if key == "displayname" || key == "avatar_url" { + return fmt.Errorf("cannot set reserved profile key %q", key) + } + marshaled, err := canonicaljson.Marshal(value) + if err != nil { + return err + } + if *ep == nil { + *ep = make(ExtraProfile) + } + (*ep)[key] = marshaled + return nil +} + +func (ep *ExtraProfile) With(key string, value any) *ExtraProfile { + exerrors.PanicIfNotNil(ep.Set(key, value)) + return ep +} + +func canonicalize(data json.RawMessage) json.RawMessage { + canonicalized, _ := canonicaljson.Marshal(data) + return canonicalized +} + +func (ep *ExtraProfile) CopyTo(dest *ExtraProfile) (changed bool) { + if len(*ep) == 0 { + return + } + if *dest == nil { + *dest = make(ExtraProfile) + } + for key, val := range *ep { + if key == "displayname" || key == "avatar_url" { + continue + } + existing, exists := (*dest)[key] + if !exists || !bytes.Equal(canonicalize(existing), val) { + (*dest)[key] = val + changed = true + } + } + return +} + +type Ghost struct { + BridgeID networkid.BridgeID + ID networkid.UserID + + Name string + AvatarID networkid.AvatarID + AvatarHash [32]byte + AvatarMXC id.ContentURIString + NameSet bool + AvatarSet bool + ContactInfoSet bool + IsBot bool + Identifiers []string + ExtraProfile ExtraProfile + Metadata any +} + +const ( + getGhostBaseQuery = ` + SELECT bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, + name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata + FROM ghost + ` + getGhostByIDQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND id=$2` + getGhostByMetadataQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND metadata->>$2=$3` + insertGhostQuery = ` + INSERT INTO ghost ( + bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, + name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ` + updateGhostQuery = ` + UPDATE ghost SET name=$3, avatar_id=$4, avatar_hash=$5, avatar_mxc=$6, + name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10, + identifiers=$11, extra_profile=$12, metadata=$13 + WHERE bridge_id=$1 AND id=$2 + ` +) + +func (gq *GhostQuery) GetByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { + return gq.QueryOne(ctx, getGhostByIDQuery, gq.BridgeID, id) +} + +// GetByMetadata returns the ghosts whose metadata field at the given JSON key +// matches the given value. +func (gq *GhostQuery) GetByMetadata(ctx context.Context, key string, value any) ([]*Ghost, error) { + return gq.QueryMany(ctx, getGhostByMetadataQuery, gq.BridgeID, key, value) +} + +func (gq *GhostQuery) Insert(ctx context.Context, ghost *Ghost) error { + ensureBridgeIDMatches(&ghost.BridgeID, gq.BridgeID) + return gq.Exec(ctx, insertGhostQuery, ghost.ensureHasMetadata(gq.MetaType).sqlVariables()...) +} + +func (gq *GhostQuery) Update(ctx context.Context, ghost *Ghost) error { + ensureBridgeIDMatches(&ghost.BridgeID, gq.BridgeID) + return gq.Exec(ctx, updateGhostQuery, ghost.ensureHasMetadata(gq.MetaType).sqlVariables()...) +} + +func (g *Ghost) Scan(row dbutil.Scannable) (*Ghost, error) { + var avatarHash string + err := row.Scan( + &g.BridgeID, &g.ID, + &g.Name, &g.AvatarID, &avatarHash, &g.AvatarMXC, + &g.NameSet, &g.AvatarSet, &g.ContactInfoSet, &g.IsBot, + dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: &g.ExtraProfile}, dbutil.JSON{Data: g.Metadata}, + ) + if err != nil { + return nil, err + } + if avatarHash != "" { + data, _ := hex.DecodeString(avatarHash) + if len(data) == 32 { + g.AvatarHash = *(*[32]byte)(data) + } + } + return g, nil +} + +func (g *Ghost) ensureHasMetadata(metaType MetaTypeCreator) *Ghost { + if g.Metadata == nil { + g.Metadata = metaType() + } + return g +} + +func (g *Ghost) sqlVariables() []any { + var avatarHash string + if g.AvatarHash != [32]byte{} { + avatarHash = hex.EncodeToString(g.AvatarHash[:]) + } + return []any{ + g.BridgeID, g.ID, + g.Name, g.AvatarID, avatarHash, g.AvatarMXC, + g.NameSet, g.AvatarSet, g.ContactInfoSet, g.IsBot, + dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.ExtraProfile}, dbutil.JSON{Data: g.Metadata}, + } +} diff --git a/mautrix-patched/bridgev2/database/kvstore.go b/mautrix-patched/bridgev2/database/kvstore.go new file mode 100644 index 00000000..bca26ed5 --- /dev/null +++ b/mautrix-patched/bridgev2/database/kvstore.go @@ -0,0 +1,59 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "errors" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type Key string + +const ( + KeySplitPortalsEnabled Key = "split_portals_enabled" + KeyBridgeInfoVersion Key = "bridge_info_version" + KeyEncryptionStateResynced Key = "encryption_state_resynced" + KeyRecoveryKey Key = "recovery_key" +) + +type KVQuery struct { + BridgeID networkid.BridgeID + *dbutil.Database +} + +const ( + getKVQuery = `SELECT value FROM kv_store WHERE bridge_id = $1 AND key = $2` + setKVQuery = ` + INSERT INTO kv_store (bridge_id, key, value) VALUES ($1, $2, $3) + ON CONFLICT (bridge_id, key) DO UPDATE SET value = $3 + ` +) + +func (kvq *KVQuery) Get(ctx context.Context, key Key) string { + var value string + err := kvq.QueryRow(ctx, getKVQuery, kvq.BridgeID, key).Scan(&value) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + zerolog.Ctx(ctx).Err(err).Str("key", string(key)).Msg("Failed to get key from kvstore") + } + return value +} + +func (kvq *KVQuery) Set(ctx context.Context, key Key, value string) { + _, err := kvq.Exec(ctx, setKVQuery, kvq.BridgeID, key, value) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("key", string(key)). + Str("value", value). + Msg("Failed to set key in kvstore") + } +} diff --git a/mautrix-patched/bridgev2/database/message.go b/mautrix-patched/bridgev2/database/message.go new file mode 100644 index 00000000..d6c79fcd --- /dev/null +++ b/mautrix-patched/bridgev2/database/message.go @@ -0,0 +1,334 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "fmt" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type MessageQuery struct { + BridgeID networkid.BridgeID + MetaType MetaTypeCreator + *dbutil.QueryHelper[*Message] + chunkDeleteLock sync.Mutex +} + +type Message struct { + RowID int64 + BridgeID networkid.BridgeID + ID networkid.MessageID + PartID networkid.PartID + MXID id.EventID + + Room networkid.PortalKey + SenderID networkid.UserID + SenderMXID id.UserID + Timestamp time.Time + EditCount int + IsDoublePuppeted bool + + ThreadRoot networkid.MessageID + ReplyTo networkid.MessageOptionalPartID + + SendTxnID networkid.RawTransactionID + + Metadata any +} + +const ( + getMessageBaseQuery = ` + SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, + timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, + send_txn_id, metadata + FROM message + ` + getAllMessagePartsByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3` + getMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 AND part_id=$4` + getMessagePartByRowIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND rowid=$2` + getMessageByMXIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` + getMessageByTxnIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND (mxid=$3 OR send_txn_id=$4)` + getLastMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id DESC LIMIT 1` + getFirstMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id ASC LIMIT 1` + getMessagesBetweenTimeQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND timestamp>$4 AND timestamp<=$5` + getOldestMessageInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp ASC, id ASC, part_id ASC LIMIT 1` + getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS FIRST, timestamp ASC, id ASC, part_id ASC LIMIT 1` + getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS LAST, timestamp DESC, id DESC, part_id DESC LIMIT 1` + getLastNInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp DESC, id DESC, part_id DESC LIMIT $4` + + getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, id DESC, part_id DESC LIMIT 1` + getLastNonFakeMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 AND mxid NOT LIKE '~fake:%' ORDER BY timestamp DESC, id DESC, part_id DESC LIMIT 1` + + countMessagesInPortalQuery = ` + SELECT COUNT(*) FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 + ` + + insertMessageQuery = ` + INSERT INTO message ( + bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, + timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, + send_txn_id, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING rowid + ` + updateMessageQuery = ` + UPDATE message SET id=$2, part_id=$3, mxid=$4, room_id=$5, room_receiver=$6, sender_id=$7, sender_mxid=$8, + timestamp=$9, edit_count=$10, double_puppeted=$11, thread_root_id=$12, reply_to_id=$13, + reply_to_part_id=$14, send_txn_id=$15, metadata=$16 + WHERE bridge_id=$1 AND rowid=$17 + ` + deleteAllMessagePartsByIDQuery = ` + DELETE FROM message WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 + ` + deleteMessagePartByRowIDQuery = ` + DELETE FROM message WHERE bridge_id=$1 AND rowid=$2 + ` + deleteMessageChunkQuery = ` + DELETE FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND rowid > $4 AND rowid <= $5 + ` + getMaxMessageRowIDQuery = `SELECT MAX(rowid) FROM message WHERE bridge_id=$1` +) + +func (mq *MessageQuery) GetAllPartsByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) ([]*Message, error) { + return mq.QueryMany(ctx, getAllMessagePartsByIDQuery, mq.BridgeID, receiver, id) +} + +func (mq *MessageQuery) GetPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID, partID networkid.PartID) (*Message, error) { + return mq.QueryOne(ctx, getMessagePartByIDQuery, mq.BridgeID, receiver, id, partID) +} + +func (mq *MessageQuery) GetPartByMXID(ctx context.Context, mxid id.EventID) (*Message, error) { + return mq.QueryOne(ctx, getMessageByMXIDQuery, mq.BridgeID, mxid) +} + +func (mq *MessageQuery) GetPartByTxnID(ctx context.Context, receiver networkid.UserLoginID, mxid id.EventID, txnID networkid.RawTransactionID) (*Message, error) { + return mq.QueryOne(ctx, getMessageByTxnIDQuery, mq.BridgeID, receiver, mxid, txnID) +} + +func (mq *MessageQuery) GetLastPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) { + return mq.QueryOne(ctx, getLastMessagePartByIDQuery, mq.BridgeID, receiver, id) +} + +func (mq *MessageQuery) GetFirstPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) { + return mq.QueryOne(ctx, getFirstMessagePartByIDQuery, mq.BridgeID, receiver, id) +} + +func (mq *MessageQuery) GetByRowID(ctx context.Context, rowID int64) (*Message, error) { + return mq.QueryOne(ctx, getMessagePartByRowIDQuery, mq.BridgeID, rowID) +} + +func (mq *MessageQuery) GetFirstOrSpecificPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageOptionalPartID) (*Message, error) { + if id.PartID == nil { + return mq.GetFirstPartByID(ctx, receiver, id.MessageID) + } else { + return mq.GetPartByID(ctx, receiver, id.MessageID, *id.PartID) + } +} + +func (mq *MessageQuery) GetLastPartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) { + return mq.QueryOne(ctx, getLastMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano()) +} + +func (mq *MessageQuery) GetLastNonFakePartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) { + return mq.QueryOne(ctx, getLastNonFakeMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano()) +} + +func (mq *MessageQuery) GetMessagesBetweenTimeQuery(ctx context.Context, portal networkid.PortalKey, start, end time.Time) ([]*Message, error) { + return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, start.UnixNano(), end.UnixNano()) +} + +func (mq *MessageQuery) GetFirstPortalMessage(ctx context.Context, portal networkid.PortalKey) (*Message, error) { + return mq.QueryOne(ctx, getOldestMessageInPortal, mq.BridgeID, portal.ID, portal.Receiver) +} + +func (mq *MessageQuery) GetFirstThreadMessage(ctx context.Context, portal networkid.PortalKey, threadRoot networkid.MessageID) (*Message, error) { + return mq.QueryOne(ctx, getFirstMessageInThread, mq.BridgeID, portal.ID, portal.Receiver, threadRoot) +} + +func (mq *MessageQuery) GetLastThreadMessage(ctx context.Context, portal networkid.PortalKey, threadRoot networkid.MessageID) (*Message, error) { + return mq.QueryOne(ctx, getLastMessageInThread, mq.BridgeID, portal.ID, portal.Receiver, threadRoot) +} + +func (mq *MessageQuery) GetLastNInPortal(ctx context.Context, portal networkid.PortalKey, n int) ([]*Message, error) { + return mq.QueryMany(ctx, getLastNInPortal, mq.BridgeID, portal.ID, portal.Receiver, n) +} + +func (mq *MessageQuery) Insert(ctx context.Context, msg *Message) error { + ensureBridgeIDMatches(&msg.BridgeID, mq.BridgeID) + return mq.GetDB().QueryRow(ctx, insertMessageQuery, msg.ensureHasMetadata(mq.MetaType).sqlVariables()...).Scan(&msg.RowID) +} + +func (mq *MessageQuery) Update(ctx context.Context, msg *Message) error { + ensureBridgeIDMatches(&msg.BridgeID, mq.BridgeID) + return mq.Exec(ctx, updateMessageQuery, msg.ensureHasMetadata(mq.MetaType).updateSQLVariables()...) +} + +func (mq *MessageQuery) DeleteAllParts(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) error { + return mq.Exec(ctx, deleteAllMessagePartsByIDQuery, mq.BridgeID, receiver, id) +} + +func (mq *MessageQuery) Delete(ctx context.Context, rowID int64) error { + return mq.Exec(ctx, deleteMessagePartByRowIDQuery, mq.BridgeID, rowID) +} + +func (mq *MessageQuery) deleteChunk(ctx context.Context, portal networkid.PortalKey, minRowID, maxRowID int64) (int64, error) { + res, err := mq.GetDB().Exec(ctx, deleteMessageChunkQuery, mq.BridgeID, portal.ID, portal.Receiver, minRowID, maxRowID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (mq *MessageQuery) getMaxRowID(ctx context.Context) (maxRowID int64, err error) { + err = mq.GetDB().QueryRow(ctx, getMaxMessageRowIDQuery, mq.BridgeID).Scan(&maxRowID) + return +} + +const deleteChunkSize = 100_000 + +func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.PortalKey) error { + if mq.GetDB().Dialect != dbutil.SQLite { + return nil + } + log := zerolog.Ctx(ctx).With(). + Str("action", "delete messages in chunks"). + Stringer("portal_key", portal). + Logger() + if !mq.chunkDeleteLock.TryLock() { + log.Warn().Msg("Portal deletion lock is being held, waiting...") + mq.chunkDeleteLock.Lock() + log.Debug().Msg("Acquired portal deletion lock after waiting") + } + defer mq.chunkDeleteLock.Unlock() + total, err := mq.CountMessagesInPortal(ctx, portal) + if err != nil { + return fmt.Errorf("failed to count messages in portal: %w", err) + } else if total < deleteChunkSize/3 { + return nil + } + globalMaxRowID, err := mq.getMaxRowID(ctx) + if err != nil { + return fmt.Errorf("failed to get max row ID: %w", err) + } + log.Debug(). + Int("total_count", total). + Int64("global_max_row_id", globalMaxRowID). + Msg("Portal has lots of messages, deleting in chunks to avoid database locks") + maxRowID := int64(deleteChunkSize) + globalMaxRowID += deleteChunkSize * 1.2 + var dbTimeUsed time.Duration + globalStart := time.Now() + for total > 500 && maxRowID < globalMaxRowID { + start := time.Now() + count, err := mq.deleteChunk(ctx, portal, maxRowID-deleteChunkSize, maxRowID) + duration := time.Since(start) + dbTimeUsed += duration + if err != nil { + return fmt.Errorf("failed to delete chunk of messages before %d: %w", maxRowID, err) + } + total -= int(count) + maxRowID += deleteChunkSize + sleepTime := max(10*time.Millisecond, min(250*time.Millisecond, time.Duration(count/100)*time.Millisecond)) + log.Debug(). + Int64("max_row_id", maxRowID). + Int64("deleted_count", count). + Int("remaining_count", total). + Dur("duration", duration). + Dur("sleep_time", sleepTime). + Msg("Deleted chunk of messages") + select { + case <-time.After(sleepTime): + case <-ctx.Done(): + return ctx.Err() + } + } + log.Debug(). + Int("remaining_count", total). + Dur("db_time_used", dbTimeUsed). + Dur("total_duration", time.Since(globalStart)). + Msg("Finished chunked delete of messages in portal") + return nil +} + +func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid.PortalKey) (count int, err error) { + err = mq.GetDB().QueryRow(ctx, countMessagesInPortalQuery, mq.BridgeID, key.ID, key.Receiver).Scan(&count) + return +} + +func (m *Message) Scan(row dbutil.Scannable) (*Message, error) { + var timestamp int64 + var threadRootID, replyToID, replyToPartID, sendTxnID sql.NullString + var doublePuppeted sql.NullBool + err := row.Scan( + &m.RowID, &m.BridgeID, &m.ID, &m.PartID, &m.MXID, &m.Room.ID, &m.Room.Receiver, &m.SenderID, &m.SenderMXID, + ×tamp, &m.EditCount, &doublePuppeted, &threadRootID, &replyToID, &replyToPartID, &sendTxnID, + dbutil.JSON{Data: m.Metadata}, + ) + if err != nil { + return nil, err + } + m.Timestamp = time.Unix(0, timestamp) + m.ThreadRoot = networkid.MessageID(threadRootID.String) + m.IsDoublePuppeted = doublePuppeted.Valid + if replyToID.Valid { + m.ReplyTo.MessageID = networkid.MessageID(replyToID.String) + if replyToPartID.Valid { + m.ReplyTo.PartID = (*networkid.PartID)(&replyToPartID.String) + } + } + if sendTxnID.Valid { + m.SendTxnID = networkid.RawTransactionID(sendTxnID.String) + } + return m, nil +} + +func (m *Message) ensureHasMetadata(metaType MetaTypeCreator) *Message { + if m.Metadata == nil { + m.Metadata = metaType() + } + return m +} + +func (m *Message) sqlVariables() []any { + return []any{ + m.BridgeID, m.ID, m.PartID, m.MXID, m.Room.ID, m.Room.Receiver, m.SenderID, m.SenderMXID, + m.Timestamp.UnixNano(), m.EditCount, m.IsDoublePuppeted, dbutil.StrPtr(m.ThreadRoot), + dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID, dbutil.StrPtr(m.SendTxnID), + dbutil.JSON{Data: m.Metadata}, + } +} + +func (m *Message) updateSQLVariables() []any { + return append(m.sqlVariables(), m.RowID) +} + +const FakeMXIDPrefix = "~fake:" +const TxnMXIDPrefix = "~txn:" +const NetworkTxnMXIDPrefix = TxnMXIDPrefix + "network:" +const RandomTxnMXIDPrefix = TxnMXIDPrefix + "random:" + +func (m *Message) SetFakeMXID() { + hash := sha256.Sum256([]byte(m.ID)) + m.MXID = id.EventID(FakeMXIDPrefix + base64.RawURLEncoding.EncodeToString(hash[:])) +} + +func (m *Message) HasFakeMXID() bool { + return strings.HasPrefix(m.MXID.String(), FakeMXIDPrefix) +} diff --git a/mautrix-patched/bridgev2/database/portal.go b/mautrix-patched/bridgev2/database/portal.go new file mode 100644 index 00000000..0e6be286 --- /dev/null +++ b/mautrix-patched/bridgev2/database/portal.go @@ -0,0 +1,296 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "encoding/hex" + "errors" + "time" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type RoomType string + +const ( + RoomTypeDefault RoomType = "" + RoomTypeDM RoomType = "dm" + RoomTypeGroupDM RoomType = "group_dm" + RoomTypeSpace RoomType = "space" +) + +type PortalQuery struct { + BridgeID networkid.BridgeID + MetaType MetaTypeCreator + *dbutil.QueryHelper[*Portal] +} + +type CapStateFlags uint32 + +func (csf CapStateFlags) Has(flag CapStateFlags) bool { + return csf&flag != 0 +} + +const ( + CapStateFlagDisappearingTimerSet CapStateFlags = 1 << iota +) + +type CapabilityState struct { + Source networkid.UserLoginID `json:"source"` + ID string `json:"id"` + Flags CapStateFlags `json:"flags"` +} + +type Portal struct { + BridgeID networkid.BridgeID + networkid.PortalKey + MXID id.RoomID + + ParentKey networkid.PortalKey + RelayLoginID networkid.UserLoginID + OtherUserID networkid.UserID + Name string + Topic string + AvatarID networkid.AvatarID + AvatarHash [32]byte + AvatarMXC id.ContentURIString + NameSet bool + TopicSet bool + AvatarSet bool + NameIsCustom bool + InSpace bool + MessageRequest bool + RoomType RoomType + Disappear DisappearingSetting + CapState CapabilityState + Metadata any +} + +const ( + getPortalBaseQuery = ` + SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_login_id, other_user_id, + name, topic, avatar_id, avatar_hash, avatar_mxc, + name_set, topic_set, avatar_set, name_is_custom, in_space, message_request, + room_type, disappear_type, disappear_timer, cap_state, + metadata + FROM portal + ` + getPortalByKeyQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND receiver=$3` + getPortalByIDWithUncertainReceiverQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='')` + getPortalByMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` + getAllPortalsWithMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid IS NOT NULL` + getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND (receiver='' OR (parent_id<>'' AND parent_receiver='')) ORDER BY parent_id DESC` + getAllDMPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND other_user_id=$2` + getDMPortalQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND receiver=$2 AND other_user_id=$3` + getAllPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1` + getChildPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND parent_id=$2 AND parent_receiver=$3` + + findPortalReceiverQuery = `SELECT id, receiver FROM portal WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='') LIMIT 1` + + insertPortalQuery = ` + INSERT INTO portal ( + bridge_id, id, receiver, mxid, + parent_id, parent_receiver, relay_login_id, other_user_id, + name, topic, avatar_id, avatar_hash, avatar_mxc, + name_set, avatar_set, topic_set, name_is_custom, in_space, message_request, + room_type, disappear_type, disappear_timer, cap_state, + metadata, relay_bridge_id + ) VALUES ( + $1, $2, $3, $4, $5, $6, cast($7 AS TEXT), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, + CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE $1 END + ) + ` + updatePortalQuery = ` + UPDATE portal + SET mxid=$4, parent_id=$5, parent_receiver=$6, + relay_login_id=cast($7 AS TEXT), relay_bridge_id=CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE bridge_id END, + other_user_id=$8, name=$9, topic=$10, avatar_id=$11, avatar_hash=$12, avatar_mxc=$13, + name_set=$14, avatar_set=$15, topic_set=$16, name_is_custom=$17, in_space=$18, message_request=$19, + room_type=$20, disappear_type=$21, disappear_timer=$22, cap_state=$23, metadata=$24 + WHERE bridge_id=$1 AND id=$2 AND receiver=$3 + ` + deletePortalQuery = ` + DELETE FROM portal + WHERE bridge_id=$1 AND id=$2 AND receiver=$3 + ` + reIDPortalQuery = `UPDATE portal SET id=$4, receiver=$5 WHERE bridge_id=$1 AND id=$2 AND receiver=$3` + migrateToSplitPortalsQuery = ` + UPDATE portal + SET receiver=new_receiver + FROM ( + SELECT bridge_id, id, COALESCE(( + SELECT login_id + FROM user_portal + WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver='' + LIMIT 1 + ), ( + SELECT login_id + FROM user_portal + WHERE portal.parent_id<>'' AND bridge_id=portal.bridge_id AND portal_id=portal.parent_id + LIMIT 1 + ), ( + SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1 + ), '') AS new_receiver + FROM portal + WHERE receiver='' AND bridge_id=$1 + ) updates + WHERE portal.bridge_id=updates.bridge_id AND portal.id=updates.id AND portal.receiver='' AND NOT EXISTS ( + SELECT 1 FROM portal p2 WHERE p2.bridge_id=updates.bridge_id AND p2.id=updates.id AND p2.receiver=updates.new_receiver + ) + ` + fixParentsAfterSplitPortalMigrationQuery = ` + UPDATE portal + SET parent_receiver=receiver + WHERE bridge_id=$1 AND parent_receiver='' AND receiver<>'' AND parent_id<>'' + AND EXISTS(SELECT 1 FROM portal pp WHERE pp.bridge_id=$1 AND pp.id=portal.parent_id AND pp.receiver=portal.receiver); + ` +) + +func (pq *PortalQuery) GetByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { + return pq.QueryOne(ctx, getPortalByKeyQuery, pq.BridgeID, key.ID, key.Receiver) +} + +func (pq *PortalQuery) FindReceiver(ctx context.Context, id networkid.PortalID, maybeReceiver networkid.UserLoginID) (key networkid.PortalKey, err error) { + err = pq.GetDB().QueryRow(ctx, findPortalReceiverQuery, pq.BridgeID, id, maybeReceiver).Scan(&key.ID, &key.Receiver) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return +} + +func (pq *PortalQuery) GetByIDWithUncertainReceiver(ctx context.Context, key networkid.PortalKey) (*Portal, error) { + return pq.QueryOne(ctx, getPortalByIDWithUncertainReceiverQuery, pq.BridgeID, key.ID, key.Receiver) +} + +func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { + return pq.QueryOne(ctx, getPortalByMXIDQuery, pq.BridgeID, mxid) +} + +func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery, pq.BridgeID) +} + +func (pq *PortalQuery) GetAllWithoutReceiver(ctx context.Context) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllPortalsWithoutReceiver, pq.BridgeID) +} + +func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllPortalsQuery, pq.BridgeID) +} + +func (pq *PortalQuery) GetAllDMsWith(ctx context.Context, otherUserID networkid.UserID) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllDMPortalsQuery, pq.BridgeID, otherUserID) +} + +func (pq *PortalQuery) GetDM(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { + return pq.QueryOne(ctx, getDMPortalQuery, pq.BridgeID, receiver, otherUserID) +} + +func (pq *PortalQuery) GetChildren(ctx context.Context, parentKey networkid.PortalKey) ([]*Portal, error) { + return pq.QueryMany(ctx, getChildPortalsQuery, pq.BridgeID, parentKey.ID, parentKey.Receiver) +} + +func (pq *PortalQuery) ReID(ctx context.Context, oldID, newID networkid.PortalKey) error { + return pq.Exec(ctx, reIDPortalQuery, pq.BridgeID, oldID.ID, oldID.Receiver, newID.ID, newID.Receiver) +} + +func (pq *PortalQuery) Insert(ctx context.Context, p *Portal) error { + ensureBridgeIDMatches(&p.BridgeID, pq.BridgeID) + return pq.Exec(ctx, insertPortalQuery, p.ensureHasMetadata(pq.MetaType).sqlVariables()...) +} + +func (pq *PortalQuery) Update(ctx context.Context, p *Portal) error { + ensureBridgeIDMatches(&p.BridgeID, pq.BridgeID) + return pq.Exec(ctx, updatePortalQuery, p.ensureHasMetadata(pq.MetaType).sqlVariables()...) +} + +func (pq *PortalQuery) Delete(ctx context.Context, key networkid.PortalKey) error { + return pq.Exec(ctx, deletePortalQuery, pq.BridgeID, key.ID, key.Receiver) +} + +func (pq *PortalQuery) MigrateToSplitPortals(ctx context.Context) (int64, error) { + res, err := pq.GetDB().Exec(ctx, migrateToSplitPortalsQuery, pq.BridgeID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (pq *PortalQuery) FixParentsAfterSplitPortalMigration(ctx context.Context) (int64, error) { + res, err := pq.GetDB().Exec(ctx, fixParentsAfterSplitPortalMigrationQuery, pq.BridgeID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { + var mxid, parentID, parentReceiver, relayLoginID, otherUserID, disappearType sql.NullString + var disappearTimer sql.NullInt64 + var avatarHash string + err := row.Scan( + &p.BridgeID, &p.ID, &p.Receiver, &mxid, + &parentID, &parentReceiver, &relayLoginID, &otherUserID, + &p.Name, &p.Topic, &p.AvatarID, &avatarHash, &p.AvatarMXC, + &p.NameSet, &p.TopicSet, &p.AvatarSet, &p.NameIsCustom, &p.InSpace, &p.MessageRequest, + &p.RoomType, &disappearType, &disappearTimer, + dbutil.JSON{Data: &p.CapState}, dbutil.JSON{Data: p.Metadata}, + ) + if err != nil { + return nil, err + } + if avatarHash != "" { + data, _ := hex.DecodeString(avatarHash) + if len(data) == 32 { + p.AvatarHash = *(*[32]byte)(data) + } + } + if disappearType.Valid { + p.Disappear = DisappearingSetting{ + Type: event.DisappearingType(disappearType.String), + Timer: time.Duration(disappearTimer.Int64), + } + } + p.MXID = id.RoomID(mxid.String) + p.OtherUserID = networkid.UserID(otherUserID.String) + if parentID.Valid { + p.ParentKey = networkid.PortalKey{ + ID: networkid.PortalID(parentID.String), + Receiver: networkid.UserLoginID(parentReceiver.String), + } + } + p.RelayLoginID = networkid.UserLoginID(relayLoginID.String) + return p, nil +} + +func (p *Portal) ensureHasMetadata(metaType MetaTypeCreator) *Portal { + if p.Metadata == nil { + p.Metadata = metaType() + } + return p +} + +func (p *Portal) sqlVariables() []any { + var avatarHash string + if p.AvatarHash != [32]byte{} { + avatarHash = hex.EncodeToString(p.AvatarHash[:]) + } + return []any{ + p.BridgeID, p.ID, p.Receiver, dbutil.StrPtr(p.MXID), + dbutil.StrPtr(p.ParentKey.ID), p.ParentKey.Receiver, dbutil.StrPtr(p.RelayLoginID), dbutil.StrPtr(p.OtherUserID), + p.Name, p.Topic, p.AvatarID, avatarHash, p.AvatarMXC, + p.NameSet, p.TopicSet, p.AvatarSet, p.NameIsCustom, p.InSpace, p.MessageRequest, + p.RoomType, dbutil.StrPtr(p.Disappear.Type), dbutil.NumPtr(p.Disappear.Timer), + dbutil.JSON{Data: p.CapState}, dbutil.JSON{Data: p.Metadata}, + } +} diff --git a/mautrix-patched/bridgev2/database/publicmedia.go b/mautrix-patched/bridgev2/database/publicmedia.go new file mode 100644 index 00000000..b667399c --- /dev/null +++ b/mautrix-patched/bridgev2/database/publicmedia.go @@ -0,0 +1,72 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "time" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/id" +) + +type PublicMediaQuery struct { + BridgeID networkid.BridgeID + *dbutil.QueryHelper[*PublicMedia] +} + +type PublicMedia struct { + BridgeID networkid.BridgeID + PublicID string + MXC id.ContentURI + Keys *attachment.EncryptedFile + MimeType string + Expiry time.Time +} + +const ( + upsertPublicMediaQuery = ` + INSERT INTO public_media (bridge_id, public_id, mxc, keys, mimetype, expiry) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (bridge_id, public_id) DO UPDATE SET expiry=EXCLUDED.expiry + ` + getPublicMediaQuery = ` + SELECT bridge_id, public_id, mxc, keys, mimetype, expiry + FROM public_media WHERE bridge_id=$1 AND public_id=$2 + ` +) + +func (pmq *PublicMediaQuery) Put(ctx context.Context, pm *PublicMedia) error { + ensureBridgeIDMatches(&pm.BridgeID, pmq.BridgeID) + return pmq.Exec(ctx, upsertPublicMediaQuery, pm.sqlVariables()...) +} + +func (pmq *PublicMediaQuery) Get(ctx context.Context, publicID string) (*PublicMedia, error) { + return pmq.QueryOne(ctx, getPublicMediaQuery, pmq.BridgeID, publicID) +} + +func (pm *PublicMedia) Scan(row dbutil.Scannable) (*PublicMedia, error) { + var expiry sql.NullInt64 + var mimetype sql.NullString + err := row.Scan(&pm.BridgeID, &pm.PublicID, &pm.MXC, dbutil.JSON{Data: &pm.Keys}, &mimetype, &expiry) + if err != nil { + return nil, err + } + if expiry.Valid { + pm.Expiry = time.Unix(0, expiry.Int64) + } + pm.MimeType = mimetype.String + return pm, nil +} + +func (pm *PublicMedia) sqlVariables() []any { + return []any{pm.BridgeID, pm.PublicID, &pm.MXC, dbutil.JSONPtr(pm.Keys), dbutil.StrPtr(pm.MimeType), dbutil.ConvertedPtr(pm.Expiry, time.Time.UnixNano)} +} diff --git a/mautrix-patched/bridgev2/database/reaction.go b/mautrix-patched/bridgev2/database/reaction.go new file mode 100644 index 00000000..b65a5c38 --- /dev/null +++ b/mautrix-patched/bridgev2/database/reaction.go @@ -0,0 +1,120 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "time" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type ReactionQuery struct { + BridgeID networkid.BridgeID + MetaType MetaTypeCreator + *dbutil.QueryHelper[*Reaction] +} + +type Reaction struct { + BridgeID networkid.BridgeID + Room networkid.PortalKey + MessageID networkid.MessageID + MessagePartID networkid.PartID + SenderID networkid.UserID + SenderMXID id.UserID + EmojiID networkid.EmojiID + MXID id.EventID + + Timestamp time.Time + Emoji string + Metadata any +} + +const ( + getReactionBaseQuery = ` + SELECT bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, emoji, room_id, room_receiver, mxid, timestamp, metadata FROM reaction + ` + getReactionByIDQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4 AND sender_id=$5 AND emoji_id=$6` + getReactionByIDWithoutMessagePartQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND sender_id=$4 AND emoji_id=$5 ORDER BY message_part_id ASC LIMIT 1` + getAllReactionsToMessageBySenderQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND sender_id=$4 ORDER BY timestamp DESC` + getAllReactionsToMessageQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3` + getAllReactionsToMessagePartQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4` + getReactionByMXIDQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` + upsertReactionQuery = ` + INSERT INTO reaction (bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, emoji, room_id, room_receiver, mxid, timestamp, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id) + DO UPDATE SET sender_mxid=excluded.sender_mxid, mxid=excluded.mxid, timestamp=excluded.timestamp, emoji=excluded.emoji, metadata=excluded.metadata + ` + deleteReactionQuery = ` + DELETE FROM reaction WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4 AND sender_id=$5 AND emoji_id=$6 + ` +) + +func (rq *ReactionQuery) GetByID(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, messagePartID networkid.PartID, senderID networkid.UserID, emojiID networkid.EmojiID) (*Reaction, error) { + return rq.QueryOne(ctx, getReactionByIDQuery, rq.BridgeID, receiver, messageID, messagePartID, senderID, emojiID) +} + +func (rq *ReactionQuery) GetByIDWithoutMessagePart(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, senderID networkid.UserID, emojiID networkid.EmojiID) (*Reaction, error) { + return rq.QueryOne(ctx, getReactionByIDWithoutMessagePartQuery, rq.BridgeID, receiver, messageID, senderID, emojiID) +} + +func (rq *ReactionQuery) GetAllToMessageBySender(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, senderID networkid.UserID) ([]*Reaction, error) { + return rq.QueryMany(ctx, getAllReactionsToMessageBySenderQuery, rq.BridgeID, receiver, messageID, senderID) +} + +func (rq *ReactionQuery) GetAllToMessage(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID) ([]*Reaction, error) { + return rq.QueryMany(ctx, getAllReactionsToMessageQuery, rq.BridgeID, receiver, messageID) +} + +func (rq *ReactionQuery) GetAllToMessagePart(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, partID networkid.PartID) ([]*Reaction, error) { + return rq.QueryMany(ctx, getAllReactionsToMessagePartQuery, rq.BridgeID, receiver, messageID, partID) +} + +func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) { + return rq.QueryOne(ctx, getReactionByMXIDQuery, rq.BridgeID, mxid) +} + +func (rq *ReactionQuery) Upsert(ctx context.Context, reaction *Reaction) error { + ensureBridgeIDMatches(&reaction.BridgeID, rq.BridgeID) + return rq.Exec(ctx, upsertReactionQuery, reaction.ensureHasMetadata(rq.MetaType).sqlVariables()...) +} + +func (rq *ReactionQuery) Delete(ctx context.Context, reaction *Reaction) error { + ensureBridgeIDMatches(&reaction.BridgeID, rq.BridgeID) + return rq.Exec(ctx, deleteReactionQuery, reaction.BridgeID, reaction.Room.Receiver, reaction.MessageID, reaction.MessagePartID, reaction.SenderID, reaction.EmojiID) +} + +func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) { + var timestamp int64 + err := row.Scan( + &r.BridgeID, &r.MessageID, &r.MessagePartID, &r.SenderID, &r.SenderMXID, &r.EmojiID, &r.Emoji, + &r.Room.ID, &r.Room.Receiver, &r.MXID, ×tamp, dbutil.JSON{Data: r.Metadata}, + ) + if err != nil { + return nil, err + } + r.Timestamp = time.Unix(0, timestamp) + return r, nil +} + +func (r *Reaction) ensureHasMetadata(metaType MetaTypeCreator) *Reaction { + if r.Metadata == nil { + r.Metadata = metaType() + } + return r +} + +func (r *Reaction) sqlVariables() []any { + return []any{ + r.BridgeID, r.MessageID, r.MessagePartID, r.SenderID, r.SenderMXID, r.EmojiID, r.Emoji, + r.Room.ID, r.Room.Receiver, r.MXID, r.Timestamp.UnixNano(), dbutil.JSON{Data: r.Metadata}, + } +} diff --git a/mautrix-patched/bridgev2/database/upgrades/00-latest.sql b/mautrix-patched/bridgev2/database/upgrades/00-latest.sql new file mode 100644 index 00000000..e169be87 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/00-latest.sql @@ -0,0 +1,234 @@ +-- v0 -> v29 (compatible with v9+): Latest revision +CREATE TABLE "user" ( + bridge_id TEXT NOT NULL, + mxid TEXT NOT NULL, + + management_room TEXT, + access_token TEXT, + + PRIMARY KEY (bridge_id, mxid) +); + +CREATE TABLE user_login ( + bridge_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + id TEXT NOT NULL, + remote_name TEXT NOT NULL, + remote_profile jsonb, + space_room TEXT, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, id), + CONSTRAINT user_login_user_fkey FOREIGN KEY (bridge_id, user_mxid) + REFERENCES "user" (bridge_id, mxid) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE portal ( + bridge_id TEXT NOT NULL, + id TEXT NOT NULL, + receiver TEXT NOT NULL, + mxid TEXT, + + parent_id TEXT, + parent_receiver TEXT NOT NULL DEFAULT '', + + relay_bridge_id TEXT, + relay_login_id TEXT, + + other_user_id TEXT, + + name TEXT NOT NULL, + topic TEXT NOT NULL, + avatar_id TEXT NOT NULL, + avatar_hash TEXT NOT NULL, + avatar_mxc TEXT NOT NULL, + name_set BOOLEAN NOT NULL, + avatar_set BOOLEAN NOT NULL, + topic_set BOOLEAN NOT NULL, + name_is_custom BOOLEAN NOT NULL DEFAULT false, + in_space BOOLEAN NOT NULL, + message_request BOOLEAN NOT NULL DEFAULT false, + room_type TEXT NOT NULL, + disappear_type TEXT, + disappear_timer BIGINT, + cap_state jsonb, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, id, receiver), + CONSTRAINT portal_parent_fkey FOREIGN KEY (bridge_id, parent_id, parent_receiver) + -- Deletes aren't allowed to cascade here: + -- children should be re-parented or cleaned up manually + REFERENCES portal (bridge_id, id, receiver) ON UPDATE CASCADE, + CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id) + REFERENCES user_login (bridge_id, id) + ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); +CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver); + +CREATE TABLE ghost ( + bridge_id TEXT NOT NULL, + id TEXT NOT NULL, + + name TEXT NOT NULL, + avatar_id TEXT NOT NULL, + avatar_hash TEXT NOT NULL, + avatar_mxc TEXT NOT NULL, + name_set BOOLEAN NOT NULL, + avatar_set BOOLEAN NOT NULL, + contact_info_set BOOLEAN NOT NULL, + is_bot BOOLEAN NOT NULL, + identifiers jsonb NOT NULL, + extra_profile jsonb, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, id) +); + +CREATE TABLE message ( + -- Messages have an extra rowid to allow a single relates_to column with ON DELETE SET NULL + -- If the foreign key used (bridge_id, relates_to), then deleting the target column + -- would try to set bridge_id to null as well. + + -- only: sqlite (line commented) +-- rowid INTEGER PRIMARY KEY, + -- only: postgres + rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + + bridge_id TEXT NOT NULL, + id TEXT NOT NULL, + part_id TEXT NOT NULL, + mxid TEXT NOT NULL, + + room_id TEXT NOT NULL, + room_receiver TEXT NOT NULL, + sender_id TEXT NOT NULL, + sender_mxid TEXT NOT NULL, + timestamp BIGINT NOT NULL, + edit_count INTEGER NOT NULL, + double_puppeted BOOLEAN, + thread_root_id TEXT, + reply_to_id TEXT, + reply_to_part_id TEXT, + send_txn_id TEXT, + metadata jsonb NOT NULL, + + CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id) + REFERENCES ghost (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id), + CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid), + CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id) +); +CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver); + +CREATE TABLE disappearing_message ( + bridge_id TEXT NOT NULL, + mx_room TEXT NOT NULL, + mxid TEXT NOT NULL, + timestamp BIGINT NOT NULL DEFAULT 0, + type TEXT NOT NULL, + timer BIGINT NOT NULL, + disappear_at BIGINT, + + PRIMARY KEY (bridge_id, mxid), + CONSTRAINT disappearing_message_portal_fkey + FOREIGN KEY (bridge_id, mx_room) + REFERENCES portal (bridge_id, mxid) + ON DELETE CASCADE +); +CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room); + +CREATE TABLE reaction ( + bridge_id TEXT NOT NULL, + message_id TEXT NOT NULL, + message_part_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + sender_mxid TEXT NOT NULL DEFAULT '', + emoji_id TEXT NOT NULL, + room_id TEXT NOT NULL, + room_receiver TEXT NOT NULL, + mxid TEXT NOT NULL, + + timestamp BIGINT NOT NULL, + emoji TEXT NOT NULL, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id), + CONSTRAINT reaction_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id) + REFERENCES message (bridge_id, room_receiver, id, part_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_sender_fkey FOREIGN KEY (bridge_id, sender_id) + REFERENCES ghost (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_mxid_unique UNIQUE (bridge_id, mxid) +); +CREATE INDEX reaction_room_idx ON reaction (bridge_id, room_id, room_receiver); + +CREATE TABLE user_portal ( + bridge_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + login_id TEXT NOT NULL, + portal_id TEXT NOT NULL, + portal_receiver TEXT NOT NULL, + in_space BOOLEAN NOT NULL, + preferred BOOLEAN NOT NULL, + last_read BIGINT, + + PRIMARY KEY (bridge_id, user_mxid, login_id, portal_id, portal_receiver), + CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id) + REFERENCES user_login (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT user_portal_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX user_portal_login_idx ON user_portal (bridge_id, login_id); +CREATE INDEX user_portal_portal_idx ON user_portal (bridge_id, portal_id, portal_receiver); + +CREATE TABLE backfill_task ( + bridge_id TEXT NOT NULL, + portal_id TEXT NOT NULL, + portal_receiver TEXT NOT NULL, + user_login_id TEXT NOT NULL, + + batch_count INTEGER NOT NULL, + is_done BOOLEAN NOT NULL, + queue_done BOOLEAN NOT NULL DEFAULT false, + cursor TEXT, + oldest_message_id TEXT, + dispatched_at BIGINT, + completed_at BIGINT, + next_dispatch_min_ts BIGINT NOT NULL, + + PRIMARY KEY (bridge_id, portal_id, portal_receiver), + CONSTRAINT backfill_queue_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE kv_store ( + bridge_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + + PRIMARY KEY (bridge_id, key) +); + +CREATE TABLE public_media ( + bridge_id TEXT NOT NULL, + public_id TEXT NOT NULL, + mxc TEXT NOT NULL, + keys jsonb, + mimetype TEXT, + expiry BIGINT, + + PRIMARY KEY (bridge_id, public_id) +); diff --git a/mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql b/mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql new file mode 100644 index 00000000..e1425e75 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql @@ -0,0 +1,11 @@ +-- v2 (compatible with v1+): Add disappearing messages table +CREATE TABLE disappearing_message ( + bridge_id TEXT NOT NULL, + mx_room TEXT NOT NULL, + mxid TEXT NOT NULL, + type TEXT NOT NULL, + timer BIGINT NOT NULL, + disappear_at BIGINT, + + PRIMARY KEY (bridge_id, mxid) +); diff --git a/mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql b/mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql new file mode 100644 index 00000000..4ea52ac6 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql @@ -0,0 +1,13 @@ +-- v3 (compatible with v1+): Add relay column for portals (Postgres) +-- only: postgres +ALTER TABLE portal ADD COLUMN relay_bridge_id TEXT; +ALTER TABLE portal ADD COLUMN relay_login_id TEXT; +ALTER TABLE user_portal DROP CONSTRAINT user_portal_user_login_fkey; +ALTER TABLE user_login DROP CONSTRAINT user_login_pkey; +ALTER TABLE user_login ADD CONSTRAINT user_login_pkey PRIMARY KEY (bridge_id, id); +ALTER TABLE user_portal ADD CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id) + REFERENCES user_login (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE portal ADD CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id) + REFERENCES user_login (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql new file mode 100644 index 00000000..04385958 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql @@ -0,0 +1,100 @@ +-- v4 (compatible with v1+): Add relay column for portals (SQLite) +-- transaction: off +-- only: sqlite + +PRAGMA foreign_keys = OFF; +BEGIN; + +CREATE TABLE user_login_new ( + bridge_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + id TEXT NOT NULL, + space_room TEXT, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, id), + CONSTRAINT user_login_user_fkey FOREIGN KEY (bridge_id, user_mxid) + REFERENCES "user" (bridge_id, mxid) + ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO user_login_new +SELECT bridge_id, user_mxid, id, space_room, metadata +FROM user_login; + +DROP TABLE user_login; +ALTER TABLE user_login_new RENAME TO user_login; + + +CREATE TABLE user_portal_new ( + bridge_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + login_id TEXT NOT NULL, + portal_id TEXT NOT NULL, + portal_receiver TEXT NOT NULL, + in_space BOOLEAN NOT NULL, + preferred BOOLEAN NOT NULL, + last_read BIGINT, + + PRIMARY KEY (bridge_id, user_mxid, login_id, portal_id, portal_receiver), + CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id) + REFERENCES user_login (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT user_portal_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO user_portal_new +SELECT bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read +FROM user_portal; + +DROP TABLE user_portal; +ALTER TABLE user_portal_new RENAME TO user_portal; + +CREATE TABLE portal_new ( + bridge_id TEXT NOT NULL, + id TEXT NOT NULL, + receiver TEXT NOT NULL, + mxid TEXT, + + parent_id TEXT, + -- This is not accessed by the bridge, it's only used for the portal parent foreign key. + -- Parent groups are probably never DMs, so they don't need a receiver. + parent_receiver TEXT NOT NULL DEFAULT '', + + relay_bridge_id TEXT, + relay_login_id TEXT, + + name TEXT NOT NULL, + topic TEXT NOT NULL, + avatar_id TEXT NOT NULL, + avatar_hash TEXT NOT NULL, + avatar_mxc TEXT NOT NULL, + name_set BOOLEAN NOT NULL, + avatar_set BOOLEAN NOT NULL, + topic_set BOOLEAN NOT NULL, + in_space BOOLEAN NOT NULL, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, id, receiver), + CONSTRAINT portal_parent_fkey FOREIGN KEY (bridge_id, parent_id, parent_receiver) + -- Deletes aren't allowed to cascade here: + -- children should be re-parented or cleaned up manually + REFERENCES portal (bridge_id, id, receiver) ON UPDATE CASCADE, + CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id) + REFERENCES user_login (bridge_id, id) + ON DELETE SET NULL ON UPDATE CASCADE +); + +INSERT INTO portal_new +SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, NULL, NULL, + name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, in_space, metadata +FROM portal; + +DROP TABLE portal; +ALTER TABLE portal_new RENAME TO portal; + +PRAGMA foreign_key_check; +COMMIT; +PRAGMA foreign_keys = ON; diff --git a/mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql b/mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql new file mode 100644 index 00000000..1cdbcccf --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql @@ -0,0 +1,10 @@ +-- v5 (compatible with v1+): Add room_receiver to message unique key (Postgres) +-- only: postgres +ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; +ALTER TABLE reaction DROP CONSTRAINT reaction_pkey1; +ALTER TABLE reaction ADD PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id); +ALTER TABLE message DROP CONSTRAINT message_real_pkey; +ALTER TABLE message ADD CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id); +ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id) + REFERENCES message (bridge_id, room_receiver, id, part_id) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql new file mode 100644 index 00000000..b88c5052 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql @@ -0,0 +1,75 @@ +-- v6 (compatible with v1+): Add room_receiver to message unique key (SQLite) +-- transaction: off +-- only: sqlite + +PRAGMA foreign_keys = OFF; +BEGIN; + +CREATE TABLE message_new ( + rowid INTEGER PRIMARY KEY, + + bridge_id TEXT NOT NULL, + id TEXT NOT NULL, + part_id TEXT NOT NULL, + mxid TEXT NOT NULL, + + room_id TEXT NOT NULL, + room_receiver TEXT NOT NULL, + sender_id TEXT NOT NULL, + timestamp BIGINT NOT NULL, + relates_to BIGINT, + metadata jsonb NOT NULL, + + CONSTRAINT message_relation_fkey FOREIGN KEY (relates_to) + REFERENCES message (rowid) ON DELETE SET NULL, + CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id) + REFERENCES ghost (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id) +); + +INSERT INTO message_new (rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, relates_to, metadata) +SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, relates_to, metadata +FROM message; + +DROP TABLE message; +ALTER TABLE message_new RENAME TO message; + +CREATE TABLE reaction_new ( + bridge_id TEXT NOT NULL, + message_id TEXT NOT NULL, + message_part_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + emoji_id TEXT NOT NULL, + room_id TEXT NOT NULL, + room_receiver TEXT NOT NULL, + mxid TEXT NOT NULL, + + timestamp BIGINT NOT NULL, + metadata jsonb NOT NULL, + + PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id), + CONSTRAINT reaction_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id) + REFERENCES message (bridge_id, room_receiver, id, part_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_sender_fkey FOREIGN KEY (bridge_id, sender_id) + REFERENCES ghost (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO reaction_new +SELECT bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, metadata +FROM reaction; + +DROP TABLE reaction; +ALTER TABLE reaction_new RENAME TO reaction; + +PRAGMA foreign_key_check; +COMMIT; +PRAGMA foreign_keys = ON; diff --git a/mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql b/mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql new file mode 100644 index 00000000..9c4c9fd5 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql @@ -0,0 +1,4 @@ +-- v7: Add new relation columns to messages +ALTER TABLE message ADD COLUMN thread_root_id TEXT; +ALTER TABLE message ADD COLUMN reply_to_id TEXT; +ALTER TABLE message ADD COLUMN reply_to_part_id TEXT; diff --git a/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql b/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql new file mode 100644 index 00000000..284f6b0e --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql @@ -0,0 +1,3 @@ +-- v8: Drop relates_to column in messages +-- transaction: off +ALTER TABLE message DROP COLUMN relates_to; diff --git a/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql new file mode 100644 index 00000000..307a876e --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql @@ -0,0 +1,41 @@ +-- v8: Drop relates_to column in messages +-- transaction: off +PRAGMA foreign_keys = OFF; +BEGIN; + +CREATE TABLE message_new ( + rowid INTEGER PRIMARY KEY, + + bridge_id TEXT NOT NULL, + id TEXT NOT NULL, + part_id TEXT NOT NULL, + mxid TEXT NOT NULL, + + room_id TEXT NOT NULL, + room_receiver TEXT NOT NULL, + sender_id TEXT NOT NULL, + timestamp BIGINT NOT NULL, + thread_root_id TEXT, + reply_to_id TEXT, + reply_to_part_id TEXT, + metadata jsonb NOT NULL, + + CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id) + REFERENCES ghost (bridge_id, id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id) +); + +INSERT INTO message_new (rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, metadata) +SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, metadata +FROM message; + +DROP TABLE message; +ALTER TABLE message_new RENAME TO message; + +PRAGMA foreign_key_check; +COMMIT; +PRAGMA foreign_keys = ON; diff --git a/mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql b/mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql new file mode 100644 index 00000000..3f348007 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql @@ -0,0 +1,45 @@ +-- v9: Move standard metadata to separate columns +ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT ''; +UPDATE message SET sender_mxid=COALESCE((metadata->>'sender_mxid'), ''); + +ALTER TABLE message ADD COLUMN edit_count INTEGER NOT NULL DEFAULT 0; +UPDATE message SET edit_count=COALESCE(CAST((metadata->>'edit_count') AS INTEGER), 0); + +ALTER TABLE portal ADD COLUMN disappear_type TEXT; +UPDATE portal SET disappear_type=(metadata->>'disappear_type'); + +ALTER TABLE portal ADD COLUMN disappear_timer BIGINT; +-- only: postgres +UPDATE portal SET disappear_timer=(metadata->>'disappear_timer')::BIGINT; +-- only: sqlite +UPDATE portal SET disappear_timer=CAST(metadata->>'disappear_timer' AS INTEGER); + +ALTER TABLE portal ADD COLUMN room_type TEXT NOT NULL DEFAULT ''; +UPDATE portal SET room_type='dm' WHERE CAST(metadata->>'is_direct' AS BOOLEAN) IS true; +UPDATE portal SET room_type='space' WHERE CAST(metadata->>'is_space' AS BOOLEAN) IS true; + +ALTER TABLE reaction ADD COLUMN emoji TEXT NOT NULL DEFAULT ''; +UPDATE reaction SET emoji=COALESCE((metadata->>'emoji'), ''); + +ALTER TABLE user_login ADD COLUMN remote_name TEXT NOT NULL DEFAULT ''; +UPDATE user_login SET remote_name=COALESCE((metadata->>'remote_name'), ''); + +ALTER TABLE ghost ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false; +UPDATE ghost SET contact_info_set=COALESCE(CAST((metadata->>'contact_info_set') AS BOOLEAN), false); + +ALTER TABLE ghost ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false; +UPDATE ghost SET is_bot=COALESCE(CAST((metadata->>'is_bot') AS BOOLEAN), false); + +ALTER TABLE ghost ADD COLUMN identifiers jsonb NOT NULL DEFAULT '[]'; +UPDATE ghost SET identifiers=COALESCE((metadata->'identifiers'), '[]'); + +-- only: postgres until "end only" +ALTER TABLE message ALTER COLUMN sender_mxid DROP DEFAULT; +ALTER TABLE message ALTER COLUMN edit_count DROP DEFAULT; +ALTER TABLE portal ALTER COLUMN room_type DROP DEFAULT; +ALTER TABLE reaction ALTER COLUMN emoji DROP DEFAULT; +ALTER TABLE user_login ALTER COLUMN remote_name DROP DEFAULT; +ALTER TABLE ghost ALTER COLUMN contact_info_set DROP DEFAULT; +ALTER TABLE ghost ALTER COLUMN is_bot DROP DEFAULT; +ALTER TABLE ghost ALTER COLUMN identifiers DROP DEFAULT; +-- end only postgres diff --git a/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql b/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql new file mode 100644 index 00000000..f42402f3 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql @@ -0,0 +1,4 @@ +-- v10 (compatible with v9+): Fix Signal portal revisions +UPDATE portal +SET metadata=jsonb_set(metadata, '{revision}', CAST((metadata->>'revision') AS jsonb)) +WHERE jsonb_typeof(metadata->'revision')='string'; diff --git a/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql new file mode 100644 index 00000000..0fd67c80 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql @@ -0,0 +1,4 @@ +-- v10 (compatible with v9+): Fix Signal portal revisions +UPDATE portal +SET metadata=json_set(metadata, '$.revision', CAST(json_extract(metadata, '$.revision') AS INTEGER)) +WHERE json_type(metadata, '$.revision')='text'; diff --git a/mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql b/mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql new file mode 100644 index 00000000..d6a67713 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql @@ -0,0 +1,5 @@ +-- v11: Add indexes for some foreign keys +CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver); +CREATE INDEX reaction_room_idx ON reaction (bridge_id, room_id, room_receiver); +CREATE INDEX user_portal_portal_idx ON user_portal (bridge_id, portal_id, portal_receiver); +CREATE INDEX user_portal_login_idx ON user_portal (bridge_id, login_id); diff --git a/mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql b/mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql new file mode 100644 index 00000000..2d2cb900 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql @@ -0,0 +1,2 @@ +-- v12 (compatible with v9+): Save other user ID in DM portals +ALTER TABLE portal ADD COLUMN other_user_id TEXT; diff --git a/mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql b/mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql new file mode 100644 index 00000000..dada993c --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql @@ -0,0 +1,20 @@ +-- v13 (compatible with v9+): Add backfill queue +CREATE TABLE backfill_task ( + bridge_id TEXT NOT NULL, + portal_id TEXT NOT NULL, + portal_receiver TEXT NOT NULL, + user_login_id TEXT NOT NULL, + + batch_count INTEGER NOT NULL, + is_done BOOLEAN NOT NULL, + cursor TEXT, + oldest_message_id TEXT, + dispatched_at BIGINT, + completed_at BIGINT, + next_dispatch_min_ts BIGINT NOT NULL, + + PRIMARY KEY (bridge_id, portal_id, portal_receiver), + CONSTRAINT backfill_queue_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) + REFERENCES portal (bridge_id, id, receiver) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql b/mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql new file mode 100644 index 00000000..2c8dfc8f --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql @@ -0,0 +1,2 @@ +-- v14 (compatible with v9+): Save whether name is custom in portals +ALTER TABLE portal ADD COLUMN name_is_custom BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql b/mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql new file mode 100644 index 00000000..e32bd832 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql @@ -0,0 +1,2 @@ +-- v15 (compatible with v9+): Save sender MXID for reactions +ALTER TABLE reaction ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT ''; diff --git a/mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql b/mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql new file mode 100644 index 00000000..e143fcee --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql @@ -0,0 +1,2 @@ +-- v16 (compatible with v9+): Save remote profile in user logins +ALTER TABLE user_login ADD COLUMN remote_profile jsonb; diff --git a/mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql b/mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql new file mode 100644 index 00000000..ee53b3f0 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql @@ -0,0 +1,8 @@ +-- v17 (compatible with v9+): Add unique constraint for message and reaction mxids +DELETE FROM message WHERE mxid IN (SELECT mxid FROM message GROUP BY mxid HAVING COUNT(*) > 1); +-- only: postgres for next 2 lines +ALTER TABLE message ADD CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid); +ALTER TABLE reaction ADD CONSTRAINT reaction_mxid_unique UNIQUE (bridge_id, mxid); +-- only: sqlite for next 2 lines +CREATE UNIQUE INDEX message_mxid_unique ON message (bridge_id, mxid); +CREATE UNIQUE INDEX reaction_mxid_unique ON reaction (bridge_id, mxid); diff --git a/mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql b/mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql new file mode 100644 index 00000000..9d233095 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql @@ -0,0 +1,8 @@ +-- v18 (compatible with v9+): Add generic key-value store +CREATE TABLE kv_store ( + bridge_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + + PRIMARY KEY (bridge_id, key) +); diff --git a/mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql b/mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql new file mode 100644 index 00000000..ec6fe836 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql @@ -0,0 +1,2 @@ +-- v19 (compatible with v9+): Add double puppeted state to messages +ALTER TABLE message ADD COLUMN double_puppeted BOOLEAN; diff --git a/mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql b/mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql new file mode 100644 index 00000000..00bd96ca --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql @@ -0,0 +1,2 @@ +-- v20 (compatible with v9+): Add portal capability state +ALTER TABLE portal ADD COLUMN cap_state jsonb; diff --git a/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql b/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql new file mode 100644 index 00000000..d1c1ad9a --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql @@ -0,0 +1,8 @@ +-- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid +CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); +DELETE FROM disappearing_message WHERE mx_room NOT IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL); +ALTER TABLE disappearing_message + ADD CONSTRAINT disappearing_message_portal_fkey + FOREIGN KEY (bridge_id, mx_room) + REFERENCES portal (bridge_id, mxid) + ON DELETE CASCADE; diff --git a/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql new file mode 100644 index 00000000..f5468c6b --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql @@ -0,0 +1,24 @@ +-- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid +CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); +CREATE TABLE disappearing_message_new ( + bridge_id TEXT NOT NULL, + mx_room TEXT NOT NULL, + mxid TEXT NOT NULL, + type TEXT NOT NULL, + timer BIGINT NOT NULL, + disappear_at BIGINT, + + PRIMARY KEY (bridge_id, mxid), + CONSTRAINT disappearing_message_portal_fkey + FOREIGN KEY (bridge_id, mx_room) + REFERENCES portal (bridge_id, mxid) + ON DELETE CASCADE +); + +WITH portal_mxids AS (SELECT mxid FROM portal WHERE mxid IS NOT NULL) +INSERT INTO disappearing_message_new (bridge_id, mx_room, mxid, type, timer, disappear_at) +SELECT bridge_id, mx_room, mxid, type, timer, disappear_at +FROM disappearing_message WHERE mx_room IN portal_mxids; + +DROP TABLE disappearing_message; +ALTER TABLE disappearing_message_new RENAME TO disappearing_message; diff --git a/mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql b/mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql new file mode 100644 index 00000000..8933984e --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql @@ -0,0 +1,6 @@ +-- v22 (compatible with v9+): Add message send transaction ID column +ALTER TABLE message ADD COLUMN send_txn_id TEXT; +-- only: postgres +ALTER TABLE message ADD CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id); +-- only: sqlite +CREATE UNIQUE INDEX message_txn_id_unique ON message (bridge_id, room_receiver, send_txn_id); diff --git a/mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql b/mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql new file mode 100644 index 00000000..ecd00b8d --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql @@ -0,0 +1,2 @@ +-- v23 (compatible with v9+): Add event timestamp for disappearing messages +ALTER TABLE disappearing_message ADD COLUMN timestamp BIGINT NOT NULL DEFAULT 0; diff --git a/mautrix-patched/bridgev2/database/upgrades/24-public-media.sql b/mautrix-patched/bridgev2/database/upgrades/24-public-media.sql new file mode 100644 index 00000000..c4290090 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/24-public-media.sql @@ -0,0 +1,11 @@ +-- v24 (compatible with v9+): Custom URLs for public media +CREATE TABLE public_media ( + bridge_id TEXT NOT NULL, + public_id TEXT NOT NULL, + mxc TEXT NOT NULL, + keys jsonb, + mimetype TEXT, + expiry BIGINT, + + PRIMARY KEY (bridge_id, public_id) +); diff --git a/mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql b/mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql new file mode 100644 index 00000000..b9d82a7a --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql @@ -0,0 +1,2 @@ +-- v25 (compatible with v9+): Flag for message request portals +ALTER TABLE portal ADD COLUMN message_request BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql b/mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql new file mode 100644 index 00000000..ae5d8cad --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql @@ -0,0 +1,3 @@ +-- v26 (compatible with v9+): Add room index for disappearing message table and portal parents +CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room); +CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver); diff --git a/mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql b/mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql new file mode 100644 index 00000000..e8e0549a --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql @@ -0,0 +1,2 @@ +-- v27 (compatible with v9+): Add column for extra ghost profile metadata +ALTER TABLE ghost ADD COLUMN extra_profile jsonb; diff --git a/mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql b/mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql new file mode 100644 index 00000000..c21e15ea --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql @@ -0,0 +1,3 @@ +-- v28 (compatible with v9+): Add separate queue_done flag for backfill queue +ALTER TABLE backfill_task ADD COLUMN queue_done BOOLEAN NOT NULL DEFAULT false; +UPDATE backfill_task SET queue_done=true WHERE is_done=true; diff --git a/mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql b/mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql new file mode 100644 index 00000000..acb4c738 --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql @@ -0,0 +1,2 @@ +-- v29 (compatible with v9+): Clear incorrect personal filtering space values +UPDATE user_login SET space_room = NULL WHERE space_room LIKE '!management:%'; diff --git a/mautrix-patched/bridgev2/database/upgrades/upgrades.go b/mautrix-patched/bridgev2/database/upgrades/upgrades.go new file mode 100644 index 00000000..4fef472e --- /dev/null +++ b/mautrix-patched/bridgev2/database/upgrades/upgrades.go @@ -0,0 +1,22 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package upgrades + +import ( + "embed" + + "go.mau.fi/util/dbutil" +) + +var Table dbutil.UpgradeTable + +//go:embed *.sql +var rawUpgrades embed.FS + +func init() { + Table.RegisterFS(rawUpgrades) +} diff --git a/mautrix-patched/bridgev2/database/user.go b/mautrix-patched/bridgev2/database/user.go new file mode 100644 index 00000000..00eae7ca --- /dev/null +++ b/mautrix-patched/bridgev2/database/user.go @@ -0,0 +1,74 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type UserQuery struct { + BridgeID networkid.BridgeID + *dbutil.QueryHelper[*User] +} + +type User struct { + BridgeID networkid.BridgeID + MXID id.UserID + + ManagementRoom id.RoomID + AccessToken string +} + +const ( + getUserBaseQuery = ` + SELECT bridge_id, mxid, management_room, access_token FROM "user" + ` + getUserByMXIDQuery = getUserBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` + insertUserQuery = ` + INSERT INTO "user" (bridge_id, mxid, management_room, access_token) + VALUES ($1, $2, $3, $4) + ` + updateUserQuery = ` + UPDATE "user" SET management_room=$3, access_token=$4 + WHERE bridge_id=$1 AND mxid=$2 + ` +) + +func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) { + return uq.QueryOne(ctx, getUserByMXIDQuery, uq.BridgeID, userID) +} + +func (uq *UserQuery) Insert(ctx context.Context, user *User) error { + ensureBridgeIDMatches(&user.BridgeID, uq.BridgeID) + return uq.Exec(ctx, insertUserQuery, user.sqlVariables()...) +} + +func (uq *UserQuery) Update(ctx context.Context, user *User) error { + ensureBridgeIDMatches(&user.BridgeID, uq.BridgeID) + return uq.Exec(ctx, updateUserQuery, user.sqlVariables()...) +} + +func (u *User) Scan(row dbutil.Scannable) (*User, error) { + var managementRoom, accessToken sql.NullString + err := row.Scan(&u.BridgeID, &u.MXID, &managementRoom, &accessToken) + if err != nil { + return nil, err + } + u.ManagementRoom = id.RoomID(managementRoom.String) + u.AccessToken = accessToken.String + return u, nil +} + +func (u *User) sqlVariables() []any { + return []any{u.BridgeID, u.MXID, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.AccessToken)} +} diff --git a/mautrix-patched/bridgev2/database/userlogin.go b/mautrix-patched/bridgev2/database/userlogin.go new file mode 100644 index 00000000..00ff01c9 --- /dev/null +++ b/mautrix-patched/bridgev2/database/userlogin.go @@ -0,0 +1,123 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/id" +) + +type UserLoginQuery struct { + BridgeID networkid.BridgeID + MetaType MetaTypeCreator + *dbutil.QueryHelper[*UserLogin] +} + +type UserLogin struct { + BridgeID networkid.BridgeID + UserMXID id.UserID + ID networkid.UserLoginID + RemoteName string + RemoteProfile status.RemoteProfile + SpaceRoom id.RoomID + Metadata any +} + +const ( + getUserLoginBaseQuery = ` + SELECT bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata FROM user_login + ` + getLoginByIDQuery = getUserLoginBaseQuery + `WHERE bridge_id=$1 AND id=$2` + getAllUsersWithLoginsQuery = `SELECT DISTINCT user_mxid FROM user_login WHERE bridge_id=$1` + getAllLoginsForUserQuery = getUserLoginBaseQuery + `WHERE bridge_id=$1 AND user_mxid=$2` + getAllLoginsInPortalQuery = ` + SELECT ul.bridge_id, ul.user_mxid, ul.id, ul.remote_name, ul.remote_profile, ul.space_room, ul.metadata FROM user_portal + LEFT JOIN user_login ul ON user_portal.bridge_id=ul.bridge_id AND user_portal.user_mxid=ul.user_mxid AND user_portal.login_id=ul.id + WHERE user_portal.bridge_id=$1 AND user_portal.portal_id=$2 AND user_portal.portal_receiver=$3 + ` + insertUserLoginQuery = ` + INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ` + updateUserLoginQuery = ` + UPDATE user_login SET remote_name=$4, remote_profile=$5, space_room=$6, metadata=$7 + WHERE bridge_id=$1 AND user_mxid=$2 AND id=$3 + ` + deleteUserLoginQuery = ` + DELETE FROM user_login WHERE bridge_id=$1 AND id=$2 + ` +) + +func (uq *UserLoginQuery) GetByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) { + return uq.QueryOne(ctx, getLoginByIDQuery, uq.BridgeID, id) +} + +func (uq *UserLoginQuery) GetAllUserIDsWithLogins(ctx context.Context) ([]id.UserID, error) { + rows, err := uq.GetDB().Query(ctx, getAllUsersWithLoginsQuery, uq.BridgeID) + return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() +} + +func (uq *UserLoginQuery) GetAllInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserLogin, error) { + return uq.QueryMany(ctx, getAllLoginsInPortalQuery, uq.BridgeID, portal.ID, portal.Receiver) +} + +func (uq *UserLoginQuery) GetAllForUser(ctx context.Context, userID id.UserID) ([]*UserLogin, error) { + return uq.QueryMany(ctx, getAllLoginsForUserQuery, uq.BridgeID, userID) +} + +func (uq *UserLoginQuery) Insert(ctx context.Context, login *UserLogin) error { + ensureBridgeIDMatches(&login.BridgeID, uq.BridgeID) + return uq.Exec(ctx, insertUserLoginQuery, login.ensureHasMetadata(uq.MetaType).sqlVariables()...) +} + +func (uq *UserLoginQuery) Update(ctx context.Context, login *UserLogin) error { + ensureBridgeIDMatches(&login.BridgeID, uq.BridgeID) + return uq.Exec(ctx, updateUserLoginQuery, login.ensureHasMetadata(uq.MetaType).sqlVariables()...) +} + +func (uq *UserLoginQuery) Delete(ctx context.Context, loginID networkid.UserLoginID) error { + return uq.Exec(ctx, deleteUserLoginQuery, uq.BridgeID, loginID) +} + +func (u *UserLogin) Scan(row dbutil.Scannable) (*UserLogin, error) { + var spaceRoom sql.NullString + err := row.Scan( + &u.BridgeID, + &u.UserMXID, + &u.ID, + &u.RemoteName, + dbutil.JSON{Data: &u.RemoteProfile}, + &spaceRoom, + dbutil.JSON{Data: u.Metadata}, + ) + if err != nil { + return nil, err + } + u.SpaceRoom = id.RoomID(spaceRoom.String) + return u, nil +} + +func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin { + if u.Metadata == nil { + u.Metadata = metaType() + } + return u +} + +func (u *UserLogin) sqlVariables() []any { + var remoteProfile dbutil.JSON + if !u.RemoteProfile.IsZero() { + remoteProfile.Data = &u.RemoteProfile + } + return []any{u.BridgeID, u.UserMXID, u.ID, u.RemoteName, remoteProfile, dbutil.StrPtr(u.SpaceRoom), dbutil.JSON{Data: u.Metadata}} +} diff --git a/mautrix-patched/bridgev2/database/userportal.go b/mautrix-patched/bridgev2/database/userportal.go new file mode 100644 index 00000000..e928a4c7 --- /dev/null +++ b/mautrix-patched/bridgev2/database/userportal.go @@ -0,0 +1,155 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + "time" + + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type UserPortalQuery struct { + BridgeID networkid.BridgeID + *dbutil.QueryHelper[*UserPortal] +} + +type UserPortal struct { + BridgeID networkid.BridgeID + UserMXID id.UserID + LoginID networkid.UserLoginID + Portal networkid.PortalKey + InSpace *bool + Preferred *bool + LastRead time.Time +} + +const ( + getUserPortalBaseQuery = ` + SELECT bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read + FROM user_portal + ` + getUserPortalQuery = getUserPortalBaseQuery + ` + WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5 + ` + findUserLoginsOfUserByPortalIDQuery = getUserPortalBaseQuery + ` + WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$3 AND portal_receiver=$4 + ORDER BY CASE WHEN preferred THEN 0 ELSE 1 END, login_id + ` + getAllUserLoginsInPortalQuery = getUserPortalBaseQuery + ` + WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3 + ` + getAllPortalsForLoginQuery = getUserPortalBaseQuery + ` + WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 + ` + getOrCreateUserPortalQuery = ` + INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred) + VALUES ($1, $2, $3, $4, $5, false, false) + ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO UPDATE SET portal_id=user_portal.portal_id + RETURNING bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read + ` + upsertUserPortalQuery = ` + INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), COALESCE($7, false), $8) + ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO UPDATE + SET in_space=COALESCE($6, user_portal.in_space), + preferred=COALESCE($7, user_portal.preferred), + last_read=COALESCE($8, user_portal.last_read) + ` + markLoginAsPreferredQuery = ` + UPDATE user_portal SET preferred=(login_id=$3) WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$4 AND portal_receiver=$5 + ` + markAllNotInSpaceQuery = ` + UPDATE user_portal SET in_space=false WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3 + ` + deleteUserPortalQuery = ` + DELETE FROM user_portal WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5 + ` +) + +func UserPortalFor(ul *UserLogin, portal networkid.PortalKey) *UserPortal { + return &UserPortal{ + BridgeID: ul.BridgeID, + UserMXID: ul.UserMXID, + LoginID: ul.ID, + Portal: portal, + } +} + +func (upq *UserPortalQuery) GetAllForUserInPortal(ctx context.Context, userID id.UserID, portal networkid.PortalKey) ([]*UserPortal, error) { + return upq.QueryMany(ctx, findUserLoginsOfUserByPortalIDQuery, upq.BridgeID, userID, portal.ID, portal.Receiver) +} + +func (upq *UserPortalQuery) GetAllForLogin(ctx context.Context, login *UserLogin) ([]*UserPortal, error) { + return upq.QueryMany(ctx, getAllPortalsForLoginQuery, upq.BridgeID, login.UserMXID, login.ID) +} + +func (upq *UserPortalQuery) GetAllInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserPortal, error) { + return upq.QueryMany(ctx, getAllUserLoginsInPortalQuery, upq.BridgeID, portal.ID, portal.Receiver) +} + +func (upq *UserPortalQuery) Get(ctx context.Context, login *UserLogin, portal networkid.PortalKey) (*UserPortal, error) { + return upq.QueryOne(ctx, getUserPortalQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) +} + +func (upq *UserPortalQuery) GetOrCreate(ctx context.Context, login *UserLogin, portal networkid.PortalKey) (*UserPortal, error) { + return upq.QueryOne(ctx, getOrCreateUserPortalQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) +} + +func (upq *UserPortalQuery) Put(ctx context.Context, up *UserPortal) error { + ensureBridgeIDMatches(&up.BridgeID, upq.BridgeID) + return upq.Exec(ctx, upsertUserPortalQuery, up.sqlVariables()...) +} + +func (upq *UserPortalQuery) MarkAsPreferred(ctx context.Context, login *UserLogin, portal networkid.PortalKey) error { + return upq.Exec(ctx, markLoginAsPreferredQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) +} + +func (upq *UserPortalQuery) MarkAllNotInSpace(ctx context.Context, portal networkid.PortalKey) error { + return upq.Exec(ctx, markAllNotInSpaceQuery, upq.BridgeID, portal.ID, portal.Receiver) +} + +func (upq *UserPortalQuery) Delete(ctx context.Context, up *UserPortal) error { + return upq.Exec(ctx, deleteUserPortalQuery, up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver) +} + +func (up *UserPortal) Scan(row dbutil.Scannable) (*UserPortal, error) { + var lastRead sql.NullInt64 + err := row.Scan( + &up.BridgeID, &up.UserMXID, &up.LoginID, &up.Portal.ID, &up.Portal.Receiver, + &up.InSpace, &up.Preferred, &lastRead, + ) + if err != nil { + return nil, err + } + if lastRead.Valid { + up.LastRead = time.Unix(0, lastRead.Int64) + } + return up, nil +} + +func (up *UserPortal) sqlVariables() []any { + return []any{ + up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver, + up.InSpace, + up.Preferred, + dbutil.ConvertedPtr(up.LastRead, time.Time.UnixNano), + } +} + +func (up *UserPortal) CopyWithoutValues() *UserPortal { + return &UserPortal{ + BridgeID: up.BridgeID, + UserMXID: up.UserMXID, + LoginID: up.LoginID, + Portal: up.Portal, + } +} diff --git a/mautrix-patched/bridgev2/disappear.go b/mautrix-patched/bridgev2/disappear.go new file mode 100644 index 00000000..b5c37e8f --- /dev/null +++ b/mautrix-patched/bridgev2/disappear.go @@ -0,0 +1,153 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type DisappearLoop struct { + br *Bridge + nextCheck atomic.Pointer[time.Time] + stop atomic.Pointer[context.CancelFunc] +} + +const DisappearCheckInterval = 1 * time.Hour + +func (dl *DisappearLoop) Start() { + log := dl.br.Log.With().Str("component", "disappear loop").Logger() + ctx, stop := context.WithCancel(log.WithContext(context.Background())) + if oldStop := dl.stop.Swap(&stop); oldStop != nil { + (*oldStop)() + } + log.Debug().Msg("Disappearing message loop starting") + for { + nextCheck := time.Now().Add(DisappearCheckInterval) + dl.nextCheck.Store(&nextCheck) + const MessageLimit = 200 + messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval, MessageLimit) + if err != nil { + log.Err(err).Msg("Failed to get upcoming disappearing messages") + } else if len(messages) > 0 { + if len(messages) >= MessageLimit { + lastDisappearTime := messages[len(messages)-1].DisappearAt + log.Debug(). + Int("message_count", len(messages)). + Time("last_due", lastDisappearTime). + Msg("Deleting disappearing messages synchronously and checking again immediately") + // Store the expected next check time to avoid Add spawning unnecessary goroutines. + // This can be in the past, in which case Add will put everything in the db, which is also fine. + dl.nextCheck.Store(&lastDisappearTime) + // If there are many messages, process them synchronously and then check again. + dl.sleepAndDisappear(ctx, messages...) + continue + } + go dl.sleepAndDisappear(ctx, messages...) + } + select { + case <-time.After(time.Until(dl.GetNextCheck())): + case <-ctx.Done(): + log.Debug().Msg("Disappearing message loop stopping") + return + } + } +} + +func (dl *DisappearLoop) GetNextCheck() time.Time { + if dl == nil { + return time.Time{} + } + nextCheck := dl.nextCheck.Load() + if nextCheck == nil { + return time.Time{} + } + return *nextCheck +} + +func (dl *DisappearLoop) Stop() { + if dl == nil { + return + } + if stop := dl.stop.Load(); stop != nil { + (*stop)() + } +} + +func (dl *DisappearLoop) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) { + startedMessages, err := dl.br.DB.DisappearingMessage.StartAllBefore(ctx, roomID, beforeTS) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to start disappearing messages") + return + } + startedMessages = slices.DeleteFunc(startedMessages, func(dm *database.DisappearingMessage) bool { + return dm.DisappearAt.After(dl.GetNextCheck()) + }) + slices.SortFunc(startedMessages, func(a, b *database.DisappearingMessage) int { + return a.DisappearAt.Compare(b.DisappearAt) + }) + if len(startedMessages) > 0 { + go dl.sleepAndDisappear(ctx, startedMessages...) + } +} + +func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessage) { + err := dl.br.DB.DisappearingMessage.Put(ctx, dm) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("event_id", dm.EventID). + Msg("Failed to save disappearing message") + } + if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.GetNextCheck()) { + go dl.sleepAndDisappear(zerolog.Ctx(ctx).WithContext(dl.br.BackgroundCtx), dm) + } +} + +func (dl *DisappearLoop) sleepAndDisappear(ctx context.Context, dms ...*database.DisappearingMessage) { + for _, msg := range dms { + timeUntilDisappear := time.Until(msg.DisappearAt) + if timeUntilDisappear <= 0 { + if ctx.Err() != nil { + return + } + } else { + select { + case <-time.After(timeUntilDisappear): + case <-ctx.Done(): + return + } + } + resp, err := dl.br.Bot.SendMessage(ctx, msg.RoomID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: msg.EventID, + Reason: "Message disappeared", + }, + }, nil) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", msg.EventID).Msg("Failed to disappear message") + } else { + zerolog.Ctx(ctx).Debug(). + Stringer("target_event_id", msg.EventID). + Stringer("redaction_event_id", resp.EventID). + Msg("Disappeared message") + } + err = dl.br.DB.DisappearingMessage.Delete(ctx, msg.EventID) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("event_id", msg.EventID). + Msg("Failed to delete disappearing message entry from database") + } + } +} diff --git a/mautrix-patched/bridgev2/errors.go b/mautrix-patched/bridgev2/errors.go new file mode 100644 index 00000000..c7f83989 --- /dev/null +++ b/mautrix-patched/bridgev2/errors.go @@ -0,0 +1,137 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "errors" + "fmt" + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// ErrIgnoringRemoteEvent can be returned by [RemoteMessage.ConvertMessage] or [RemoteEdit.ConvertEdit] +// to indicate that the event should be ignored after all. Handling the event will be cancelled immediately. +var ErrIgnoringRemoteEvent = errors.New("ignoring remote event") + +// ErrNoStatus can be returned by [MatrixMessageResponse.HandleEcho] to indicate that the message is still in-flight +// and a status should not be sent yet. The message will still be saved into the database. +var ErrNoStatus = errors.New("omit message status") + +// ErrResolveIdentifierTryNext can be returned by ResolveIdentifier or CreateChatWithGhost to signal that +// the identifier is valid, but can't be reached by the current login, and the caller should try the next +// login if there are more. +// +// This should generally only be returned when resolving internal IDs (which happens when initiating chats via Matrix). +// For example, Google Messages would return this when trying to resolve another login's user ID, +// and Telegram would return this when the access hash isn't available. +var ErrResolveIdentifierTryNext = errors.New("that identifier is not available via this login") + +var ErrNotLoggedIn = errors.New("not logged in") + +// ErrDirectMediaNotEnabled may be returned by Matrix connectors if [MatrixConnector.GenerateContentURI] is called, +// but direct media is not enabled. +var ErrDirectMediaNotEnabled = errors.New("direct media is not enabled") + +var ErrPortalIsDeleted = errors.New("portal is deleted") +var ErrPortalNotFoundInEventHandler = errors.New("portal not found to handle remote event") +var ErrSplitPortalMigrationFailed = errors.New("failed to migrate to split portals") + +// Common message status errors +var ( + ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage() + ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringAcceptRequestRelayedUser error = WrapErrorInStatus(errors.New("ignoring accept message request event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrUnknownPoll error = WrapErrorInStatus(errors.New("vote target poll not found")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) + ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) + ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) + ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true) + ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) + ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrVoiceMessageDurationTooLong error = WrapErrorInStatus(errors.New("voice message too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) + ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) + ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) + ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) + ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) + + ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) + ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) + ErrPublicMediaGenerateFailed = WrapErrorInStatus(errors.New("failed to generate public media URL")).WithIsCertain(true).WithMessage("failed to generate public media URL").WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) + + ErrDisappearingTimerUnsupported error = WrapErrorInStatus(errors.New("invalid disappearing timer")).WithIsCertain(true) + + ErrFailedToGetIntent error = errors.New("failed to get intent for event") + ErrHandlerBackgrounded error = errors.New("event handling took too long and was backgrounded") +) + +// Common login interface errors +var ( + ErrInvalidLoginFlowID error = RespError(mautrix.MNotFound.WithMessage("Invalid login flow ID")) +) + +// RespError is a class of error that certain network interface methods can return to ensure that the error +// is properly translated into an HTTP error when the method is called via the provisioning API. +// +// However, unlike mautrix.RespError, this does not include the error code +// in the message shown to users when used outside HTTP contexts. +type RespError mautrix.RespError + +func (re RespError) Error() string { + return re.Err +} + +func (re RespError) Is(err error) bool { + var e2 RespError + if errors.As(err, &e2) { + return e2.Err == re.Err + } + return errors.Is(err, mautrix.RespError(re)) +} + +func (re RespError) Write(w http.ResponseWriter) { + mautrix.RespError(re).Write(w) +} + +func (re RespError) WithMessage(msg string, args ...any) RespError { + return RespError(mautrix.RespError(re).WithMessage(msg, args...)) +} + +func (re RespError) AppendMessage(append string, args ...any) RespError { + re.Err += fmt.Sprintf(append, args...) + return re +} + +func WrapRespErrManual(err error, code string, status int) RespError { + return RespError{ErrCode: code, Err: err.Error(), StatusCode: status} +} + +func WrapRespErr(err error, target mautrix.RespError) RespError { + return RespError{ErrCode: target.ErrCode, Err: err.Error(), StatusCode: target.StatusCode} +} diff --git a/mautrix-patched/bridgev2/ghost.go b/mautrix-patched/bridgev2/ghost.go new file mode 100644 index 00000000..22e1d2a8 --- /dev/null +++ b/mautrix-patched/bridgev2/ghost.go @@ -0,0 +1,405 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "maps" + "net/http" + "slices" + "strings" + + "github.com/rs/zerolog" + "github.com/tidwall/sjson" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exmime" + + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type Ghost struct { + *database.Ghost + Bridge *Bridge + Log zerolog.Logger + Intent MatrixAPI +} + +func (br *Bridge) loadGhost(ctx context.Context, dbGhost *database.Ghost, queryErr error, id *networkid.UserID) (*Ghost, error) { + if queryErr != nil { + return nil, fmt.Errorf("failed to query db: %w", queryErr) + } + if dbGhost == nil { + if id == nil { + return nil, nil + } + dbGhost = &database.Ghost{ + BridgeID: br.ID, + ID: *id, + } + err := br.DB.Ghost.Insert(ctx, dbGhost) + if err != nil { + return nil, fmt.Errorf("failed to insert new ghost: %w", err) + } + } + ghost := &Ghost{ + Ghost: dbGhost, + Bridge: br, + Log: br.Log.With().Str("ghost_id", string(dbGhost.ID)).Logger(), + Intent: br.Matrix.GhostIntent(dbGhost.ID), + } + br.ghostsByID[ghost.ID] = ghost + return ghost, nil +} + +func (br *Bridge) unlockedGetGhostByID(ctx context.Context, id networkid.UserID, onlyIfExists bool) (*Ghost, error) { + cached, ok := br.ghostsByID[id] + if ok { + return cached, nil + } + idPtr := &id + if onlyIfExists { + idPtr = nil + } + db, err := br.DB.Ghost.GetByID(ctx, id) + return br.loadGhost(ctx, db, err, idPtr) +} + +func (br *Bridge) IsGhostMXID(userID id.UserID) bool { + _, isGhost := br.Matrix.ParseGhostMXID(userID) + return isGhost +} + +func (br *Bridge) GetGhostByMXID(ctx context.Context, mxid id.UserID) (*Ghost, error) { + ghostID, ok := br.Matrix.ParseGhostMXID(mxid) + if !ok { + return nil, nil + } + return br.GetGhostByID(ctx, ghostID) +} + +func (br *Bridge) GetGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + ghost, err := br.unlockedGetGhostByID(ctx, id, false) + if err != nil { + return nil, err + } else if ghost == nil { + panic(fmt.Errorf("unlockedGetGhostByID(ctx, %q, false) returned nil", id)) + } + return ghost, nil +} + +func (br *Bridge) GetExistingGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.unlockedGetGhostByID(ctx, id, true) +} + +type Avatar struct { + ID networkid.AvatarID + Get func(ctx context.Context) ([]byte, error) + Remove bool + + // For pre-uploaded avatars, the MXC URI and hash can be provided directly + MXC id.ContentURIString + Hash [32]byte +} + +func (a *Avatar) Reupload(ctx context.Context, intent MatrixAPI, currentHash [32]byte, currentMXC id.ContentURIString) (id.ContentURIString, [32]byte, error) { + if a.MXC != "" || a.Hash != [32]byte{} { + return a.MXC, a.Hash, nil + } else if a.Get == nil { + return "", [32]byte{}, fmt.Errorf("no Get function provided for avatar") + } + data, err := a.Get(ctx) + if err != nil { + return "", [32]byte{}, err + } + hash := sha256.Sum256(data) + if hash == currentHash && currentMXC != "" { + return currentMXC, hash, nil + } + mime := http.DetectContentType(data) + fileName := "avatar" + exmime.ExtensionFromMimetype(mime) + uri, _, err := intent.UploadMedia(ctx, "", data, fileName, mime) + if err != nil { + return "", hash, err + } + return uri, hash, nil +} + +type UserInfo struct { + Identifiers []string + Name *string + Avatar *Avatar + IsBot *bool + ExtraProfile database.ExtraProfile + + ExtraUpdates ExtraUpdater[*Ghost] +} + +func (ghost *Ghost) prepareName(name string) bool { + if ghost.Name == name && ghost.NameSet { + return false + } + ghost.Name = name + ghost.NameSet = false + return true +} + +func (ghost *Ghost) UpdateName(ctx context.Context, name string) bool { + if !ghost.prepareName(name) { + return false + } + if err := ghost.Intent.SetDisplayName(ctx, name); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set display name") + } else { + ghost.NameSet = true + } + return true +} + +func (ghost *Ghost) prepareAvatar(ctx context.Context, avatar *Avatar) (changed, mxcChanged bool) { + if ghost.AvatarID == avatar.ID && (avatar.Remove || ghost.AvatarMXC != "") && ghost.AvatarSet { + return false, false + } + ghost.AvatarID = avatar.ID + if !avatar.Remove { + newMXC, newHash, err := avatar.Reupload(ctx, ghost.Intent, ghost.AvatarHash, ghost.AvatarMXC) + if err != nil { + ghost.AvatarSet = false + zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload avatar") + return true, false + } else if newHash == ghost.AvatarHash && ghost.AvatarMXC != "" && ghost.AvatarSet { + return true, false + } + ghost.AvatarHash = newHash + ghost.AvatarMXC = newMXC + } else { + ghost.AvatarMXC = "" + } + ghost.AvatarSet = false + return true, true +} + +func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { + changed, mxcChanged := ghost.prepareAvatar(ctx, avatar) + if !changed { + return false + } + if mxcChanged { + if err := ghost.Intent.SetAvatarURL(ctx, ghost.AvatarMXC); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar URL") + } else { + ghost.AvatarSet = true + } + } + return true +} + +func (ghost *Ghost) getExtraProfileMeta() any { + bridgeName := ghost.Bridge.Network.GetName() + baseExtra := &event.BeeperProfileExtra{ + RemoteID: string(ghost.ID), + Identifiers: ghost.Identifiers, + Service: bridgeName.BeeperBridgeType, + Network: bridgeName.NetworkID, + IsBridgeBot: false, + IsNetworkBot: ghost.IsBot, + } + if len(ghost.ExtraProfile) == 0 { + return baseExtra + } + mergedExtra := maps.Clone(ghost.ExtraProfile) + baseExtraMarshaled := exerrors.Must(json.Marshal(baseExtra)) + exerrors.PanicIfNotNil(json.Unmarshal(baseExtraMarshaled, &mergedExtra)) + return mergedExtra +} + +func (ghost *Ghost) getFullProfile() json.RawMessage { + marshaled := exerrors.Must(json.Marshal(ghost.getExtraProfileMeta())) + if ghost.Name != "" { + marshaled = exerrors.Must(sjson.SetBytes(marshaled, "displayname", ghost.Name)) + } + if ghost.AvatarMXC != "" { + marshaled = exerrors.Must(sjson.SetBytes(marshaled, "avatar_url", ghost.AvatarMXC)) + } + return marshaled +} + +func (ghost *Ghost) prepareContactInfo(identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool { + caps := ghost.Bridge.Matrix.GetCapabilities() + if !caps.ExtraProfileMeta && !caps.ReplaceEntireProfile { + ghost.ContactInfoSet = false + return false + } + if identifiers != nil { + slices.Sort(identifiers) + if !ghost.Bridge.Config.PhoneNumbersInProfile { + identifiers = slices.DeleteFunc(identifiers, func(id string) bool { + return strings.HasPrefix(id, "tel:") + }) + } + } + changed := extraProfile.CopyTo(&ghost.ExtraProfile) + if identifiers != nil { + changed = changed || !slices.Equal(identifiers, ghost.Identifiers) + ghost.Identifiers = identifiers + } + if isBot != nil { + changed = changed || *isBot != ghost.IsBot + ghost.IsBot = *isBot + } + if ghost.ContactInfoSet && !changed { + return false + } + ghost.ContactInfoSet = false + return true +} + +func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool { + if !ghost.prepareContactInfo(identifiers, isBot, extraProfile) { + return false + } + if err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta()); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") + } else { + ghost.ContactInfoSet = true + } + return true +} + +func (br *Bridge) allowAggressiveUpdateForType(evtType RemoteEventType) bool { + if !br.Network.GetCapabilities().AggressiveUpdateInfo { + return false + } + switch evtType { + case RemoteEventUnknown, RemoteEventMessage, RemoteEventEdit, RemoteEventReaction: + return true + default: + return false + } +} + +func (ghost *Ghost) UpdateInfoIfNecessary(ctx context.Context, source *UserLogin, evtType RemoteEventType) { + if ghost.Name != "" && ghost.NameSet && ghost.AvatarSet && !ghost.Bridge.allowAggressiveUpdateForType(evtType) { + return + } + info, err := source.Client.GetUserInfo(ctx, ghost) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("ghost_id", string(ghost.ID)).Msg("Failed to get info to update ghost") + } else if info != nil { + zerolog.Ctx(ctx).Debug(). + Bool("has_name", ghost.Name != ""). + Bool("name_set", ghost.NameSet). + Bool("has_avatar", ghost.AvatarMXC != ""). + Bool("avatar_set", ghost.AvatarSet). + Msg("Updating ghost info in IfNecessary call") + ghost.UpdateInfo(ctx, info) + } else { + zerolog.Ctx(ctx).Trace(). + Bool("has_name", ghost.Name != ""). + Bool("name_set", ghost.NameSet). + Bool("has_avatar", ghost.AvatarMXC != ""). + Bool("avatar_set", ghost.AvatarSet). + Msg("No ghost info received in IfNecessary call") + } +} + +func (ghost *Ghost) updateDMPortals(ctx context.Context) { + if !ghost.Bridge.Config.PrivateChatPortalMeta { + return + } + dmPortals, err := ghost.Bridge.GetDMPortalsWith(ctx, ghost.ID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM portals to update info") + return + } + for _, portal := range dmPortals { + go portal.lockedUpdateInfoFromGhost(ctx, ghost) + } +} + +func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) { + oldName := ghost.Name + oldAvatar := ghost.AvatarMXC + + nameChanged := info.Name != nil && ghost.prepareName(*info.Name) + + var avatarChanged, avatarMXCChanged bool + if info.Avatar != nil { + avatarChanged, avatarMXCChanged = ghost.prepareAvatar(ctx, info.Avatar) + } else if oldAvatar == "" && !ghost.AvatarSet { + // Special case: nil avatar means we're not expecting one ever, if we don't currently have + // one we flag it as set to avoid constantly refetching in UpdateInfoIfNecessary. + ghost.AvatarSet = true + avatarChanged = true + } + + var contactInfoChanged bool + if info.Identifiers != nil || info.IsBot != nil || info.ExtraProfile != nil { + contactInfoChanged = ghost.prepareContactInfo(info.Identifiers, info.IsBot, info.ExtraProfile) + } + + update := nameChanged || avatarChanged || contactInfoChanged + if info.ExtraUpdates != nil { + update = info.ExtraUpdates(ctx, ghost) || update + } + ghost.pushProfileChanges(ctx, nameChanged, avatarMXCChanged, contactInfoChanged) + if oldName != ghost.Name || oldAvatar != ghost.AvatarMXC { + ghost.updateDMPortals(ctx) + } + if update { + err := ghost.Bridge.DB.Ghost.Update(ctx, ghost.Ghost) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to update ghost in database after updating info") + } + } +} + +func (ghost *Ghost) pushProfileChanges(ctx context.Context, nameChanged, avatarChanged, contactInfoChanged bool) { + if !nameChanged && !avatarChanged && !contactInfoChanged { + return + } + if ghost.Bridge.Matrix.GetCapabilities().ReplaceEntireProfile { + if err := ghost.Intent.SetProfile(ctx, ghost.getFullProfile()); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set profile") + } else { + ghost.NameSet = true + ghost.AvatarSet = true + ghost.ContactInfoSet = true + } + } else { + if nameChanged { + if err := ghost.Intent.SetDisplayName(ctx, ghost.Name); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set display name") + } else { + ghost.NameSet = true + } + } + if avatarChanged { + if err := ghost.Intent.SetAvatarURL(ctx, ghost.AvatarMXC); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar URL") + } else { + ghost.AvatarSet = true + } + } + if contactInfoChanged { + if err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta()); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") + } else { + ghost.ContactInfoSet = true + } + } + } +} diff --git a/mautrix-patched/bridgev2/login.go b/mautrix-patched/bridgev2/login.go new file mode 100644 index 00000000..76b42d4d --- /dev/null +++ b/mautrix-patched/bridgev2/login.go @@ -0,0 +1,318 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +// LoginProcess represents a single occurrence of a user logging into the remote network. +type LoginProcess interface { + // Start starts the process and returns the first step. + // + // For example, a network using QR login may connect to the network, fetch a QR code, + // and return a DisplayAndWait-type step. + // + // This will only ever be called once. + Start(ctx context.Context) (*LoginStep, error) + // Cancel stops the login process and cleans up any resources. + // No other methods will be called after cancel. + // + // Cancel will not be called if any other method returned an error: + // errors are always treated as fatal and the process is assumed to be automatically cancelled. + Cancel() +} + +type LoginProcessWithOverride interface { + LoginProcess + // StartWithOverride starts the process with the intent of re-authenticating an existing login. + // + // The call to this is mutually exclusive with the call to the default Start method. + // + // The user login being overridden will still be logged out automatically + // in case the complete step returns a different login. + StartWithOverride(ctx context.Context, override *UserLogin) (*LoginStep, error) +} + +type LoginProcessDisplayAndWait interface { + LoginProcess + Wait(ctx context.Context) (*LoginStep, error) +} + +type LoginProcessUserInput interface { + LoginProcess + SubmitUserInput(ctx context.Context, input map[string]string) (*LoginStep, error) +} + +type LoginProcessCookies interface { + LoginProcess + SubmitCookies(ctx context.Context, cookies map[string]string) (*LoginStep, error) +} + +type LoginProcessWebAuthn interface { + LoginProcess + SubmitWebAuthnResponse(ctx context.Context, response json.RawMessage) (*LoginStep, error) +} + +type LoginFlow struct { + Name string `json:"name"` + Description string `json:"description"` + ID string `json:"id"` +} + +type LoginStepType string + +const ( + LoginStepTypeUserInput LoginStepType = "user_input" + LoginStepTypeCookies LoginStepType = "cookies" + LoginStepTypeDisplayAndWait LoginStepType = "display_and_wait" + LoginStepTypeWebAuthn LoginStepType = "webauthn" + LoginStepTypeComplete LoginStepType = "complete" +) + +type LoginDisplayType string + +const ( + LoginDisplayTypeQR LoginDisplayType = "qr" + LoginDisplayTypeEmoji LoginDisplayType = "emoji" + LoginDisplayTypeCode LoginDisplayType = "code" + LoginDisplayTypeNothing LoginDisplayType = "nothing" +) + +type LoginStep struct { + // The type of login step + Type LoginStepType `json:"type"` + // A unique ID for this step. The ID should be same for every login using the same flow, + // but it should be different for different bridges and step types. + // + // For example, Telegram's QR scan followed by a 2-factor password + // might use the IDs `fi.mau.telegram.qr` and `fi.mau.telegram.2fa_password`. + StepID string `json:"step_id"` + // Instructions contains human-readable instructions for completing the login step. + Instructions string `json:"instructions"` + + // Exactly one of the following structs must be filled depending on the step type. + + DisplayAndWaitParams *LoginDisplayAndWaitParams `json:"display_and_wait,omitempty"` + CookiesParams *LoginCookiesParams `json:"cookies,omitempty"` + UserInputParams *LoginUserInputParams `json:"user_input,omitempty"` + WebAuthnParams *LoginWebAuthnParams `json:"webauthn,omitempty"` + CompleteParams *LoginCompleteParams `json:"complete,omitempty"` +} + +type LoginWebAuthnParams struct { + // The origin URL where the credential should be extracted from. + URL string `json:"url,omitempty"` + + // Standard parameters for https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get + // Only publicKey seems to be used in practice, so the other options aren't allowed yet. + PublicKey json.RawMessage `json:"publicKey,omitempty"` +} + +type LoginDisplayAndWaitParams struct { + // The type of thing to display (QR, emoji or text code) + Type LoginDisplayType `json:"type"` + // The thing to display (raw data for QR, unicode emoji for emoji, plain string for code, omitted for nothing) + Data string `json:"data,omitempty"` + // An image containing the thing to display. If present, this is recommended over using data directly. + // For emojis, the URL to the canonical image representation of the emoji + ImageURL string `json:"image_url,omitempty"` +} + +type LoginCookieFieldSourceType string + +const ( + LoginCookieTypeCookie LoginCookieFieldSourceType = "cookie" + LoginCookieTypeLocalStorage LoginCookieFieldSourceType = "local_storage" + LoginCookieTypeRequestHeader LoginCookieFieldSourceType = "request_header" + LoginCookieTypeRequestBody LoginCookieFieldSourceType = "request_body" + LoginCookieTypeSpecial LoginCookieFieldSourceType = "special" +) + +type LoginCookieFieldSource struct { + // The type of source. + Type LoginCookieFieldSourceType `json:"type"` + // The name of the field. The exact meaning depends on the type of source. + // Cookie: cookie name + // Local storage: key in local storage + // Request header: header name + // Request body: field name inside body after it's parsed (as JSON or multipart form data) + // Special: a namespaced identifier that clients can implement special handling for + Name string `json:"name"` + + // For request header & body types, a regex matching request URLs where the value can be extracted from. + RequestURLRegex string `json:"request_url_regex,omitempty"` + // For cookie types, the domain the cookie is present on. + CookieDomain string `json:"cookie_domain,omitempty"` +} + +type LoginCookieField struct { + // The key in the map that is submitted to the connector. + ID string `json:"id"` + Required bool `json:"required"` + // The sources that can be used to acquire the field value. Only one of these needs to be used. + Sources []LoginCookieFieldSource `json:"sources"` + // A regex pattern that the client can use to validate value client-side. + Pattern string `json:"pattern,omitempty"` +} + +type LoginCookiesParams struct { + URL string `json:"url"` + UserAgent string `json:"user_agent,omitempty"` + + // The fields that are needed for this cookie login. + Fields []LoginCookieField `json:"fields"` + // A JavaScript snippet that can extract some or all of the fields. + // The snippet will evaluate to a promise that resolves when the relevant fields are found. + // Fields that are not present in the promise result must be extracted another way. + ExtractJS string `json:"extract_js,omitempty"` + // A regex pattern that the URL should match before the client closes the webview. + // + // The client may submit the login if the user closes the webview after all cookies are collected + // even if this URL is not reached, but it should only automatically close the webview after + // both cookies and the URL match. + WaitForURLPattern string `json:"wait_for_url_pattern,omitempty"` +} + +type LoginInputFieldType string + +const ( + LoginInputFieldTypeUsername LoginInputFieldType = "username" + LoginInputFieldTypePassword LoginInputFieldType = "password" + LoginInputFieldTypePhoneNumber LoginInputFieldType = "phone_number" + LoginInputFieldTypeEmail LoginInputFieldType = "email" + LoginInputFieldType2FACode LoginInputFieldType = "2fa_code" + LoginInputFieldTypeToken LoginInputFieldType = "token" + LoginInputFieldTypeURL LoginInputFieldType = "url" + LoginInputFieldTypeDomain LoginInputFieldType = "domain" + LoginInputFieldTypeSelect LoginInputFieldType = "select" + LoginInputFieldTypeCaptchaCode LoginInputFieldType = "captcha_code" +) + +type LoginInputDataField struct { + // The type of input field as a hint for the client. + Type LoginInputFieldType `json:"type"` + // The ID of the field to be used as the key in the map that is submitted to the connector. + ID string `json:"id"` + // The name of the field shown to the user. + Name string `json:"name"` + // The description of the field shown to the user. + Description string `json:"description"` + // A default value that the client can pre-fill the field with. + DefaultValue string `json:"default_value,omitempty"` + // A regex pattern that the client can use to validate input client-side. + Pattern string `json:"pattern,omitempty"` + // For fields of type select, the valid options. + // Pattern may also be filled with a regex that matches the same options. + Options []string `json:"options,omitempty"` + // A function that validates the input and optionally cleans it up before it's submitted to the connector. + Validate func(string) (string, error) `json:"-"` +} + +var numberCleaner = strings.NewReplacer("-", "", " ", "", "(", "", ")", "") + +func isOnlyNumbers(input string) bool { + for _, r := range input { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func CleanNonInternationalPhoneNumber(phone string) (string, error) { + phone = numberCleaner.Replace(phone) + if !isOnlyNumbers(strings.TrimPrefix(phone, "+")) { + return "", fmt.Errorf("phone number must only contain numbers") + } + return phone, nil +} + +func CleanPhoneNumber(phone string) (string, error) { + phone = numberCleaner.Replace(phone) + if len(phone) < 2 { + return "", fmt.Errorf("phone number must start with + and contain numbers") + } else if phone[0] != '+' { + return "", fmt.Errorf("phone number must start with +") + } else if !isOnlyNumbers(phone[1:]) { + return "", fmt.Errorf("phone number must only contain numbers") + } + return phone, nil +} + +func noopValidate(input string) (string, error) { + return input, nil +} + +func (f *LoginInputDataField) FillDefaultValidate() { + if f.Validate != nil { + return + } + switch f.Type { + case LoginInputFieldTypePhoneNumber: + f.Validate = CleanPhoneNumber + case LoginInputFieldTypeEmail: + f.Validate = func(email string) (string, error) { + if !strings.ContainsRune(email, '@') { + return "", fmt.Errorf("invalid email") + } + return email, nil + } + default: + if f.Pattern != "" { + f.Validate = func(s string) (string, error) { + match, err := regexp.MatchString(f.Pattern, s) + if err != nil { + return "", err + } else if !match { + return "", fmt.Errorf("doesn't match regex `%s`", f.Pattern) + } else { + return s, nil + } + } + } else { + f.Validate = noopValidate + } + } +} + +type LoginUserInputParams struct { + // The fields that the user needs to fill in. + Fields []LoginInputDataField `json:"fields"` + + // Attachments to display alongside the input fields. + Attachments []*LoginUserInputAttachment `json:"attachments"` +} + +type LoginUserInputAttachment struct { + Type event.MessageType `json:"type,omitempty"` + FileName string `json:"filename,omitempty"` + Content []byte `json:"content,omitempty"` + Info LoginUserInputAttachmentInfo `json:"info,omitempty"` +} + +type LoginUserInputAttachmentInfo struct { + MimeType string `json:"mimetype,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + Size int `json:"size,omitempty"` +} + +type LoginCompleteParams struct { + UserLoginID networkid.UserLoginID `json:"user_login_id"` + UserLogin *UserLogin `json:"-"` +} + +type LoginSubmit struct { +} diff --git a/mautrix-patched/bridgev2/matrix/analytics.go b/mautrix-patched/bridgev2/matrix/analytics.go new file mode 100644 index 00000000..7eb2a33a --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/analytics.go @@ -0,0 +1,62 @@ +package matrix + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "maunium.net/go/mautrix/id" +) + +func (br *Connector) trackSync(userID id.UserID, event string, properties map[string]any) error { + var buf bytes.Buffer + var analyticsUserID string + if br.Config.Analytics.UserID != "" { + analyticsUserID = br.Config.Analytics.UserID + } else { + analyticsUserID = userID.String() + } + err := json.NewEncoder(&buf).Encode(map[string]any{ + "userId": analyticsUserID, + "event": event, + "properties": properties, + }) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, br.Config.Analytics.URL, &buf) + if err != nil { + return err + } + req.SetBasicAuth(br.Config.Analytics.Token, "") + resp, err := br.AS.HTTPClient.Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + return nil +} + +func (br *Connector) TrackAnalytics(userID id.UserID, event string, props map[string]any) { + if br.Config.Analytics.Token == "" || br.Config.Analytics.URL == "" { + return + } + + if props == nil { + props = map[string]any{} + } + props["bridge"] = br.Bridge.Network.GetName().BeeperBridgeType + go func() { + err := br.trackSync(userID, event, props) + if err != nil { + br.Log.Err(err).Str("component", "analytics").Str("event", event).Msg("Error tracking event") + } else { + br.Log.Debug().Str("component", "analytics").Str("event", event).Msg("Tracked event") + } + }() +} diff --git a/mautrix-patched/bridgev2/matrix/cmdadmin.go b/mautrix-patched/bridgev2/matrix/cmdadmin.go new file mode 100644 index 00000000..0bd3eb82 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/cmdadmin.go @@ -0,0 +1,79 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "strconv" + + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/id" +) + +var CommandDiscardMegolmSession = &commands.FullHandler{ + Func: func(ce *commands.Event) { + matrix := ce.Bridge.Matrix.(*Connector) + if matrix.Crypto == nil { + ce.Reply("This bridge instance doesn't have end-to-bridge encryption enabled") + } else { + matrix.Crypto.ResetSession(ce.Ctx, ce.RoomID) + ce.Reply("Successfully reset Megolm session in this room. New decryption keys will be shared the next time a message is sent from the remote network.") + } + }, + Name: "discard-megolm-session", + Aliases: []string{"discard-session"}, + Help: commands.HelpMeta{ + Section: commands.HelpSectionAdmin, + Description: "Discard the Megolm session in the room", + }, + RequiresAdmin: true, +} + +func fnSetPowerLevel(ce *commands.Event) { + var level int + var userID id.UserID + var err error + if len(ce.Args) == 1 { + level, err = strconv.Atoi(ce.Args[0]) + if err != nil { + ce.Reply("Invalid power level \"%s\"", ce.Args[0]) + return + } + userID = ce.User.MXID + } else if len(ce.Args) == 2 { + userID = id.UserID(ce.Args[0]) + _, _, err := userID.Parse() + if err != nil { + ce.Reply("Invalid user ID \"%s\"", ce.Args[0]) + return + } + level, err = strconv.Atoi(ce.Args[1]) + if err != nil { + ce.Reply("Invalid power level \"%s\"", ce.Args[1]) + return + } + } else { + ce.Reply("**Usage:** `set-pl [user] `") + return + } + _, err = ce.Bot.(*ASIntent).Matrix.SetPowerLevel(ce.Ctx, ce.RoomID, userID, level) + if err != nil { + ce.Reply("Failed to set power levels: %v", err) + } +} + +var CommandSetPowerLevel = &commands.FullHandler{ + Func: fnSetPowerLevel, + Name: "set-pl", + Aliases: []string{"set-power-level"}, + Help: commands.HelpMeta{ + Section: commands.HelpSectionAdmin, + Description: "Change the power level in a portal room.", + Args: "[_user ID_] <_power level_>", + }, + RequiresAdmin: true, + RequiresPortal: true, +} diff --git a/mautrix-patched/bridgev2/matrix/cmddoublepuppet.go b/mautrix-patched/bridgev2/matrix/cmddoublepuppet.go new file mode 100644 index 00000000..2f3a3dc2 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/cmddoublepuppet.go @@ -0,0 +1,90 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "maunium.net/go/mautrix/bridgev2/commands" +) + +var CommandLoginMatrix = &commands.FullHandler{ + Func: fnLoginMatrix, + Name: "login-matrix", + Help: commands.HelpMeta{ + Section: commands.HelpSectionAuth, + Description: "Enable double puppeting.", + Args: "<_access token_>", + }, + RequiresLogin: true, +} + +func fnLoginMatrix(ce *commands.Event) { + if !ce.User.Permissions.DoublePuppet { + ce.Reply("You don't have permission to manage double puppeting.") + return + } + if len(ce.Args) == 0 { + ce.Reply("**Usage:** `login-matrix `") + return + } + err := ce.User.LoginDoublePuppet(ce.Ctx, ce.Args[0]) + if err != nil { + ce.Reply("Failed to enable double puppeting: %v", err) + } else { + ce.Reply("Successfully switched puppets") + } +} + +var CommandPingMatrix = &commands.FullHandler{ + Func: fnPingMatrix, + Name: "ping-matrix", + Help: commands.HelpMeta{ + Section: commands.HelpSectionAuth, + Description: "Ping the Matrix server with the double puppet.", + }, +} + +func fnPingMatrix(ce *commands.Event) { + intent := ce.User.DoublePuppet(ce.Ctx) + if intent == nil { + ce.Reply("You don't have double puppeting enabled.") + return + } + asIntent := intent.(*ASIntent) + resp, err := asIntent.Matrix.Whoami(ce.Ctx) + if err != nil { + ce.Reply("Failed to validate Matrix login: %v", err) + } else { + if asIntent.Matrix.SetAppServiceUserID && resp.DeviceID == "" { + ce.Reply("Confirmed valid access token for %s (appservice double puppeting)", resp.UserID) + } else { + ce.Reply("Confirmed valid access token for %s / %s", resp.UserID, resp.DeviceID) + } + } +} + +var CommandLogoutMatrix = &commands.FullHandler{ + Func: fnLogoutMatrix, + Name: "logout-matrix", + Help: commands.HelpMeta{ + Section: commands.HelpSectionAuth, + Description: "Disable double puppeting.", + }, + RequiresLogin: true, +} + +func fnLogoutMatrix(ce *commands.Event) { + if !ce.User.Permissions.DoublePuppet { + ce.Reply("You don't have permission to manage double puppeting.") + return + } + if ce.User.AccessToken == "" { + ce.Reply("You don't have double puppeting enabled.") + return + } + ce.User.LogoutDoublePuppet(ce.Ctx) + ce.Reply("Successfully disabled double puppeting.") +} diff --git a/mautrix-patched/bridgev2/matrix/connector.go b/mautrix-patched/bridgev2/matrix/connector.go new file mode 100644 index 00000000..caaf7d2e --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/connector.go @@ -0,0 +1,786 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + _ "github.com/lib/pq" + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + _ "go.mau.fi/util/dbutil/litestream" + "go.mau.fi/util/exbytes" + "go.mau.fi/util/exsync" + "go.mau.fi/util/ptr" + "go.mau.fi/util/random" + "golang.org/x/sync/semaphore" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mediaproxy" + "maunium.net/go/mautrix/sqlstatestore" +) + +type Crypto interface { + HandleMemberEvent(context.Context, *event.Event) + Decrypt(context.Context, *event.Event) (*event.Event, error) + Encrypt(context.Context, id.RoomID, event.Type, *event.Content) error + WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool + RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) + ResetSession(context.Context, id.RoomID) + Init(ctx context.Context) error + Start() + Stop() + Reset(ctx context.Context, startAfterReset bool) error + Client() *mautrix.Client + ShareKeys(context.Context) error + BeeperStreamPublisher() bridgev2.BeeperStreamPublisher +} + +type Connector struct { + AS *appservice.AppService + Bot *appservice.IntentAPI + StateStore *sqlstatestore.SQLStateStore + Crypto Crypto + Log *zerolog.Logger + Config *bridgeconfig.Config + Bridge *bridgev2.Bridge + Provisioning *ProvisioningAPI + DoublePuppet *doublePuppetUtil + MediaProxy *mediaproxy.MediaProxy + + uploadSema *semaphore.Weighted + dmaSigKey [32]byte + pubMediaSigKey []byte + + doublePuppetIntents *exsync.Map[id.UserID, *appservice.IntentAPI] + + deterministicEventIDServer string + + MediaConfig mautrix.RespMediaConfig + SpecVersions *mautrix.RespVersions + SpecCaps *mautrix.RespCapabilities + specCapsLock sync.Mutex + Capabilities *bridgev2.MatrixCapabilities + IgnoreUnsupportedServer bool + + EventProcessor *appservice.EventProcessor + + userIDRegex *regexp.Regexp + + Websocket bool + wsStopPinger chan struct{} + wsStarted chan struct{} + wsStopped chan struct{} + wsShortCircuitReconnectBackoff chan struct{} + wsStartupWait *sync.WaitGroup + stopping bool + hasSentAnyStates bool + OnWebsocketReplaced func() +} + +var ( + _ bridgev2.MatrixConnector = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithServer = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithArbitraryRoomState = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithPostRoomBridgeHandling = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithPublicMedia = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithNameDisambiguation = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithURLPreviews = (*Connector)(nil) + _ bridgev2.MatrixConnectorWithAnalytics = (*Connector)(nil) +) + +func NewConnector(cfg *bridgeconfig.Config) *Connector { + c := &Connector{} + c.Config = cfg + c.userIDRegex = cfg.MakeUserIDRegex("(.+)") + c.MediaConfig.UploadSize = 50 * 1024 * 1024 + c.uploadSema = semaphore.NewWeighted(c.MediaConfig.UploadSize + 1) + c.Capabilities = &bridgev2.MatrixCapabilities{} + c.doublePuppetIntents = exsync.NewMap[id.UserID, *appservice.IntentAPI]() + return c +} + +func (br *Connector) Init(bridge *bridgev2.Bridge) { + br.Bridge = bridge + br.Log = &bridge.Log + br.StateStore = sqlstatestore.NewSQLStateStore(bridge.DB.Database, dbutil.ZeroLogger(br.Log.With().Str("db_section", "matrix_state").Logger()), false) + br.AS = br.Config.MakeAppService() + br.AS.Log = bridge.Log + br.AS.StateStore = br.StateStore + br.EventProcessor = appservice.NewEventProcessor(br.AS) + if !br.Config.AppService.AsyncTransactions { + br.EventProcessor.ExecMode = appservice.Sync + } + for evtType := range status.CheckpointTypes { + br.EventProcessor.On(evtType, br.sendBridgeCheckpoint) + } + for _, evtType := range []event.Type{ + event.EventMessage, + event.EventSticker, + event.EventUnstablePollStart, + event.EventUnstablePollResponse, + event.EventReaction, + event.EventRedaction, + event.StateMember, + event.StatePowerLevels, + event.StateRoomName, + event.BeeperSendState, + event.StateRoomAvatar, + event.StateTopic, + event.StateTombstone, + event.StateBeeperDisappearingTimer, + event.BeeperDeleteChat, + event.BeeperAcceptMessageRequest, + } { + br.EventProcessor.On(evtType, br.handleRoomEvent) + } + br.EventProcessor.On(event.EventEncrypted, br.handleEncryptedEvent) + br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) + br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) + br.Bot = br.AS.BotIntent() + br.Crypto = NewCryptoHelper(br) + br.Bridge.Commands.(*commands.Processor).AddHandlers( + CommandDiscardMegolmSession, CommandSetPowerLevel, + CommandLoginMatrix, CommandPingMatrix, CommandLogoutMatrix, + ) + br.Provisioning = &ProvisioningAPI{br: br} + br.DoublePuppet = newDoublePuppetUtil(br) + br.deterministicEventIDServer = "backfill." + br.Config.Homeserver.Domain +} + +func (br *Connector) Start(ctx context.Context) error { + br.Provisioning.Init() + err := br.initDirectMedia() + if err != nil { + return err + } + err = br.initPublicMedia() + if err != nil { + return err + } + needsStateResync := br.Config.Encryption.Default && + br.Bridge.DB.KV.Get(ctx, database.KeyEncryptionStateResynced) != "true" + if needsStateResync { + dbExists, err := br.StateStore.TableExists(ctx, "mx_version") + if err != nil { + return fmt.Errorf("failed to check if mx_version table exists: %w", err) + } else if !dbExists { + needsStateResync = false + br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true") + } + } + err = br.StateStore.Upgrade(ctx) + if err != nil { + return bridgev2.DBUpgradeError{Section: "matrix_state", Err: err} + } + if br.Config.Homeserver.Websocket || len(br.Config.Homeserver.WSProxy) > 0 { + br.Websocket = true + br.Log.Debug().Msg("Starting appservice websocket") + var wg sync.WaitGroup + wg.Add(1) + br.wsStartupWait = &wg + br.wsShortCircuitReconnectBackoff = make(chan struct{}) + go br.startWebsocket(&wg) + } else if br.Config.AppService.NoServer { + br.Log.Debug().Msg("Not starting appservice HTTP server, assuming someone else is routing traffic") + } else if br.AS.Host.IsConfigured() { + br.Log.Debug().Msg("Starting appservice HTTP server") + go br.AS.Start() + } else { + br.Log.WithLevel(zerolog.FatalLevel).Msg("Neither appservice HTTP listener nor websocket is enabled") + return ExitError{23} + } + + br.Log.Debug().Msg("Checking connection to homeserver") + if err := br.ensureConnection(ctx); err != nil { + return err + } + go br.fetchMediaConfig(ctx) + if br.Crypto != nil { + err = br.Crypto.Init(ctx) + if err != nil { + return err + } + } + br.EventProcessor.Start(ctx) + go br.UpdateBotProfile(ctx) + if br.Crypto != nil { + go br.Crypto.Start() + } + parsed, _ := url.Parse(br.Bridge.Network.GetName().NetworkURL) + if parsed != nil { + br.deterministicEventIDServer = strings.TrimPrefix(parsed.Hostname(), "www.") + } + br.AS.Ready = true + if br.Websocket && br.Config.Homeserver.WSPingInterval > 0 { + br.wsStopPinger = make(chan struct{}, 1) + go br.websocketServerPinger() + } + if needsStateResync { + br.ResyncEncryptionState(ctx) + } + return nil +} + +func (br *Connector) ResyncEncryptionState(ctx context.Context) { + log := zerolog.Ctx(ctx) + roomIDScanner := dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) + rooms, err := roomIDScanner.NewRowIter(br.Bridge.DB.Query(ctx, ` + SELECT rooms.room_id + FROM (SELECT DISTINCT(room_id) FROM mx_user_profile WHERE room_id<>'') rooms + LEFT JOIN mx_room_state ON rooms.room_id = mx_room_state.room_id + WHERE mx_room_state.encryption IS NULL + `)).AsList() + if err != nil { + log.Err(err).Msg("Failed to get room list to resync state") + return + } + var failedCount, successCount, forbiddenCount int + for _, roomID := range rooms { + if roomID == "" { + continue + } + var outContent *event.EncryptionEventContent + err = br.Bot.Client.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent) + if errors.Is(err, mautrix.MForbidden) { + // Most likely non-existent room + log.Debug().Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") + forbiddenCount++ + } else if err != nil { + log.Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") + failedCount++ + } else { + successCount++ + } + } + br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true") + log.Info(). + Int("success_count", successCount). + Int("forbidden_count", forbiddenCount). + Int("failed_count", failedCount). + Msg("Resynced rooms") +} + +func (br *Connector) GetPublicAddress() string { + if br.Config.AppService.PublicAddress == "https://bridge.example.com" { + return "" + } + return strings.TrimRight(br.Config.AppService.PublicAddress, "/") +} + +func (br *Connector) GetRouter() *http.ServeMux { + if br.GetPublicAddress() != "" { + return br.AS.Router + } + return nil +} + +func (br *Connector) GetCapabilities() *bridgev2.MatrixCapabilities { + return br.Capabilities +} + +func (br *Connector) GetBeeperStreamPublisher() bridgev2.BeeperStreamPublisher { + if br == nil || br.Crypto == nil { + return nil + } + return br.Crypto.BeeperStreamPublisher() +} + +func sendStopSignal(ch chan struct{}) { + if ch != nil { + select { + case ch <- struct{}{}: + default: + } + } +} + +func (br *Connector) PreStop() { + br.stopping = true + br.AS.Stop() + if stopWebsocket := br.AS.StopWebsocket; stopWebsocket != nil { + stopWebsocket(appservice.ErrWebsocketManualStop) + } + sendStopSignal(br.wsStopPinger) + sendStopSignal(br.wsShortCircuitReconnectBackoff) +} + +func (br *Connector) Stop() { + br.EventProcessor.Stop() + if br.Crypto != nil { + br.Crypto.Stop() + } + if wsStopChan := br.wsStopped; wsStopChan != nil { + select { + case <-wsStopChan: + case <-time.After(4 * time.Second): + br.Log.Warn().Msg("Timed out waiting for websocket to close") + } + } +} + +var MinSpecVersion = mautrix.SpecV14 + +func (br *Connector) logInitialRequestError(err error, defaultMessage string) { + if errors.Is(err, mautrix.MUnknownToken) { + br.Log.WithLevel(zerolog.FatalLevel).Msg("The as_token was not accepted. Is the registration file installed in your homeserver correctly?") + br.Log.Info().Msg("See https://docs.mau.fi/faq/as-token for more info") + } else if errors.Is(err, mautrix.MExclusive) { + br.Log.WithLevel(zerolog.FatalLevel).Msg("The as_token was accepted, but the /register request was not. Are the homeserver domain, bot username and username template in the config correct, and do they match the values in the registration?") + br.Log.Info().Msg("See https://docs.mau.fi/faq/as-register for more info") + } else { + br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg(defaultMessage) + } +} + +func (br *Connector) ensureConnection(ctx context.Context) error { + triedToRegister := false + for { + versions, err := br.Bot.Versions(ctx) + if err != nil { + if errors.Is(err, mautrix.MForbidden) && !triedToRegister { + br.Log.Debug().Msg("M_FORBIDDEN in /versions, trying to register before retrying") + err = br.Bot.EnsureRegistered(ctx) + if err != nil { + br.logInitialRequestError(err, "Failed to register after /versions failed with M_FORBIDDEN") + return fmt.Errorf("%w: failed to register after /versions failed with M_FORBIDDEN: %w", ExitError{16}, err) + } + triedToRegister = true + } else if errors.Is(err, mautrix.MUnknownToken) || errors.Is(err, mautrix.MExclusive) { + br.logInitialRequestError(err, "/versions request failed with auth error") + return fmt.Errorf("%w: /versions request failed with auth error: %w", ExitError{16}, err) + } else if errors.Is(err, context.Canceled) { + br.logInitialRequestError(err, "/versions request was canceled") + return fmt.Errorf("%w: /versions request was canceled: %w", ExitError{16}, err) + } else { + br.Log.Err(err).Msg("Failed to connect to homeserver, retrying in 10 seconds...") + select { + case <-time.After(10 * time.Second): + case <-ctx.Done(): + return fmt.Errorf("%w: %w", ExitError{16}, ctx.Err()) + } + } + } else { + br.SpecVersions = versions + *br.AS.SpecVersions = *versions + br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) + br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) + br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange) + br.Capabilities.ReplaceEntireProfile = br.SpecVersions.Supports(mautrix.FeatureUnstableReplaceProfile) || + br.SpecVersions.Supports(mautrix.FeatureStableReplaceProfile) + br.Capabilities.ExtraProfileMeta = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || + (br.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && br.Config.Matrix.GhostExtraProfileInfo) + break + } + } + + unsupportedServerLogLevel := zerolog.FatalLevel + if br.IgnoreUnsupportedServer { + unsupportedServerLogLevel = zerolog.ErrorLevel + } + if br.Config.Homeserver.Software == bridgeconfig.SoftwareHungry && !br.SpecVersions.Supports(mautrix.BeeperFeatureHungry) { + br.Log.WithLevel(zerolog.FatalLevel).Msg("The config claims the homeserver is hungryserv, but the /versions response didn't confirm it") + return ExitError{18} + } else if !br.SpecVersions.ContainsGreaterOrEqual(MinSpecVersion) { + br.Log.WithLevel(unsupportedServerLogLevel). + Stringer("server_supports", br.SpecVersions.GetLatest()). + Stringer("bridge_requires", MinSpecVersion). + Msg("The homeserver is outdated (supported spec versions are below minimum required by bridge)") + if !br.IgnoreUnsupportedServer { + return ExitError{18} + } + } + + resp, err := br.Bot.Whoami(ctx) + if err != nil { + br.logInitialRequestError(err, "/whoami request failed with unknown error") + return fmt.Errorf("%w: /whoami request failed with unknown error: %w", ExitError{16}, err) + } else if resp.UserID != br.Bot.UserID { + br.Log.WithLevel(zerolog.FatalLevel). + Stringer("got_user_id", resp.UserID). + Stringer("expected_user_id", br.Bot.UserID). + Msg("Unexpected user ID in whoami call") + return ExitError{17} + } + + if br.Websocket { + br.Log.Debug().Msg("Websocket mode: no need to check status of homeserver -> bridge connection") + } else if !br.SpecVersions.Supports(mautrix.FeatureAppservicePing) { + br.Log.Debug().Msg("Homeserver does not support checking status of homeserver -> bridge connection") + } else if !br.Bot.EnsureAppserviceConnection(ctx) { + return ExitError{13} + } + return nil +} + +func (br *Connector) fetchCapabilities(ctx context.Context) *mautrix.RespCapabilities { + br.specCapsLock.Lock() + defer br.specCapsLock.Unlock() + if br.SpecCaps != nil { + return br.SpecCaps + } + caps, err := br.Bot.Capabilities(ctx) + if err != nil { + br.Log.Err(err).Msg("Failed to fetch capabilities from homeserver") + return nil + } + br.SpecCaps = caps + return caps +} + +func (br *Connector) fetchMediaConfig(ctx context.Context) { + cfg, err := br.Bot.GetMediaConfig(ctx) + if err != nil { + br.Log.Warn().Err(err).Msg("Failed to fetch media config") + } else { + if cfg.UploadSize == 0 { + cfg.UploadSize = 50 * 1024 * 1024 + } + br.MediaConfig = *cfg + mfsn, ok := br.Bridge.Network.(bridgev2.MaxFileSizeingNetwork) + if ok { + mfsn.SetMaxFileSize(br.MediaConfig.UploadSize) + } + br.uploadSema = semaphore.NewWeighted(br.MediaConfig.UploadSize + 1) + } +} + +func (br *Connector) UpdateBotProfile(ctx context.Context) { + br.Log.Debug().Msg("Updating bot profile") + botConfig := &br.Config.AppService.Bot + + var err error + var mxc id.ContentURI + if botConfig.Avatar == "remove" { + err = br.Bot.SetAvatarURL(ctx, mxc) + } else if !botConfig.ParsedAvatar.IsEmpty() { + err = br.Bot.SetAvatarURL(ctx, botConfig.ParsedAvatar) + } + if err != nil { + br.Log.Warn().Err(err).Msg("Failed to update bot avatar") + } + + if botConfig.Displayname == "remove" { + err = br.Bot.SetDisplayName(ctx, "") + } else if len(botConfig.Displayname) > 0 { + err = br.Bot.SetDisplayName(ctx, botConfig.Displayname) + } + if err != nil { + br.Log.Warn().Err(err).Msg("Failed to update bot displayname") + } + + if br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { + br.Log.Debug().Msg("Setting contact info on the appservice bot") + netName := br.Bridge.Network.GetName() + err = br.Bot.BeeperUpdateProfile(ctx, event.BeeperProfileExtra{ + Service: netName.BeeperBridgeType, + Network: netName.NetworkID, + IsBridgeBot: true, + }) + if err != nil { + br.Log.Warn().Err(err).Msg("Failed to update bot contact info") + } + } +} + +func (br *Connector) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI { + return &ASIntent{ + Matrix: br.AS.Intent(br.FormatGhostMXID(userID)), + Connector: br, + } +} + +func (br *Connector) SendBridgeStatus(ctx context.Context, state *status.BridgeState) error { + if br.Websocket { + br.hasSentAnyStates = true + return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{ + Command: "bridge_status", + Data: state, + }) + } else if br.Config.Homeserver.StatusEndpoint != "" { + // Connecting states aren't really relevant unless the bridge runs somewhere with an unreliable network + if state.StateEvent == status.StateConnecting { + return nil + } + return state.SendHTTP(ctx, br.Config.Homeserver.StatusEndpoint, br.Config.AppService.ASToken) + } else { + return nil + } +} + +func (br *Connector) SendMessageStatus(ctx context.Context, ms *bridgev2.MessageStatus, evt *bridgev2.MessageStatusEventInfo) { + go br.internalSendMessageStatus(ctx, ms, evt, "") +} + +func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2.MessageStatus, evt *bridgev2.MessageStatusEventInfo, editEvent id.EventID) id.EventID { + if evt.EventType.IsEphemeral() || evt.SourceEventID == "" { + return "" + } + log := zerolog.Ctx(ctx) + + if !evt.IsSourceEventDoublePuppeted { + err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{ms.ToCheckpoint(evt)}) + if err != nil { + log.Err(err).Msg("Failed to send message checkpoint") + } + } + + if !ms.DisableMSS && br.Config.Matrix.MessageStatusEvents { + mssEvt := ms.ToMSSEvent(evt) + _, err := br.Bot.SendMessageEvent(ctx, evt.RoomID, event.BeeperMessageStatus, mssEvt) + if err != nil { + log.Err(err). + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.SourceEventID). + Any("mss_content", mssEvt). + Msg("Failed to send MSS event") + } + } + if ms.SendNotice && br.Config.Matrix.MessageErrorNotices && evt.MessageType != event.MsgNotice && + (ms.Status == event.MessageStatusFail || ms.Status == event.MessageStatusRetriable || ms.Step == status.MsgStepDecrypted) { + content := ms.ToNoticeEvent(evt) + if editEvent != "" { + content.SetEdit(editEvent) + } + resp, err := br.Bot.SendMessageEvent(ctx, evt.RoomID, event.EventMessage, content) + if err != nil { + log.Err(err). + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.SourceEventID). + Str("notice_message", content.Body). + Msg("Failed to send notice event") + } else { + return resp.EventID + } + } + if ms.Status == event.MessageStatusSuccess && br.Config.Matrix.DeliveryReceipts { + err := br.Bot.SendReceipt(ctx, evt.RoomID, evt.SourceEventID, event.ReceiptTypeRead, nil) + if err != nil { + log.Err(err). + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.SourceEventID). + Msg("Failed to send Matrix delivery receipt") + } + } + return "" +} + +func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []*status.MessageCheckpoint) error { + checkpointsJSON := status.CheckpointsJSON{Checkpoints: checkpoints} + + if br.Websocket { + return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{ + Command: "message_checkpoint", + Data: checkpointsJSON, + }) + } + + endpoint := br.Config.Homeserver.MessageSendCheckpointEndpoint + if endpoint == "" { + return nil + } + + return checkpointsJSON.SendHTTP(ctx, br.AS.HTTPClient, endpoint, br.AS.Registration.AppToken) +} + +func (br *Connector) ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) { + match := br.userIDRegex.FindStringSubmatch(string(userID)) + if match == nil || userID == br.Bot.UserID { + return "", false + } + decoded, err := id.DecodeUserLocalpart(match[1]) + if err != nil { + return "", false + } + return networkid.UserID(decoded), true +} + +func (br *Connector) FormatGhostMXID(userID networkid.UserID) id.UserID { + localpart := br.Config.AppService.FormatUsername(id.EncodeUserLocalpart(string(userID))) + return id.NewUserID(localpart, br.Config.Homeserver.Domain) +} + +func (br *Connector) NewUserIntent(ctx context.Context, userID id.UserID, accessToken string) (bridgev2.MatrixAPI, string, error) { + intent, newToken, err := br.DoublePuppet.Setup(ctx, userID, accessToken) + if err != nil { + if errors.Is(err, ErrNoAccessToken) { + err = nil + } + return nil, accessToken, err + } + br.doublePuppetIntents.Set(userID, intent) + return &ASIntent{Connector: br, Matrix: intent}, newToken, nil +} + +func (br *Connector) BotIntent() bridgev2.MatrixAPI { + return &ASIntent{Connector: br, Matrix: br.Bot} +} + +func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) { + return br.Bot.PowerLevels(ctx, roomID) +} + +func (br *Connector) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) { + if stateKey == "" { + switch eventType { + case event.StateCreate: + createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) + if err != nil || createEvt != nil { + return createEvt, err + } + case event.StateJoinRules: + joinRulesContent, err := br.Bot.StateStore.GetJoinRules(ctx, roomID) + if err != nil { + return nil, err + } else if joinRulesContent != nil { + return &event.Event{ + Type: event.StateJoinRules, + RoomID: roomID, + StateKey: ptr.Ptr(""), + Content: event.Content{Parsed: joinRulesContent}, + }, nil + } + } + } + return br.Bot.FullStateEvent(ctx, roomID, eventType, stateKey) +} + +func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + fetched, err := br.Bot.StateStore.HasFetchedMembers(ctx, roomID) + if err != nil { + return nil, err + } else if fetched { + return br.Bot.StateStore.GetAllMembers(ctx, roomID) + } + members, err := br.Bot.Members(ctx, roomID) + if err != nil { + return nil, err + } + output := make(map[id.UserID]*event.MemberEventContent, len(members.Chunk)) + for _, evt := range members.Chunk { + output[id.UserID(evt.GetStateKey())] = evt.Content.AsMember() + } + return output, nil +} + +func (br *Connector) GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { + // TODO fetch from network sometimes? + return br.AS.StateStore.GetMember(ctx, roomID, userID) +} + +func (br *Connector) IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error) { + return br.AS.StateStore.IsConfusableName(ctx, roomID, userID, name) +} + +func (br *Connector) GetUniqueBridgeID() string { + return fmt.Sprintf("%s/%s", br.Config.Homeserver.Domain, br.Config.AppService.ID) +} + +func (br *Connector) isEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { + if br.Config.Encryption.Require { + return true, nil + } + return br.StateStore.IsEncrypted(ctx, roomID) +} + +func (br *Connector) BatchSend(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBeeperBatchSend, extras []*bridgev2.MatrixSendExtra) (*mautrix.RespBeeperBatchSend, error) { + if encrypted, err := br.isEncrypted(ctx, roomID); err != nil { + return nil, fmt.Errorf("failed to check if room is encrypted: %w", err) + } else if encrypted { + for _, evt := range req.Events { + intent, _ := br.doublePuppetIntents.Get(evt.Sender) + if intent != nil { + intent.AddDoublePuppetValueWithTS(&evt.Content, evt.Timestamp) + } + if evt.Type != event.EventEncrypted && evt.Type != event.EventReaction { + err = br.Crypto.Encrypt(ctx, roomID, evt.Type, &evt.Content) + if err != nil { + return nil, err + } + evt.Type = event.EventEncrypted + if intent != nil { + intent.AddDoublePuppetValueWithTS(&evt.Content, evt.Timestamp) + } + } + } + } + return br.Bot.BeeperBatchSend(ctx, roomID, req) +} + +func (br *Connector) GenerateDeterministicEventID(roomID id.RoomID, _ networkid.PortalKey, messageID networkid.MessageID, partID networkid.PartID) id.EventID { + data := make([]byte, 0, len(roomID)+1+len(messageID)+1+len(partID)) + data = append(data, roomID...) + data = append(data, 0) + data = append(data, messageID...) + data = append(data, 0) + data = append(data, partID...) + + hash := sha256.Sum256(data) + hashB64Len := base64.RawURLEncoding.EncodedLen(len(hash)) + + eventID := make([]byte, 1+hashB64Len+1+len(br.deterministicEventIDServer)) + eventID[0] = '$' + base64.RawURLEncoding.Encode(eventID[1:1+hashB64Len], hash[:]) + eventID[1+hashB64Len] = ':' + copy(eventID[1+hashB64Len+1:], br.deterministicEventIDServer) + + return id.EventID(exbytes.UnsafeString(eventID)) +} + +func (br *Connector) GenerateDeterministicRoomID(key networkid.PortalKey) id.RoomID { + return id.RoomID(fmt.Sprintf("!%s.%s:%s", key.ID, key.Receiver, br.ServerName())) +} + +func (br *Connector) GenerateReactionEventID(roomID id.RoomID, targetMessage *database.Message, sender networkid.UserID, emojiID networkid.EmojiID) id.EventID { + // We don't care about determinism for reactions + return id.EventID(fmt.Sprintf("$%s:%s", base64.RawURLEncoding.EncodeToString(random.Bytes(32)), br.deterministicEventIDServer)) +} + +func (br *Connector) ServerName() string { + return br.Config.Homeserver.Domain +} + +func (br *Connector) HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error { + _, err := br.Bot.Members(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch members in newly bridged room") + } + if !br.Config.Encryption.Default { + return nil + } + _, err = br.Bot.SendStateEvent(ctx, roomID, event.StateEncryption, "", &event.Content{ + Parsed: br.getDefaultEncryptionEvent(), + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to enable encryption in newly bridged room") + return fmt.Errorf("failed to enable encryption") + } + return nil +} + +func (br *Connector) GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error) { + return br.Bot.GetURLPreview(ctx, url) +} diff --git a/mautrix-patched/bridgev2/matrix/crypto.go b/mautrix-patched/bridgev2/matrix/crypto.go new file mode 100644 index 00000000..073e7b85 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/crypto.go @@ -0,0 +1,607 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build cgo && !nocrypto + +package matrix + +import ( + "context" + "errors" + "fmt" + "os" + "runtime/debug" + "strings" + "sync" + "time" + + "github.com/lib/pq" + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/beeperstream" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/sqlstatestore" +) + +func init() { + crypto.PostgresArrayWrapper = pq.Array +} + +var _ crypto.StateStore = (*sqlstatestore.SQLStateStore)(nil) + +var NoSessionFound = crypto.ErrNoSessionFound +var DuplicateMessageIndex = crypto.ErrDuplicateMessageIndex +var UnknownMessageIndex = olm.ErrUnknownMessageIndex + +type CryptoHelper struct { + bridge *Connector + client *mautrix.Client + mach *crypto.OlmMachine + store *SQLCryptoStore + log *zerolog.Logger + streams *beeperstream.Helper + + lock sync.RWMutex + syncDone sync.WaitGroup + cancelSync func() + + cancelPeriodicDeleteLoop func() +} + +func NewCryptoHelper(c *Connector) Crypto { + if !c.Config.Encryption.Allow { + c.Log.Debug().Msg("Bridge built with end-to-bridge encryption, but disabled in config") + return nil + } + log := c.Log.With().Str("component", "crypto").Logger() + return &CryptoHelper{ + bridge: c, + log: &log, + } +} + +func (helper *CryptoHelper) Init(ctx context.Context) error { + if len(helper.bridge.Config.Encryption.PickleKey) == 0 { + panic("CryptoPickleKey not set") + } + helper.log.Debug().Msg("Initializing end-to-bridge encryption...") + + helper.store = NewSQLCryptoStore( + helper.bridge.Bridge.DB.Database, + dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()), + string(helper.bridge.Bridge.ID), + helper.bridge.AS.BotMXID(), + fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain), + helper.bridge.Config.Encryption.PickleKey, + ) + + err := helper.store.DB.Upgrade(ctx) + if err != nil { + return bridgev2.DBUpgradeError{Section: "crypto", Err: err} + } + + var isExistingDevice bool + helper.client, isExistingDevice, err = helper.loginBot(ctx) + if err != nil { + return err + } + + helper.log.Debug(). + Str("device_id", helper.client.DeviceID.String()). + Msg("Logged in as bridge bot") + helper.mach = crypto.NewOlmMachine(helper.client, helper.log, helper.store, helper.bridge.StateStore) + helper.mach.DisableSharedGroupSessionTracking = true + helper.mach.AllowKeyShare = helper.allowKeyShare + + encryptionConfig := helper.bridge.Config.Encryption + helper.mach.SendKeysMinTrust = encryptionConfig.VerificationLevels.Receive + helper.mach.PlaintextMentions = encryptionConfig.PlaintextMentions + + helper.mach.DeleteOutboundKeysOnAck = encryptionConfig.DeleteKeys.DeleteOutboundOnAck + helper.mach.DontStoreOutboundKeys = encryptionConfig.DeleteKeys.DontStoreOutbound + helper.mach.RatchetKeysOnDecrypt = encryptionConfig.DeleteKeys.RatchetOnDecrypt + helper.mach.DeleteFullyUsedKeysOnDecrypt = encryptionConfig.DeleteKeys.DeleteFullyUsedOnDecrypt + helper.mach.DeletePreviousKeysOnReceive = encryptionConfig.DeleteKeys.DeletePrevOnNewSession + helper.mach.DeleteKeysOnDeviceDelete = encryptionConfig.DeleteKeys.DeleteOnDeviceDelete + helper.mach.DisableDeviceChangeKeyRotation = encryptionConfig.Rotation.DisableDeviceChangeKeyRotation + if encryptionConfig.DeleteKeys.PeriodicallyDeleteExpired { + ctx, cancel := context.WithCancel(context.Background()) + helper.cancelPeriodicDeleteLoop = cancel + go helper.mach.ExpiredKeyDeleteLoop(ctx) + } + + if encryptionConfig.DeleteKeys.DeleteOutdatedInbound { + deleted, err := helper.store.RedactOutdatedGroupSessions(ctx) + if err != nil { + return err + } + if len(deleted) > 0 { + helper.log.Debug().Int("deleted", len(deleted)).Msg("Deleted inbound keys which lacked expiration metadata") + } + } + + streams, err := beeperstream.New(helper.client) + if err != nil { + return err + } + helper.streams = streams + helper.client.Syncer = &cryptoSyncer{OlmMachine: helper.mach, handleSyncResponse: streams.HandleSyncResponse} + helper.client.Store = helper.store + + err = helper.mach.Load(ctx) + if err != nil { + return err + } + if isExistingDevice { + if ok, err := helper.verifyKeysAreOnServer(ctx); err != nil { + return err + } else if !ok { + return nil + } + } else { + err = helper.ShareKeys(ctx) + if err != nil { + return fmt.Errorf("failed to share device keys: %w", err) + } + } + if helper.bridge.Config.Encryption.SelfSign { + if !helper.doSelfSign(ctx) { + return ExitError{34} + } + } + + go helper.resyncEncryptionInfo(context.TODO()) + + return nil +} + +func (helper *CryptoHelper) doSelfSign(ctx context.Context) bool { + log := zerolog.Ctx(ctx) + hasKeys, isVerified, err := helper.mach.GetOwnVerificationStatus(ctx) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check verification status") + return false + } + mkVerified, sskVerified, uskVerified, err := helper.mach.GetOwnCrossSigningVerificationStatus(ctx) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check cross-signing key verification status") + return false + } + log.Debug(). + Bool("has_keys", hasKeys). + Bool("device_verified", isVerified). + Bool("mk_verified", mkVerified). + Bool("usk_verified", uskVerified). + Bool("ssk_verified", sskVerified). + Msg("Checked verification status") + keyInDB := helper.bridge.Bridge.DB.KV.Get(ctx, database.KeyRecoveryKey) + if !hasKeys || keyInDB == "overwrite" { + if keyInDB != "" && keyInDB != "overwrite" { + log.WithLevel(zerolog.FatalLevel). + Msg("No keys on server, but database already has recovery key. Delete `recovery_key` from `kv_store` manually to continue.") + return false + } + recoveryKey, err := helper.mach.GenerateAndVerifyWithRecoveryKey(ctx) + if recoveryKey != "" { + helper.bridge.Bridge.DB.KV.Set(ctx, database.KeyRecoveryKey, recoveryKey) + } + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to generate recovery key and self-sign") + return false + } + log.Info().Msg("Generated new recovery key and self-signed bot device") + } else if !isVerified || !mkVerified { + if keyInDB == "" { + log.WithLevel(zerolog.FatalLevel). + Msg("Server already has cross-signing keys, but no key in database. Add `recovery_key` to `kv_store`, or set it to `overwrite` to generate new keys.") + return false + } + err = helper.mach.VerifyWithRecoveryKey(ctx, keyInDB) + if err != nil { + log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to verify with recovery key") + return false + } + log.Info().Msg("Verified bot device with existing recovery key") + } + return true +} + +func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) { + log := helper.log.With().Str("action", "resync encryption event").Logger() + rows, err := helper.store.DB.Query(ctx, `SELECT room_id FROM mx_room_state WHERE encryption='{"resync":true}'`) + roomIDs, err := dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.RoomID], err).AsList() + if err != nil { + log.Err(err).Msg("Failed to query rooms for resync") + return + } + if len(roomIDs) > 0 { + log.Debug().Interface("room_ids", roomIDs).Msg("Resyncing rooms") + for _, roomID := range roomIDs { + var evt event.EncryptionEventContent + err = helper.client.StateEvent(ctx, roomID, event.StateEncryption, "", &evt) + if err != nil { + log.Err(err).Stringer("room_id", roomID).Msg("Failed to get encryption event") + _, err = helper.store.DB.Exec(ctx, ` + UPDATE mx_room_state SET encryption=NULL WHERE room_id=$1 AND encryption='{"resync":true}' + `, roomID) + if err != nil { + log.Err(err).Stringer("room_id", roomID).Msg("Failed to unmark room for resync after failed sync") + } + } else { + maxAge := evt.RotationPeriodMillis + if maxAge <= 0 { + maxAge = (7 * 24 * time.Hour).Milliseconds() + } + maxMessages := evt.RotationPeriodMessages + if maxMessages <= 0 { + maxMessages = 100 + } + log.Debug(). + Str("room_id", roomID.String()). + Int64("max_age_ms", maxAge). + Int("max_messages", maxMessages). + Interface("content", &evt). + Msg("Resynced encryption event") + _, err = helper.store.DB.Exec(ctx, ` + UPDATE crypto_megolm_inbound_session + SET max_age=$1, max_messages=$2 + WHERE room_id=$3 AND max_age IS NULL AND max_messages IS NULL + `, maxAge, maxMessages, roomID) + if err != nil { + log.Err(err).Stringer("room_id", roomID).Msg("Failed to update megolm session table") + } else { + log.Debug().Stringer("room_id", roomID).Msg("Updated megolm session table") + } + } + } + } +} + +func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device, info event.RequestedKeyInfo) *crypto.KeyShareRejection { + cfg := helper.bridge.Config.Encryption + if !cfg.AllowKeySharing { + return &crypto.KeyShareRejectNoResponse + } else if device.Trust == id.TrustStateBlacklisted { + return &crypto.KeyShareRejectBlacklisted + } else if trustState, _ := helper.mach.ResolveTrustContext(ctx, device); trustState >= cfg.VerificationLevels.Share { + portal, err := helper.bridge.Bridge.GetPortalByMXID(ctx, info.RoomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal to handle key request") + return &crypto.KeyShareRejectNoResponse + } else if portal == nil { + zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: room is not a portal") + return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"} + } + user, err := helper.bridge.Bridge.GetExistingUserByMXID(ctx, device.UserID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle key request") + return &crypto.KeyShareRejectNoResponse + } else if user == nil { + zerolog.Ctx(ctx).Debug().Msg("Couldn't find user to handle key request") + return &crypto.KeyShareRejectNoResponse + } else if !user.Permissions.Admin { + zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: user is not admin") + // TODO is in room check? + return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "Key sharing for non-admins is not yet implemented"} + } + zerolog.Ctx(ctx).Debug().Msg("Accepting key request") + return nil + } else { + return &crypto.KeyShareRejectUnverified + } +} + +func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool, error) { + deviceID, err := helper.store.FindDeviceID(ctx) + if err != nil { + return nil, false, fmt.Errorf("failed to find existing device ID: %w", err) + } else if len(deviceID) > 0 { + helper.log.Debug().Stringer("device_id", deviceID).Msg("Found existing device ID for bot in database") + } + // Create a new client instance with the default AS settings (including as_token), + // the Login call will then override the access token in the client. + client := helper.bridge.AS.NewMautrixClient(helper.bridge.AS.BotMXID()) + + initialDeviceDisplayName := fmt.Sprintf("%s bridge", helper.bridge.Bridge.Network.GetName().DisplayName) + if helper.bridge.Config.Encryption.MSC4190 { + helper.log.Debug().Msg("Creating bot device with MSC4190") + err = client.CreateDeviceMSC4190(ctx, deviceID, initialDeviceDisplayName) + if err != nil { + return nil, deviceID != "", fmt.Errorf("failed to create device for bridge bot: %w", err) + } + helper.store.DeviceID = client.DeviceID + return client, deviceID != "", nil + } + + flows, err := client.GetLoginFlows(ctx) + if err != nil { + return nil, deviceID != "", fmt.Errorf("failed to get supported login flows: %w", err) + } else if !flows.HasFlow(mautrix.AuthTypeAppservice) { + return nil, deviceID != "", fmt.Errorf("homeserver does not support appservice login") + } + + resp, err := client.Login(ctx, &mautrix.ReqLogin{ + Type: mautrix.AuthTypeAppservice, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: string(helper.bridge.AS.BotMXID()), + }, + DeviceID: deviceID, + StoreCredentials: true, + InitialDeviceDisplayName: initialDeviceDisplayName, + }) + if err != nil { + return nil, deviceID != "", fmt.Errorf("failed to log in as bridge bot: %w", err) + } + helper.store.DeviceID = resp.DeviceID + return client, deviceID != "", nil +} + +func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) (bool, error) { + helper.log.Debug().Msg("Making sure keys are still on server") + resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ + DeviceKeys: map[id.UserID]mautrix.DeviceIDList{ + helper.client.UserID: {helper.client.DeviceID}, + }, + }) + if err != nil { + helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to query own keys to make sure device still exists") + return false, fmt.Errorf("%w: failed to query own keys to make sure device still exists:%w", ExitError{33}, err) + } + device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID] + if ok && len(device.Keys) > 0 { + return true, nil + } + helper.log.Warn().Msg("Existing device doesn't have keys on server, resetting crypto") + return false, helper.Reset(ctx, false) +} + +func (helper *CryptoHelper) Start() { + if helper.bridge.Config.Encryption.Appservice { + helper.log.Debug().Msg("End-to-bridge encryption is in appservice mode, registering event listeners and not starting syncer") + helper.bridge.AS.Registration.EphemeralEvents = true + helper.mach.AddAppserviceListener(helper.bridge.EventProcessor) + if helper.streams != nil { + err := helper.streams.InitAppservice(helper.bridge.EventProcessor) + if err != nil { + helper.log.Err(err).Msg("Failed to initialize beeper stream appservice listener") + } + } + return + } + helper.syncDone.Add(1) + defer helper.syncDone.Done() + helper.log.Debug().Msg("Starting syncer for receiving to-device messages") + var ctx context.Context + ctx, helper.cancelSync = context.WithCancel(context.Background()) + err := helper.client.SyncWithContext(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Fatal error syncing") + os.Exit(51) + } else { + helper.log.Info().Msg("Bridge bot to-device syncer stopped without error") + } +} + +func (helper *CryptoHelper) Stop() { + helper.log.Debug().Msg("CryptoHelper.Stop() called, stopping bridge bot sync") + helper.client.StopSync() + if helper.cancelSync != nil { + helper.cancelSync() + } + if helper.cancelPeriodicDeleteLoop != nil { + helper.cancelPeriodicDeleteLoop() + } + helper.syncDone.Wait() + if helper.streams != nil { + _ = helper.streams.Close() + } +} + +func (helper *CryptoHelper) clearDatabase(ctx context.Context) { + _, err := helper.store.DB.Exec(ctx, "DELETE FROM crypto_account") + if err != nil { + helper.log.Warn().Err(err).Msg("Failed to clear crypto_account table") + } + _, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_olm_session") + if err != nil { + helper.log.Warn().Err(err).Msg("Failed to clear crypto_olm_session table") + } + _, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_megolm_outbound_session") + if err != nil { + helper.log.Warn().Err(err).Msg("Failed to clear crypto_megolm_outbound_session table") + } + //_, _ = helper.store.DB.Exec("DELETE FROM crypto_device") + //_, _ = helper.store.DB.Exec("DELETE FROM crypto_tracked_user") + //_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_keys") + //_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_signatures") +} + +func (helper *CryptoHelper) Reset(ctx context.Context, startAfterReset bool) error { + helper.lock.Lock() + defer helper.lock.Unlock() + helper.log.Info().Msg("Resetting end-to-bridge encryption device") + helper.Stop() + helper.log.Debug().Msg("Crypto syncer stopped, clearing database") + helper.clearDatabase(ctx) + helper.log.Debug().Msg("Crypto database cleared, logging out of all sessions") + _, err := helper.client.LogoutAll(ctx) + if err != nil { + helper.log.Warn().Err(err).Msg("Failed to log out all devices") + } + helper.client = nil + helper.store = nil + helper.mach = nil + err = helper.Init(ctx) + if err != nil { + helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Error reinitializing end-to-bridge encryption") + return ExitError{50} + } + helper.log.Info().Msg("End-to-bridge encryption successfully reset") + if startAfterReset { + go helper.Start() + } + return nil +} + +func (helper *CryptoHelper) Client() *mautrix.Client { + return helper.client +} + +func (helper *CryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) { + return helper.mach.DecryptMegolmEvent(ctx, evt) +} + +func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content *event.Content) (err error) { + helper.lock.RLock() + defer helper.lock.RUnlock() + var encrypted *event.EncryptedEventContent + encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content) + if err != nil { + if !errors.Is(err, crypto.ErrSessionExpired) && !errors.Is(err, crypto.ErrSessionNotShared) && !errors.Is(err, crypto.ErrNoGroupSession) { + return + } + helper.log.Debug().Err(err). + Str("room_id", roomID.String()). + Msg("Got error while encrypting event for room, sharing group session and trying again...") + var users []id.UserID + users, err = helper.store.GetRoomJoinedOrInvitedMembers(ctx, roomID) + if err != nil { + err = fmt.Errorf("failed to get room member list: %w", err) + } else if err = helper.mach.ShareGroupSession(ctx, roomID, users); err != nil { + err = fmt.Errorf("failed to share group session: %w", err) + } else if encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content); err != nil { + err = fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err) + } + } + if encrypted != nil { + content.Parsed = encrypted + content.Raw = nil + } + return +} + +func (helper *CryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool { + helper.lock.RLock() + defer helper.lock.RUnlock() + return helper.mach.WaitForSession(ctx, roomID, senderKey, sessionID, timeout) +} + +func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) { + helper.lock.RLock() + defer helper.lock.RUnlock() + if deviceID == "" { + deviceID = "*" + } + err := helper.mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}}) + if err != nil { + helper.log.Warn().Err(err). + Str("user_id", userID.String()). + Str("device_id", deviceID.String()). + Str("session_id", sessionID.String()). + Str("room_id", roomID.String()). + Msg("Failed to send key request") + } else { + helper.log.Debug(). + Str("user_id", userID.String()). + Str("device_id", deviceID.String()). + Str("session_id", sessionID.String()). + Str("room_id", roomID.String()). + Msg("Sent key request") + } +} + +func (helper *CryptoHelper) ResetSession(ctx context.Context, roomID id.RoomID) { + helper.lock.RLock() + defer helper.lock.RUnlock() + err := helper.mach.CryptoStore.RemoveOutboundGroupSession(ctx, roomID) + if err != nil { + helper.log.Debug().Err(err). + Str("room_id", roomID.String()). + Msg("Error manually removing outbound group session in room") + } +} + +func (helper *CryptoHelper) HandleMemberEvent(ctx context.Context, evt *event.Event) { + helper.lock.RLock() + defer helper.lock.RUnlock() + helper.mach.HandleMemberEvent(ctx, evt) +} + +// ShareKeys uploads the given number of one-time-keys to the server. +func (helper *CryptoHelper) ShareKeys(ctx context.Context) error { + return helper.mach.ShareKeys(ctx, -1) +} + +func (helper *CryptoHelper) BeeperStreamPublisher() bridgev2.BeeperStreamPublisher { + if helper == nil { + return nil + } + return helper.streams +} + +type cryptoSyncer struct { + *crypto.OlmMachine + handleSyncResponse func(context.Context, *mautrix.RespSync) []*event.Event +} + +func (syncer *cryptoSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error { + done := make(chan struct{}) + go func() { + defer func() { + if err := recover(); err != nil { + syncer.Log.Error(). + Str("since", since). + Interface("error", err). + Str("stack", string(debug.Stack())). + Msg("Processing sync response panicked") + } + done <- struct{}{} + }() + syncer.Log.Trace().Str("since", since).Msg("Starting sync response handling") + syncer.ProcessSyncResponse(ctx, resp, since) + if syncer.handleSyncResponse != nil { + syncer.handleSyncResponse(ctx, resp) + } + syncer.Log.Trace().Str("since", since).Msg("Successfully handled sync response") + }() + select { + case <-done: + case <-time.After(30 * time.Second): + syncer.Log.Warn().Str("since", since).Msg("Handling sync response is taking unusually long") + } + return nil +} + +func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) { + if errors.Is(err, mautrix.MUnknownToken) { + return 0, err + } + syncer.Log.Error().Err(err).Msg("Error /syncing, waiting 10 seconds") + return 10 * time.Second, nil +} + +func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { + everything := []event.Type{{Type: "*"}} + return &mautrix.Filter{ + Presence: &mautrix.FilterPart{NotTypes: everything}, + AccountData: &mautrix.FilterPart{NotTypes: everything}, + Room: &mautrix.RoomFilter{ + IncludeLeave: false, + Ephemeral: &mautrix.FilterPart{NotTypes: everything}, + AccountData: &mautrix.FilterPart{NotTypes: everything}, + State: &mautrix.FilterPart{NotTypes: everything}, + Timeline: &mautrix.FilterPart{NotTypes: everything}, + }, + } +} diff --git a/mautrix-patched/bridgev2/matrix/cryptoerror.go b/mautrix-patched/bridgev2/matrix/cryptoerror.go new file mode 100644 index 00000000..ea29703a --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/cryptoerror.go @@ -0,0 +1,94 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "errors" + "fmt" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ( + errDeviceNotTrusted = errors.New("your device is not trusted") + errMessageNotEncrypted = errors.New("unencrypted message") + errNoDecryptionKeys = errors.New("the bridge hasn't received the decryption keys") + errNoCrypto = errors.New("this bridge has not been configured to support encryption") +) + +func errorToHumanMessage(err error) string { + var withheld *event.RoomKeyWithheldEventContent + switch { + case errors.Is(err, errDeviceNotTrusted), errors.Is(err, errNoDecryptionKeys), errors.Is(err, errNoCrypto): + return err.Error() + case errors.Is(err, UnknownMessageIndex): + return "the keys received by the bridge can't decrypt the message" + case errors.Is(err, DuplicateMessageIndex): + return "your client encrypted multiple messages with the same key" + case errors.As(err, &withheld): + if withheld.Code == event.RoomKeyWithheldBeeperRedacted { + return "your client used an outdated encryption session" + } + return "your client refused to share decryption keys with the bridge" + case errors.Is(err, errMessageNotEncrypted): + return "the message is not encrypted" + default: + return "the bridge failed to decrypt the message" + } +} + +func deviceUnverifiedErrorWithExplanation(trust id.TrustState) error { + var explanation string + switch trust { + case id.TrustStateBlacklisted: + explanation = "device is blacklisted" + case id.TrustStateUnset: + explanation = "unverified" + case id.TrustStateUnknownDevice: + explanation = "device info not found" + case id.TrustStateForwarded: + explanation = "keys were forwarded from an unknown device" + case id.TrustStateCrossSignedUntrusted: + explanation = "cross-signing keys changed after setting up the bridge" + default: + return errDeviceNotTrusted + } + return fmt.Errorf("%w (%s)", errDeviceNotTrusted, explanation) +} + +func (br *Connector) sendCryptoStatusError(ctx context.Context, evt *event.Event, err error, errorEventID *id.EventID, retryNum int, isFinal bool) { + ms := &bridgev2.MessageStatus{ + Step: status.MsgStepDecrypted, + Status: event.MessageStatusRetriable, + ErrorReason: event.MessageStatusUndecryptable, + InternalError: err, + Message: errorToHumanMessage(err), + IsCertain: true, + SendNotice: true, + RetryNum: retryNum, + } + if !isFinal { + ms.Status = event.MessageStatusPending + // Don't send notice for first error + if retryNum == 0 { + ms.SendNotice = false + ms.DisableMSS = true + } + } + var editEventID id.EventID + if errorEventID != nil { + editEventID = *errorEventID + } + respEventID := br.internalSendMessageStatus(ctx, ms, bridgev2.StatusEventInfoFromEvent(evt), editEventID) + if errorEventID != nil && *errorEventID == "" { + *errorEventID = respEventID + } +} diff --git a/mautrix-patched/bridgev2/matrix/cryptostore.go b/mautrix-patched/bridgev2/matrix/cryptostore.go new file mode 100644 index 00000000..4c3b5d30 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/cryptostore.go @@ -0,0 +1,63 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build cgo && !nocrypto + +package matrix + +import ( + "context" + + "github.com/lib/pq" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/id" +) + +func init() { + crypto.PostgresArrayWrapper = pq.Array +} + +type SQLCryptoStore struct { + *crypto.SQLCryptoStore + UserID id.UserID + GhostIDFormat string +} + +var _ crypto.Store = (*SQLCryptoStore)(nil) + +func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, accountID string, userID id.UserID, ghostIDFormat, pickleKey string) *SQLCryptoStore { + return &SQLCryptoStore{ + SQLCryptoStore: crypto.NewSQLCryptoStore(db, log, accountID, "", []byte(pickleKey)), + UserID: userID, + GhostIDFormat: ghostIDFormat, + } +} + +func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) (members []id.UserID, err error) { + var rows dbutil.Rows + rows, err = store.DB.Query(ctx, ` + SELECT user_id FROM mx_user_profile + WHERE room_id=$1 + AND (membership='join' OR membership='invite') + AND user_id<>$2 + AND user_id NOT LIKE $3 ESCAPE '\' + `, roomID, store.UserID, store.GhostIDFormat) + if err != nil { + return + } + for rows.Next() { + var userID id.UserID + err = rows.Scan(&userID) + if err != nil { + return members, err + } else { + members = append(members, userID) + } + } + return +} diff --git a/mautrix-patched/bridgev2/matrix/directmedia.go b/mautrix-patched/bridgev2/matrix/directmedia.go new file mode 100644 index 00000000..3670ebab --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/directmedia.go @@ -0,0 +1,107 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mediaproxy" +) + +const MediaIDPrefix = "\U0001F408" +const MediaIDTruncatedHashLength = 16 +const ContentURIMaxLength = 255 + +func (br *Connector) initDirectMedia() error { + if !br.Config.DirectMedia.Enabled { + return nil + } + dmn, ok := br.Bridge.Network.(bridgev2.DirectMediableNetwork) + if !ok { + return fmt.Errorf("direct media is enabled in config, but the network connector does not support it") + } + var err error + br.MediaProxy, err = mediaproxy.NewFromConfig(br.Config.DirectMedia.BasicConfig, br.getDirectMedia) + if err != nil { + return fmt.Errorf("failed to initialize media proxy: %w", err) + } + br.MediaProxy.RegisterRoutes(br.AS.Router, br.Log.With().Str("component", "media proxy").Logger()) + br.dmaSigKey = sha256.Sum256(br.MediaProxy.GetServerKey().Priv.Seed()) + dmn.SetUseDirectMedia() + br.Log.Debug().Str("server_name", br.MediaProxy.GetServerName()).Msg("Enabled direct media access") + return nil +} + +func (br *Connector) hashMediaID(data []byte) []byte { + hasher := hmac.New(sha256.New, br.dmaSigKey[:]) + hasher.Write(data) + return hasher.Sum(nil)[:MediaIDTruncatedHashLength] +} + +func (br *Connector) GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) { + if br.MediaProxy == nil { + return "", bridgev2.ErrDirectMediaNotEnabled + } + buf := make([]byte, len(MediaIDPrefix)+len(mediaID)+MediaIDTruncatedHashLength) + copy(buf, MediaIDPrefix) + copy(buf[len(MediaIDPrefix):], mediaID) + truncatedHash := br.hashMediaID(buf[:len(MediaIDPrefix)+len(mediaID)]) + copy(buf[len(MediaIDPrefix)+len(mediaID):], truncatedHash) + mxc := id.ContentURI{ + Homeserver: br.MediaProxy.GetServerName(), + FileID: br.Config.DirectMedia.MediaIDPrefix + base64.RawURLEncoding.EncodeToString(buf), + }.CUString() + if len(mxc) > ContentURIMaxLength { + return "", fmt.Errorf("content URI too long (%d > %d)", len(mxc), ContentURIMaxLength) + } + return mxc, nil +} + +func (br *Connector) ParseContentURI(ctx context.Context, mxc id.ContentURIString) (networkid.MediaID, error) { + if br.MediaProxy == nil { + return nil, bridgev2.ErrDirectMediaNotEnabled + } + parsed, err := mxc.Parse() + if err != nil { + return nil, fmt.Errorf("failed to parse mxc URI: %w", err) + } else if parsed.Homeserver != br.MediaProxy.GetServerName() { + return nil, fmt.Errorf("mxc URI homeserver does not match media proxy server name") + } + return br.parseMediaID(parsed.FileID) +} + +func (br *Connector) parseMediaID(mediaIDStr string) (networkid.MediaID, error) { + mediaID, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(mediaIDStr, br.Config.DirectMedia.MediaIDPrefix)) + if err != nil || !bytes.HasPrefix(mediaID, []byte(MediaIDPrefix)) || len(mediaID) < len(MediaIDPrefix)+MediaIDTruncatedHashLength+1 { + return nil, mediaproxy.ErrInvalidMediaIDSyntax + } + receivedHash := mediaID[len(mediaID)-MediaIDTruncatedHashLength:] + expectedHash := br.hashMediaID(mediaID[:len(mediaID)-MediaIDTruncatedHashLength]) + if !hmac.Equal(receivedHash, expectedHash) { + return nil, mautrix.MNotFound.WithMessage("Invalid checksum in media ID part") + } + remoteMediaID := networkid.MediaID(mediaID[len(MediaIDPrefix) : len(mediaID)-MediaIDTruncatedHashLength]) + return remoteMediaID, nil +} + +func (br *Connector) getDirectMedia(ctx context.Context, mediaIDStr string, params map[string]string) (response mediaproxy.GetMediaResponse, err error) { + remoteMediaID, err := br.parseMediaID(mediaIDStr) + if err != nil { + return response, err + } + return br.Bridge.Network.(bridgev2.DirectMediableNetwork).Download(ctx, remoteMediaID, params) +} diff --git a/mautrix-patched/bridgev2/matrix/doublepuppet.go b/mautrix-patched/bridgev2/matrix/doublepuppet.go new file mode 100644 index 00000000..ace33f30 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/doublepuppet.go @@ -0,0 +1,131 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/id" +) + +type doublePuppetUtil struct { + br *Connector + + discoveryCache map[string]string + discoveryCacheLock sync.Mutex +} + +func newDoublePuppetUtil(br *Connector) *doublePuppetUtil { + return &doublePuppetUtil{ + br: br, + discoveryCache: make(map[string]string), + } +} + +func (dp *doublePuppetUtil) newClient(ctx context.Context, mxid id.UserID, accessToken string) (*mautrix.Client, error) { + _, homeserver, err := mxid.Parse() + if err != nil { + return nil, err + } + homeserverURL, found := dp.br.Config.DoublePuppet.Servers[homeserver] + if !found { + if homeserver == dp.br.AS.HomeserverDomain { + homeserverURL = "" + } else if dp.br.Config.DoublePuppet.AllowDiscovery { + dp.discoveryCacheLock.Lock() + defer dp.discoveryCacheLock.Unlock() + if homeserverURL, found = dp.discoveryCache[homeserver]; !found { + resp, err := mautrix.DiscoverClientAPI(ctx, homeserver) + if err != nil { + return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err) + } + homeserverURL = resp.Homeserver.BaseURL + dp.discoveryCache[homeserver] = homeserverURL + zerolog.Ctx(ctx).Debug(). + Str("homeserver", homeserver). + Str("url", homeserverURL). + Str("user_id", mxid.String()). + Msg("Discovered URL to enable double puppeting for user") + } + } else { + return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver) + } + } + return dp.br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL) +} + +func (dp *doublePuppetUtil) newIntent(ctx context.Context, mxid id.UserID, accessToken string) (*appservice.IntentAPI, error) { + client, err := dp.newClient(ctx, mxid, accessToken) + if err != nil { + return nil, err + } + + ia := dp.br.AS.NewIntentAPI("custom") + ia.Client = client + ia.Localpart, _, _ = mxid.Parse() + ia.UserID = mxid + ia.IsCustomPuppet = true + return ia, nil +} + +var ( + ErrMismatchingMXID = errors.New("whoami result does not match custom mxid") + ErrNoAccessToken = errors.New("no access token provided") + ErrNoMXID = errors.New("no mxid provided") +) + +const useConfigASToken = "appservice-config" +const asTokenModePrefix = "as_token:" + +func (dp *doublePuppetUtil) Setup(ctx context.Context, mxid id.UserID, savedAccessToken string) (intent *appservice.IntentAPI, newAccessToken string, err error) { + if len(mxid) == 0 { + err = ErrNoMXID + return + } + _, homeserver, _ := mxid.Parse() + loginSecret, hasSecret := dp.br.Config.DoublePuppet.Secrets[homeserver] + if hasSecret && strings.HasPrefix(loginSecret, asTokenModePrefix) { + intent, err = dp.newIntent(ctx, mxid, strings.TrimPrefix(loginSecret, asTokenModePrefix)) + if err != nil { + return + } + intent.SetAppServiceUserID = true + if savedAccessToken != useConfigASToken { + var resp *mautrix.RespWhoami + resp, err = intent.Whoami(ctx) + if err == nil && resp.UserID != mxid { + err = ErrMismatchingMXID + } + } + return intent, useConfigASToken, err + } else if savedAccessToken == "" || savedAccessToken == useConfigASToken { + err = ErrNoAccessToken + return + } + intent, err = dp.newIntent(ctx, mxid, savedAccessToken) + if err != nil { + return + } + var resp *mautrix.RespWhoami + resp, err = intent.Whoami(ctx) + if err == nil { + if resp.UserID != mxid { + err = ErrMismatchingMXID + } else { + newAccessToken = savedAccessToken + } + } + return +} diff --git a/mautrix-patched/bridgev2/matrix/exiterror.go b/mautrix-patched/bridgev2/matrix/exiterror.go new file mode 100644 index 00000000..451d7cd3 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/exiterror.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "fmt" + "os" +) + +type ExitError struct { + ExitCode int +} + +func (e ExitError) Error() string { + return fmt.Sprintf("exit with code %d", e.ExitCode) +} + +func (e ExitError) Exit() { + os.Exit(e.ExitCode) +} diff --git a/mautrix-patched/bridgev2/matrix/intent.go b/mautrix-patched/bridgev2/matrix/intent.go new file mode 100644 index 00000000..9a1c0a94 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/intent.go @@ -0,0 +1,791 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/fallocate" + "go.mau.fi/util/ptr" + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +// ASIntent implements the bridge ghost API interface using a real Matrix homeserver as the backend. +type ASIntent struct { + Matrix *appservice.IntentAPI + Connector *Connector + + dmUpdateLock sync.Mutex + directChatsCache event.DirectChatsEventContent +} + +var _ bridgev2.MatrixAPI = (*ASIntent)(nil) +var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil) + +func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { + if extra == nil { + extra = &bridgev2.MatrixSendExtra{} + } + if eventType == event.EventRedaction && !as.Connector.SpecVersions.Supports(mautrix.FeatureRedactSendAsEvent) { + parsedContent := content.Parsed.(*event.RedactionEventContent) + as.Matrix.AddDoublePuppetValue(content) + if parsedContent.DontRenderPlaceholder { + if content.Raw == nil { + content.Raw = make(map[string]any) + } + content.Raw["com.beeper.dont_render_redacted_placeholder"] = true + } + return as.Matrix.RedactEvent(ctx, roomID, parsedContent.Redacts, mautrix.ReqRedact{ + Reason: parsedContent.Reason, + Extra: content.Raw, + }) + } + if (eventType != event.EventReaction || as.Connector.Config.Encryption.MSC4392) && eventType != event.EventRedaction { + msgContent, ok := content.Parsed.(*event.MessageEventContent) + if ok && eventType == event.EventMessage { + msgContent.AddPerMessageProfileFallback() + } + if encrypted, err := as.Connector.isEncrypted(ctx, roomID); err != nil { + return nil, fmt.Errorf("failed to check if room is encrypted: %w", err) + } else if encrypted { + if as.Connector.Crypto == nil { + return nil, fmt.Errorf("room is encrypted, but bridge isn't configured to support encryption") + } + if as.Matrix.IsCustomPuppet { + if extra.Timestamp.IsZero() { + as.Matrix.AddDoublePuppetValue(content) + } else { + as.Matrix.AddDoublePuppetValueWithTS(content, extra.Timestamp.UnixMilli()) + } + } + err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content) + if err != nil { + return nil, err + } + eventType = event.EventEncrypted + } + } + return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()}) +} + +func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) { + targetContent, ok := content.Parsed.(*event.MemberEventContent) + if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" { + return + } + memberContent, err := as.Matrix.StateStore.TryGetMember(ctx, roomID, userID) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("target_user_id", userID). + Str("membership", string(targetContent.Membership)). + Msg("Failed to get old member content from state store to fill new membership event") + } else if memberContent != nil { + targetContent.Displayname = memberContent.Displayname + targetContent.AvatarURL = memberContent.AvatarURL + } else if ghost, err := as.Connector.Bridge.GetGhostByMXID(ctx, userID); err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("target_user_id", userID). + Str("membership", string(targetContent.Membership)). + Msg("Failed to get ghost to fill new membership event") + } else if ghost != nil { + targetContent.Displayname = ghost.Name + targetContent.AvatarURL = ghost.AvatarMXC + } else if profile, err := as.Matrix.GetProfile(ctx, userID); err != nil { + zerolog.Ctx(ctx).Debug().Err(err). + Stringer("target_user_id", userID). + Str("membership", string(targetContent.Membership)). + Msg("Failed to get profile to fill new membership event") + } else if profile != nil { + targetContent.Displayname = profile.DisplayName + targetContent.AvatarURL = profile.AvatarURL.CUString() + } +} + +func (as *ASIntent) SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { + if eventType == event.StateMember { + as.fillMemberEvent(ctx, roomID, id.UserID(stateKey), content) + } + resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content, mautrix.ReqSendEvent{Timestamp: ts.UnixMilli()}) + if err != nil && eventType == event.StateMember { + var httpErr mautrix.HTTPError + if errors.As(err, &httpErr) && httpErr.RespError != nil && + (strings.Contains(httpErr.RespError.Err, "is already in the room") || strings.Contains(httpErr.RespError.Err, "is already joined to room")) { + err = as.Matrix.StateStore.SetMembership(ctx, roomID, id.UserID(stateKey), event.MembershipJoin) + } + } + return resp, err +} + +func (as *ASIntent) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, ts time.Time) (err error) { + extraData := map[string]any{} + if !ts.IsZero() { + extraData["ts"] = ts.UnixMilli() + } + as.Matrix.AddDoublePuppetValue(extraData) + req := mautrix.ReqSetReadMarkers{ + Read: eventID, + BeeperReadExtra: extraData, + } + if as.Matrix.IsCustomPuppet { + req.FullyRead = eventID + req.BeeperFullyReadExtra = extraData + } + if as.Matrix.IsCustomPuppet && as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureInboxState) && as.Connector.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { + err = as.Matrix.SetBeeperInboxState(ctx, roomID, &mautrix.ReqSetBeeperInboxState{ + //MarkedUnread: ptr.Ptr(false), + ReadMarkers: &req, + }) + } else { + err = as.Matrix.SetReadMarkers(ctx, roomID, &req) + if err == nil && as.Matrix.IsCustomPuppet && as.Connector.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { + err = as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataMarkedUnread.Type, &event.MarkedUnreadEventContent{ + Unread: false, + }) + } + } + return +} + +func (as *ASIntent) MarkUnread(ctx context.Context, roomID id.RoomID, unread bool) error { + if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { + return nil + } + if as.Matrix.IsCustomPuppet && as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureInboxState) { + return as.Matrix.SetBeeperInboxState(ctx, roomID, &mautrix.ReqSetBeeperInboxState{ + MarkedUnread: ptr.Ptr(unread), + }) + } else { + return as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataMarkedUnread.Type, &event.MarkedUnreadEventContent{ + Unread: unread, + }) + } +} + +func (as *ASIntent) MarkTyping(ctx context.Context, roomID id.RoomID, typingType bridgev2.TypingType, timeout time.Duration) error { + if typingType != bridgev2.TypingTypeText { + return nil + } else if as.Matrix.IsCustomPuppet { + // Don't send double puppeted typing notifications, there's no good way to prevent echoing them + return nil + } + _, err := as.Matrix.UserTyping(ctx, roomID, timeout > 0, timeout) + return err +} + +func (as *ASIntent) DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) { + if file != nil { + uri = file.URL + } + parsedURI, err := uri.Parse() + if err != nil { + return nil, err + } + data, err := as.Matrix.DownloadBytes(ctx, parsedURI) + if err != nil { + return nil, err + } + if file != nil { + err = file.DecryptInPlace(data) + if err != nil { + return nil, err + } + } + return data, nil +} + +func (as *ASIntent) DownloadMediaToFile(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo, writable bool, callback func(*os.File) error) error { + if file != nil { + uri = file.URL + err := file.PrepareForDecryption() + if err != nil { + return err + } + } + parsedURI, err := uri.Parse() + if err != nil { + return err + } + tempFile, err := os.CreateTemp("", "mautrix-download-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + resp, err := as.Matrix.Download(ctx, parsedURI) + if err != nil { + return fmt.Errorf("failed to send download request: %w", err) + } + defer resp.Body.Close() + reader := resp.Body + if file != nil { + reader = file.DecryptStream(reader) + } + if resp.ContentLength > 0 { + err = fallocate.Fallocate(tempFile, int(resp.ContentLength)) + if err != nil { + return fmt.Errorf("failed to preallocate file: %w", err) + } + } + _, err = io.Copy(tempFile, reader) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + err = reader.Close() + if err != nil { + return fmt.Errorf("failed to close response body: %w", err) + } + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return fmt.Errorf("failed to seek to start of temp file: %w", err) + } + err = callback(tempFile) + if err != nil { + return bridgev2.CallbackError{Type: "read", Wrapped: err} + } + return nil +} + +func (as *ASIntent) UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) { + if int64(len(data)) > as.Connector.MediaConfig.UploadSize { + return "", nil, fmt.Errorf("%w (%.2f MB > %.2f MB)", bridgev2.ErrMediaTooLarge, float64(len(data))/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) + } + if roomID != "" { + var encrypted bool + if encrypted, err = as.Connector.isEncrypted(ctx, roomID); err != nil { + err = fmt.Errorf("failed to check if room is encrypted: %w", err) + return + } else if encrypted { + file = &event.EncryptedFileInfo{ + EncryptedFile: *attachment.NewEncryptedFile(), + } + file.EncryptInPlace(data) + mimeType = "application/octet-stream" + fileName = "" + } + } + url, err = as.doUploadReq(ctx, file, mautrix.ReqUploadMedia{ + ContentBytes: data, + ContentType: mimeType, + FileName: fileName, + }) + return +} + +func (as *ASIntent) UploadMediaStream( + ctx context.Context, + roomID id.RoomID, + size int64, + requireFile bool, + cb bridgev2.FileStreamCallback, +) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) { + if size > as.Connector.MediaConfig.UploadSize { + return "", nil, fmt.Errorf("%w (%.2f MB > %.2f MB)", bridgev2.ErrMediaTooLarge, float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) + } + if !requireFile && 0 < size && size < as.Connector.Config.Matrix.UploadFileThreshold { + var buf bytes.Buffer + res, err := cb(&buf) + if err != nil { + return "", nil, err + } else if res.ReplacementFile != "" { + panic(fmt.Errorf("logic error: replacement path must only be returned if requireFile is true")) + } + return as.UploadMedia(ctx, roomID, buf.Bytes(), res.FileName, res.MimeType) + } + var tempFile *os.File + tempFile, err = os.CreateTemp("", "mautrix-upload-*") + if err != nil { + err = fmt.Errorf("failed to create temp file: %w", err) + return + } + removeAndClose := func(f *os.File) { + _ = f.Close() + _ = os.Remove(f.Name()) + } + startedAsyncUpload := false + defer func() { + if !startedAsyncUpload { + removeAndClose(tempFile) + } + }() + if size > 0 { + err = fallocate.Fallocate(tempFile, int(size)) + if err != nil { + err = fmt.Errorf("failed to preallocate file: %w", err) + return + } + } + if roomID != "" { + var encrypted bool + if encrypted, err = as.Connector.isEncrypted(ctx, roomID); err != nil { + err = fmt.Errorf("failed to check if room is encrypted: %w", err) + return + } else if encrypted { + file = &event.EncryptedFileInfo{ + EncryptedFile: *attachment.NewEncryptedFile(), + } + } + } + var res *bridgev2.FileStreamResult + res, err = cb(tempFile) + if err != nil { + err = bridgev2.CallbackError{Type: "write", Wrapped: err} + return + } + var replFile *os.File + if res.ReplacementFile != "" { + replFile, err = os.OpenFile(res.ReplacementFile, os.O_RDWR, 0) + if err != nil { + err = fmt.Errorf("failed to open replacement file: %w", err) + return + } + defer func() { + if !startedAsyncUpload { + removeAndClose(replFile) + } + }() + } else { + replFile = tempFile + _, err = replFile.Seek(0, io.SeekStart) + if err != nil { + err = fmt.Errorf("failed to seek to start of temp file: %w", err) + return + } + } + if file != nil { + res.FileName = "" + res.MimeType = "application/octet-stream" + err = file.EncryptFile(replFile) + if err != nil { + err = fmt.Errorf("failed to encrypt file: %w", err) + return + } + _, err = replFile.Seek(0, io.SeekStart) + if err != nil { + err = fmt.Errorf("failed to seek to start of temp file after encrypting: %w", err) + return + } + } + info, err := replFile.Stat() + if err != nil { + err = fmt.Errorf("failed to get temp file info: %w", err) + return + } + size = info.Size() + if size > as.Connector.MediaConfig.UploadSize { + return "", nil, fmt.Errorf("%w (%.2f MB > %.2f MB)", bridgev2.ErrMediaTooLarge, float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) + } + req := mautrix.ReqUploadMedia{ + Content: replFile, + ContentLength: size, + ContentType: res.MimeType, + FileName: res.FileName, + } + if as.Connector.Config.Homeserver.AsyncMedia { + req.DoneCallback = func() { + removeAndClose(replFile) + removeAndClose(tempFile) + } + req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx) + startedAsyncUpload = true + var resp *mautrix.RespCreateMXC + resp, err = as.Matrix.UploadAsync(ctx, req) + if resp != nil { + url = resp.ContentURI.CUString() + } + } else { + var resp *mautrix.RespMediaUpload + resp, err = as.Matrix.UploadMedia(ctx, req) + if resp != nil { + url = resp.ContentURI.CUString() + } + } + if file != nil { + file.URL = url + url = "" + } + return +} + +func (as *ASIntent) doUploadReq(ctx context.Context, file *event.EncryptedFileInfo, req mautrix.ReqUploadMedia) (url id.ContentURIString, err error) { + if as.Connector.Config.Homeserver.AsyncMedia { + if req.ContentBytes != nil { + // Prevent too many background uploads at once + err = as.Connector.uploadSema.Acquire(ctx, int64(len(req.ContentBytes))) + if err != nil { + return + } + req.DoneCallback = func() { + as.Connector.uploadSema.Release(int64(len(req.ContentBytes))) + } + } + req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx) + var resp *mautrix.RespCreateMXC + resp, err = as.Matrix.UploadAsync(ctx, req) + if resp != nil { + url = resp.ContentURI.CUString() + } + } else { + var resp *mautrix.RespMediaUpload + resp, err = as.Matrix.UploadMedia(ctx, req) + if resp != nil { + url = resp.ContentURI.CUString() + } + } + if file != nil { + file.URL = url + url = "" + } + return +} + +func (as *ASIntent) SetDisplayName(ctx context.Context, name string) error { + return as.Matrix.SetDisplayName(ctx, name) +} + +func (as *ASIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error { + parsedAvatarURL, err := avatarURL.Parse() + if err != nil { + return err + } + return as.Matrix.SetAvatarURL(ctx, parsedAvatarURL) +} + +func dataToFields(data any) (map[string]json.RawMessage, error) { + fields, ok := data.(map[string]json.RawMessage) + if ok { + return fields, nil + } + d, err := canonicaljson.Marshal(data) + if err != nil { + return nil, err + } + err = json.Unmarshal(d, &fields) + return fields, err +} + +func marshalField(val any) json.RawMessage { + data, _ := canonicaljson.Marshal(val) + return data +} + +var nullJSON = json.RawMessage("null") + +func (as *ASIntent) SetProfile(ctx context.Context, data any) error { + return as.Matrix.UnstableOverwriteProfile(ctx, data) +} + +func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error { + if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { + return as.Matrix.BeeperUpdateProfile(ctx, data) + } else if as.Connector.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && as.Connector.Config.Matrix.GhostExtraProfileInfo { + fields, err := dataToFields(data) + if err != nil { + return fmt.Errorf("failed to marshal fields: %w", err) + } + currentProfile, err := as.Matrix.GetProfile(ctx, as.Matrix.UserID) + if err != nil { + return fmt.Errorf("failed to get current profile: %w", err) + } + for key, val := range fields { + existing, ok := currentProfile.Extra[key] + if !ok { + if bytes.Equal(val, nullJSON) { + continue + } + err = as.Matrix.SetProfileField(ctx, key, val) + } else if !bytes.Equal(marshalField(existing), val) { + if bytes.Equal(val, nullJSON) { + err = as.Matrix.DeleteProfileField(ctx, key) + } else { + err = as.Matrix.SetProfileField(ctx, key, val) + } + } + if err != nil { + return fmt.Errorf("failed to set profile field %q: %w", key, err) + } + } + } + return nil +} + +func (as *ASIntent) GetMXID() id.UserID { + return as.Matrix.UserID +} + +func (as *ASIntent) IsDoublePuppet() bool { + return as.Matrix.IsDoublePuppet() +} + +func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...bridgev2.EnsureJoinedParams) error { + var params bridgev2.EnsureJoinedParams + if len(extra) > 0 { + params = extra[0] + } + err := as.Matrix.EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{Via: params.Via}) + if err != nil { + return err + } + if as.Connector.Bot.UserID == as.Matrix.UserID { + _, err = as.Matrix.State(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get state after joining room with bot") + } + } + return nil +} + +func (as *ASIntent) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error { + return as.Matrix.EnsureInvited(ctx, roomID, userID) +} + +func (br *Connector) getDefaultEncryptionEvent() *event.EncryptionEventContent { + content := &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} + if rot := br.Config.Encryption.Rotation; rot.EnableCustom { + content.RotationPeriodMillis = rot.Milliseconds + content.RotationPeriodMessages = rot.Messages + } + return content +} + +func (as *ASIntent) filterCreateRequestForV12(ctx context.Context, req *mautrix.ReqCreateRoom) { + if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { + // Hungryserv doesn't override the capabilities endpoint nor do room versions + return + } + caps := as.Connector.fetchCapabilities(ctx) + roomVer := req.RoomVersion + if roomVer == "" && caps != nil && caps.RoomVersions != nil { + roomVer = id.RoomVersion(caps.RoomVersions.Default) + } + if roomVer != "" && !roomVer.PrivilegedRoomCreators() { + return + } + creators, _ := req.CreationContent["additional_creators"].([]id.UserID) + creators = append(slices.Clone(creators), as.GetMXID()) + if req.PowerLevelOverride != nil { + for _, creator := range creators { + delete(req.PowerLevelOverride.Users, creator) + } + } + for _, evt := range req.InitialState { + if evt.Type != event.StatePowerLevels { + continue + } + content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) + if ok { + for _, creator := range creators { + delete(content.Users, creator) + } + } + } +} + +func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) { + if as.Connector.Config.Encryption.Default { + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateEncryption, + Content: event.Content{ + Parsed: as.Connector.getDefaultEncryptionEvent(), + }, + }) + } + if !as.Connector.Config.Matrix.FederateRooms { + if req.CreationContent == nil { + req.CreationContent = make(map[string]any) + } + req.CreationContent["m.federate"] = false + } + as.filterCreateRequestForV12(ctx, req) + resp, err := as.Matrix.CreateRoom(ctx, req) + if err != nil { + return "", err + } + return resp.RoomID, nil +} + +func (as *ASIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id.UserID) error { + if !as.Connector.Config.Matrix.SyncDirectChatList { + return nil + } + as.dmUpdateLock.Lock() + defer as.dmUpdateLock.Unlock() + cached, ok := as.directChatsCache[withUser] + if ok && slices.Contains(cached, roomID) { + return nil + } + var directChats event.DirectChatsEventContent + err := as.Matrix.GetAccountData(ctx, event.AccountDataDirectChats.Type, &directChats) + if errors.Is(err, mautrix.MNotFound) { + directChats = make(event.DirectChatsEventContent) + } else if err != nil { + return err + } + as.directChatsCache = directChats + rooms := directChats[withUser] + if slices.Contains(rooms, roomID) { + return nil + } + directChats[withUser] = append(rooms, roomID) + err = as.Matrix.SetAccountData(ctx, event.AccountDataDirectChats.Type, &directChats) + if err != nil { + if rooms == nil { + delete(directChats, withUser) + } else { + directChats[withUser] = rooms + } + return fmt.Errorf("failed to set direct chats account data: %w", err) + } + return nil +} + +func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error { + if roomID == "" { + return nil + } + if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { + err := as.Matrix.BeeperDeleteRoom(ctx, roomID) + if err != nil { + return err + } + err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal") + } + return nil + } + members, err := as.Matrix.JoinedMembers(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to get portal members for cleanup: %w", err) + } + for member := range members.Joined { + if member == as.Matrix.UserID { + continue + } + if as.Connector.Bridge.IsGhostMXID(member) { + _, err = as.Connector.AS.Intent(member).LeaveRoom(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("user_id", member).Msg("Failed to leave room while cleaning up portal") + } + } else if !puppetsOnly { + _, err = as.Matrix.KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("user_id", member).Msg("Failed to kick user while cleaning up portal") + } + } + } + _, err = as.Matrix.LeaveRoom(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to leave room while cleaning up portal") + } + err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal") + } + return nil +} + +func (as *ASIntent) TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error { + tags, err := as.Matrix.GetTags(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to get room tags: %w", err) + } + if isTagged { + _, alreadyTagged := tags.Tags[tag] + if alreadyTagged { + return nil + } + err = as.Matrix.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{ + MauDoublePuppetSource: as.Connector.AS.DoublePuppetValue, + }) + if err != nil { + return err + } + } + for extraTag := range tags.Tags { + if extraTag == event.RoomTagFavourite || extraTag == event.RoomTagLowPriority { + err = as.Matrix.RemoveTag(ctx, roomID, extraTag) + if err != nil { + return fmt.Errorf("failed to remove extra tag %s: %w", extraTag, err) + } + } + } + return nil +} + +func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error { + var mutedUntil int64 + if until.Before(time.Now()) { + mutedUntil = 0 + } else if until == event.MutedForever { + mutedUntil = -1 + } else { + mutedUntil = until.UnixMilli() + } + if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureAccountDataMute) { + return as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataBeeperMute.Type, &event.BeeperMuteEventContent{ + MutedUntil: mutedUntil, + }) + } + if mutedUntil == 0 { + err := as.Matrix.DeletePushRule(ctx, "global", pushrules.RoomRule, string(roomID)) + // If the push rule doesn't exist, everything is fine + if errors.Is(err, mautrix.MNotFound) { + err = nil + } + return err + } else { + return as.Matrix.PutPushRule(ctx, "global", pushrules.RoomRule, string(roomID), &mautrix.ReqPutPushRule{ + Actions: []*pushrules.PushAction{}, + }) + } +} + +func (as *ASIntent) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) { + evt, err := as.Matrix.Client.GetEvent(ctx, roomID, eventID) + if err != nil { + return nil, err + } + err = evt.Content.ParseRaw(evt.Type) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("room_id", roomID).Stringer("event_id", eventID).Msg("failed to parse event content") + } + + if evt.Type == event.EventEncrypted { + if as.Connector.Crypto == nil || as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt { + return nil, errors.New("can't decrypt the event") + } + return as.Connector.Crypto.Decrypt(ctx, evt) + } + + return evt, nil +} + +func (as *ASIntent) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) { + return as.Matrix.FullStateEvent(ctx, roomID, eventType, stateKey) +} diff --git a/mautrix-patched/bridgev2/matrix/matrix.go b/mautrix-patched/bridgev2/matrix/matrix.go new file mode 100644 index 00000000..78181d18 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/matrix.go @@ -0,0 +1,251 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (br *Connector) handleRoomEvent(ctx context.Context, evt *event.Event) { + if evt.Type == event.StateMember && br.Crypto != nil && !br.Bridge.IsGhostMXID(id.UserID(evt.GetStateKey())) { + br.Crypto.HandleMemberEvent(ctx, evt) + } + if br.shouldIgnoreEvent(evt) { + return + } + if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents && evt.Type != event.StateMember { + zerolog.Ctx(ctx).Debug(). + Stringer("event_type", evt.Type). + Stringer("room_id", evt.RoomID). + Stringer("sender", evt.Sender). + Stringer("event_id", evt.ID). + Msg("Dropping event from user with no permission to send events") + br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt)) + return + } + if (evt.Type == event.EventMessage || evt.Type == event.EventSticker) && !evt.Mautrix.WasEncrypted && br.Config.Encryption.Require { + zerolog.Ctx(ctx).Warn(). + Stringer("room_id", evt.RoomID). + Stringer("sender", evt.Sender). + Stringer("event_id", evt.ID). + Msg("Dropping unencrypted event as encryption is configured to be required") + br.sendCryptoStatusError(ctx, evt, errMessageNotEncrypted, nil, 0, true) + return + } + br.Bridge.QueueMatrixEvent(ctx, evt) +} + +func (br *Connector) handleEphemeralEvent(ctx context.Context, evt *event.Event) { + switch evt.Type { + case event.EphemeralEventReceipt: + receiptContent := *evt.Content.AsReceipt() + for eventID, receipts := range receiptContent { + for receiptType, userReceipts := range receipts { + for userID, receipt := range userReceipts { + if br.shouldIgnoreEventFromUser(userID) || (br.AS.DoublePuppetValue != "" && receipt.Extra[appservice.DoublePuppetKey] == br.AS.DoublePuppetValue) { + delete(userReceipts, userID) + } + } + if len(userReceipts) == 0 { + delete(receipts, receiptType) + } + } + if len(receipts) == 0 { + delete(receiptContent, eventID) + } + } + if len(receiptContent) == 0 { + return + } + case event.EphemeralEventTyping: + typingContent := evt.Content.AsTyping() + typingContent.UserIDs = slices.DeleteFunc(typingContent.UserIDs, br.shouldIgnoreEventFromUser) + } + br.Bridge.QueueMatrixEvent(ctx, evt) +} + +func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event) { + if br.shouldIgnoreEvent(evt) { + return + } + content := evt.Content.AsEncrypted() + log := zerolog.Ctx(ctx).With(). + Str("event_id", evt.ID.String()). + Str("session_id", content.SessionID.String()). + Logger() + if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents { + log.Debug(). + Stringer("room_id", evt.RoomID). + Stringer("sender", evt.Sender). + Stringer("event_id", evt.ID). + Msg("Dropping encrypted event from user with no permission to send events") + br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt)) + return + } + ctx = log.WithContext(ctx) + if br.Crypto == nil { + br.sendCryptoStatusError(ctx, evt, errNoCrypto, nil, 0, true) + log.Error().Msg("Can't decrypt message: no crypto") + return + } + log.Debug().Msg("Decrypting received event") + + decryptionStart := time.Now() + decrypted, err := br.Crypto.Decrypt(ctx, evt) + decryptionRetryCount := 0 + var errorEventID id.EventID + if errors.Is(err, NoSessionFound) { + decryptionRetryCount = 1 + log.Debug(). + Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). + Msg("Couldn't find session, waiting for keys to arrive...") + go br.sendCryptoStatusError(ctx, evt, err, &errorEventID, 0, false) + if br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { + log.Debug().Msg("Got keys after waiting, trying to decrypt event again") + decrypted, err = br.Crypto.Decrypt(ctx, evt) + } else { + go br.waitLongerForSession(ctx, evt, decryptionStart, &errorEventID) + return + } + } + if err != nil { + log.Warn().Err(err).Msg("Failed to decrypt event") + go br.sendCryptoStatusError(ctx, evt, err, nil, decryptionRetryCount, true) + return + } + br.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, &errorEventID, time.Since(decryptionStart)) +} + +func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time, errorEventID *id.EventID) { + log := zerolog.Ctx(ctx) + content := evt.Content.AsEncrypted() + log.Debug(). + Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())). + Msg("Couldn't find session, requesting keys and waiting longer...") + + //lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank + go br.Crypto.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) + go br.sendCryptoStatusError(ctx, evt, fmt.Errorf("%w. The bridge will retry for %d seconds", errNoDecryptionKeys, int(extendedSessionWaitTimeout.Seconds())), errorEventID, 1, false) + + if !br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) { + log.Debug().Msg("Didn't get session, giving up trying to decrypt event") + go br.sendCryptoStatusError(ctx, evt, errNoDecryptionKeys, errorEventID, 2, true) + return + } + + log.Debug().Msg("Got keys after waiting longer, trying to decrypt event again") + decrypted, err := br.Crypto.Decrypt(ctx, evt) + if err != nil { + log.Error().Err(err).Msg("Failed to decrypt event") + go br.sendCryptoStatusError(ctx, evt, err, errorEventID, 2, true) + return + } + + br.postDecrypt(ctx, evt, decrypted, 2, errorEventID, time.Since(decryptionStart)) +} + +type CommandProcessor interface { + Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user bridgev2.User, message string, replyTo id.EventID) +} + +func (br *Connector) sendSuccessCheckpoint(ctx context.Context, evt *event.Event, step status.MessageCheckpointStep, retryNum int) { + err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{{ + RoomID: evt.RoomID, + EventID: evt.ID, + EventType: evt.Type, + MessageType: evt.Content.AsMessage().MsgType, + Step: step, + Timestamp: jsontime.UnixMilliNow(), + Status: status.MsgStatusSuccess, + ReportedBy: status.MsgReportedByBridge, + RetryNum: retryNum, + }}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("checkpoint_step", string(step)).Msg("Failed to send checkpoint") + } +} + +func (br *Connector) sendBridgeCheckpoint(ctx context.Context, evt *event.Event) { + if !evt.Mautrix.CheckpointSent { + go br.sendSuccessCheckpoint(ctx, evt, status.MsgStepBridge, 0) + } +} + +func (br *Connector) shouldIgnoreEventFromUser(userID id.UserID) bool { + return userID == br.Bot.UserID || br.Bridge.IsGhostMXID(userID) +} + +func (br *Connector) shouldIgnoreEvent(evt *event.Event) bool { + if br.shouldIgnoreEventFromUser(evt.Sender) && evt.Type != event.StateTombstone { + return true + } + dpVal, ok := evt.Content.Raw[appservice.DoublePuppetKey] + if ok && dpVal == br.AS.DoublePuppetValue { + dpTS, ok := evt.Content.Raw[appservice.DoublePuppetTSKey].(float64) + if !ok || int64(dpTS) == evt.Timestamp { + return true + } + } + return false +} + +const initialSessionWaitTimeout = 3 * time.Second +const extendedSessionWaitTimeout = 22 * time.Second + +func copySomeKeys(original, decrypted *event.Event) { + isScheduled, _ := original.Content.Raw["com.beeper.scheduled"].(bool) + _, alreadyExists := decrypted.Content.Raw["com.beeper.scheduled"] + if isScheduled && !alreadyExists { + decrypted.Content.Raw["com.beeper.scheduled"] = true + } +} + +func (br *Connector) postDecrypt(ctx context.Context, original, decrypted *event.Event, retryCount int, errorEventID *id.EventID, duration time.Duration) { + log := zerolog.Ctx(ctx) + minLevel := br.Config.Encryption.VerificationLevels.Send + if decrypted.Mautrix.TrustState < minLevel { + logEvt := log.Warn(). + Str("user_id", decrypted.Sender.String()). + Bool("forwarded_keys", decrypted.Mautrix.ForwardedKeys). + Stringer("device_trust", decrypted.Mautrix.TrustState). + Stringer("min_trust", minLevel) + if decrypted.Mautrix.TrustSource != nil { + dev := decrypted.Mautrix.TrustSource + logEvt. + Str("device_id", dev.DeviceID.String()). + Str("device_signing_key", dev.SigningKey.String()) + } else { + logEvt.Str("device_id", "unknown") + } + logEvt.Msg("Dropping event due to insufficient verification level") + err := deviceUnverifiedErrorWithExplanation(decrypted.Mautrix.TrustState) + go br.sendCryptoStatusError(ctx, decrypted, err, errorEventID, retryCount, true) + return + } + copySomeKeys(original, decrypted) + + go br.sendSuccessCheckpoint(ctx, decrypted, status.MsgStepDecrypted, retryCount) + decrypted.Mautrix.CheckpointSent = true + decrypted.Mautrix.DecryptionDuration = duration + br.EventProcessor.Dispatch(ctx, decrypted) + if errorEventID != nil && *errorEventID != "" { + _, _ = br.Bot.RedactEvent(ctx, decrypted.RoomID, *errorEventID) + } +} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/config.go b/mautrix-patched/bridgev2/matrix/mxmain/config.go new file mode 100644 index 00000000..a684d8a2 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/config.go @@ -0,0 +1,36 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mxmain + +import ( + _ "embed" + "strings" + "text/template" + + "go.mau.fi/util/exerrors" +) + +//go:embed example-config.yaml +var MatrixExampleConfigBase string + +var matrixExampleConfigBaseTemplate = exerrors.Must(template.New("example-config.yaml"). + Delims("$<<", ">>"). + Parse(MatrixExampleConfigBase)) + +func (br *BridgeMain) makeFullExampleConfig(networkExample string) string { + var buf strings.Builder + buf.WriteString("# Network-specific config options\n") + buf.WriteString("network:\n") + for _, line := range strings.Split(networkExample, "\n") { + buf.WriteString(" ") + buf.WriteString(line) + buf.WriteRune('\n') + } + buf.WriteRune('\n') + exerrors.PanicIfNotNil(matrixExampleConfigBaseTemplate.Execute(&buf, br.Connector.GetName())) + return buf.String() +} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/dberror.go b/mautrix-patched/bridgev2/matrix/mxmain/dberror.go new file mode 100644 index 00000000..f5e438de --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/dberror.go @@ -0,0 +1,79 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mxmain + +import ( + "errors" + "os" + + "github.com/lib/pq" + "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + + "go.mau.fi/util/dbutil" +) + +type zerologPQError pq.Error + +func (zpe *zerologPQError) MarshalZerologObject(evt *zerolog.Event) { + maybeStr := func(field, value string) { + if value != "" { + evt.Str(field, value) + } + } + maybeStr("severity", zpe.Severity) + if name := zpe.Code.Name(); name != "" { + evt.Str("code", name) + } else if zpe.Code != "" { + evt.Str("code", string(zpe.Code)) + } + //maybeStr("message", zpe.Message) + maybeStr("detail", zpe.Detail) + maybeStr("hint", zpe.Hint) + maybeStr("position", zpe.Position) + maybeStr("internal_position", zpe.InternalPosition) + maybeStr("internal_query", zpe.InternalQuery) + maybeStr("where", zpe.Where) + maybeStr("schema", zpe.Schema) + maybeStr("table", zpe.Table) + maybeStr("column", zpe.Column) + maybeStr("data_type_name", zpe.DataTypeName) + maybeStr("constraint", zpe.Constraint) + maybeStr("file", zpe.File) + maybeStr("line", zpe.Line) + maybeStr("routine", zpe.Routine) +} + +func (br *BridgeMain) LogDBUpgradeErrorAndExit(name string, err error, message string) { + logEvt := br.Log.WithLevel(zerolog.FatalLevel). + Err(err). + Str("db_section", name) + var errWithLine *dbutil.PQErrorWithLine + if errors.As(err, &errWithLine) { + logEvt.Str("sql_line", errWithLine.Line) + } + var pqe *pq.Error + if errors.As(err, &pqe) { + logEvt.Object("pq_error", (*zerologPQError)(pqe)) + } + logEvt.Msg(message) + if sqlError := (&sqlite3.Error{}); errors.As(err, sqlError) && sqlError.Code == sqlite3.ErrCorrupt { + os.Exit(18) + } else if errors.Is(err, dbutil.ErrForeignTables) { + br.Log.Info().Msg("See https://docs.mau.fi/faq/foreign-tables for more info") + } else if errors.Is(err, dbutil.ErrNotOwned) { + var noe dbutil.NotOwnedError + if errors.As(err, &noe) && noe.Owner == br.Name { + br.Log.Info().Msg("The database appears to be on a very old pre-megabridge schema. Perhaps you need to run an older version of the bridge with migration support first?") + } else { + br.Log.Info().Msg("Sharing the same database with different programs is not supported") + } + } else if errors.Is(err, dbutil.ErrUnsupportedDatabaseVersion) { + br.Log.Info().Msg("Downgrading the bridge is not supported") + } + os.Exit(15) +} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/envconfig.go b/mautrix-patched/bridgev2/matrix/mxmain/envconfig.go new file mode 100644 index 00000000..f291b828 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/envconfig.go @@ -0,0 +1,191 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mxmain + +import ( + "fmt" + "iter" + "os" + "reflect" + "strconv" + "strings" + + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/bridgev2" +) + +func LoadGlobalConfigEnv() { + peb, err := strconv.Atoi(os.Getenv("MAUTRIX_PORTAL_EVENT_BUFFER")) + if err == nil && peb >= 0 { + bridgev2.PortalEventBuffer = peb + } + pose, err := strconv.ParseBool(os.Getenv("MAUTRIX_PANIC_ON_STUCK_EVENT")) + if err == nil { + bridgev2.PanicOnStuckEvent = pose + } + ehtt, err := strconv.Atoi(os.Getenv("MAUTRIX_EVENT_HANDLING_TIMEOUT_TICKS")) + if err == nil && ehtt > 0 { + bridgev2.EventHandlingTimeoutTicks = ehtt + } +} + +var randomParseFilePrefix = random.String(16) + "READFILE:" + +func parseEnv(prefix string) iter.Seq2[[]string, string] { + return func(yield func([]string, string) bool) { + for _, s := range os.Environ() { + if !strings.HasPrefix(s, prefix) { + continue + } + kv := strings.SplitN(s, "=", 2) + key := strings.TrimPrefix(kv[0], prefix) + value := kv[1] + if strings.HasSuffix(key, "_FILE") { + key = strings.TrimSuffix(key, "_FILE") + value = randomParseFilePrefix + value + } + key = strings.ToLower(key) + if !strings.ContainsRune(key, '.') { + key = strings.ReplaceAll(key, "__", ".") + } + if !yield(strings.Split(key, "."), value) { + return + } + } + } +} + +func reflectYAMLFieldName(f *reflect.StructField) string { + parts := strings.SplitN(f.Tag.Get("yaml"), ",", 2) + fieldName := parts[0] + if fieldName == "-" && len(parts) == 1 { + return "" + } + if fieldName == "" { + return strings.ToLower(f.Name) + } + return fieldName +} + +type reflectGetResult struct { + val reflect.Value + valKind reflect.Kind + remainingPath []string +} + +func reflectGetYAML(rv reflect.Value, path []string) (*reflectGetResult, bool) { + if len(path) == 0 { + return &reflectGetResult{val: rv, valKind: rv.Kind()}, true + } + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Map: + return &reflectGetResult{val: rv, remainingPath: path, valKind: rv.Type().Elem().Kind()}, true + case reflect.Struct: + fields := reflect.VisibleFields(rv.Type()) + for _, field := range fields { + fieldName := reflectYAMLFieldName(&field) + if fieldName != "" && fieldName == path[0] { + return reflectGetYAML(rv.FieldByIndex(field.Index), path[1:]) + } + } + default: + } + return nil, false +} + +func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectGetResult, bool) { + if len(path) > 0 && path[0] == "network" { + return reflectGetYAML(network, path[1:]) + } + return reflectGetYAML(main, path) +} + +func formatKeyString(key []string) string { + return strings.Join(key, "->") +} + +func UpdateConfigFromEnv(cfg, networkData any, prefix string) error { + cfgVal := reflect.ValueOf(cfg) + networkVal := reflect.ValueOf(networkData) + for key, value := range parseEnv(prefix) { + field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, key) + if !ok { + return fmt.Errorf("%s not found", formatKeyString(key)) + } + if strings.HasPrefix(value, randomParseFilePrefix) { + filepath := strings.TrimPrefix(value, randomParseFilePrefix) + fileData, err := os.ReadFile(filepath) + if err != nil { + return fmt.Errorf("failed to read file %s for %s: %w", filepath, formatKeyString(key), err) + } + value = strings.TrimSpace(string(fileData)) + } + var parsedVal any + var err error + switch field.valKind { + case reflect.String: + parsedVal = value + case reflect.Bool: + parsedVal, err = strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + parsedVal, err = strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + parsedVal, err = strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + case reflect.Float32, reflect.Float64: + parsedVal, err = strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) + } + default: + return fmt.Errorf("unsupported type %s in %s", field.valKind, formatKeyString(key)) + } + if field.val.Kind() == reflect.Ptr { + if field.val.IsNil() { + field.val.Set(reflect.New(field.val.Type().Elem())) + } + field.val = field.val.Elem() + } + if field.val.Kind() == reflect.Map { + key = key[:len(key)-len(field.remainingPath)] + mapKeyStr := strings.Join(field.remainingPath, ".") + key = append(key, mapKeyStr) + if field.val.Type().Key().Kind() != reflect.String { + return fmt.Errorf("unsupported map key type %s in %s", field.val.Type().Key().Kind(), formatKeyString(key)) + } + field.val.SetMapIndex(reflect.ValueOf(mapKeyStr), reflect.ValueOf(parsedVal)) + } else { + switch typedVal := parsedVal.(type) { + case int64: + field.val.SetInt(typedVal) + case uint64: + field.val.SetUint(typedVal) + case float64: + field.val.SetFloat(typedVal) + case bool: + field.val.SetBool(typedVal) + case string: + field.val.SetString(typedVal) + default: + return fmt.Errorf("unexpected parsed value type %T", parsedVal) + } + } + } + return nil +} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml b/mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml new file mode 100644 index 00000000..1740634c --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml @@ -0,0 +1,513 @@ +# Config options that affect the central bridge module. +bridge: + # The prefix for commands. Only required in non-management rooms. + command_prefix: '$<>' + # Should the bridge create a space for each login containing the rooms that account is in? + personal_filtering_spaces: true + # Whether the bridge should set names and avatars explicitly for DM portals. + # This is only necessary when using clients that don't support MSC4171. + private_chat_portal_meta: true + # Should events be handled asynchronously within portal rooms? + # If true, events may end up being out of order, but slow events won't block other ones. + # This is not yet safe to use. + async_events: false + # Should every user have their own portals rather than sharing them? + # By default, users who are in the same group on the remote network will be + # in the same Matrix room bridged to that group. If this is set to true, + # every user will get their own Matrix room instead. + # SETTING THIS IS IRREVERSIBLE AND POTENTIALLY DESTRUCTIVE IF PORTALS ALREADY EXIST. + split_portals: false + # Should the bridge resend `m.bridge` events to all portals on startup? + resend_bridge_info: false + # Should `m.bridge` events be sent without a state key? + # By default, the bridge uses a unique key that won't conflict with other bridges. + no_bridge_info_state_key: false + # Should bridge connection status be sent to the management room as `m.notice` events? + # These contain the same data that can be posted to an external HTTP server using homeserver -> status_endpoint. + # Allowed values: none, errors, all + bridge_status_notices: errors + # How long after an unknown error should the bridge attempt a full reconnect? + # Must be at least 1 minute. The bridge will add an extra ±20% jitter to this value. + unknown_error_auto_reconnect: null + # Maximum number of times to do the auto-reconnect above. + # The counter is per login, but is never reset except on logout and restart. + unknown_error_max_auto_reconnects: 10 + + # Should leaving Matrix rooms be bridged as leaving groups on the remote network? + bridge_matrix_leave: false + # Should `m.notice` messages be bridged? + bridge_notices: false + # Should room tags only be synced when creating the portal? Tags mean things like favorite/pin and archive/low priority. + # Tags currently can't be synced back to the remote network, so a continuous sync means tagging from Matrix will be undone. + tag_only_on_create: true + # List of tags to allow bridging. If empty, no tags will be bridged. + only_bridge_tags: [m.favourite, m.lowpriority] + # Should room mute status only be synced when creating the portal? + # Like tags, mutes can't currently be synced back to the remote network. + mute_only_on_create: true + # Should the bridge check the db to ensure that incoming events haven't been handled before + deduplicate_matrix_messages: false + # Should cross-room reply metadata be bridged? + # Most Matrix clients don't support this and servers may reject such messages too. + cross_room_replies: false + # If a state event fails to bridge, should the bridge revert any state changes made by that event? + revert_failed_state_changes: false + # In portals with no relay set, should Matrix users be kicked if they're + # not logged into an account that's in the remote chat? + kick_matrix_users: true + # Should the bridge listen to com.beeper.state_request events? + # This is not necessary for anything outside of Beeper. + enable_send_state_requests: false + # Should the com.beeper.bridge.identifiers list in global ghost profiles include phone numbers? + phone_numbers_in_profile: false + + # What should be done to portal rooms when a user logs out or is logged out? + # Permitted values: + # nothing - Do nothing, let the user stay in the portals + # kick - Remove the user from the portal rooms, but don't delete them + # unbridge - Remove all ghosts in the room and disassociate it from the remote chat + # delete - Remove all ghosts and users from the room (i.e. delete it) + cleanup_on_logout: + # Should cleanup on logout be enabled at all? + enabled: false + # Settings for manual logouts (explicitly initiated by the Matrix user) + manual: + # Action for private portals which will never be shared with other Matrix users. + private: nothing + # Action for portals with a relay user configured. + relayed: nothing + # Action for portals which may be shared, but don't currently have any other Matrix users. + shared_no_users: nothing + # Action for portals which have other logged-in Matrix users. + shared_has_users: nothing + # Settings for credentials being invalidated (initiated by the remote network, possibly through user action). + # Keys have the same meanings as in the manual section. + bad_credentials: + private: nothing + relayed: nothing + shared_no_users: nothing + shared_has_users: nothing + + # Settings for relay mode + relay: + # Whether relay mode should be allowed. If allowed, the set-relay command can be used to turn any + # authenticated user into a relaybot for that chat. + enabled: false + # Should only admins be allowed to set themselves as relay users? + # If true, non-admins can only set users listed in default_relays as relays in a room. + admin_only: true + # Should default relays be preferred when an explicit login ID isn't specified even if the user is logged in? + # This applies to the set-relay and bridge commands sent by any user, including admins. + prefer_default: true + # Should non-admins be allowed to use the bridge and sync-chat commands via default relays specified below? + allow_bridge: true + # List of user login IDs which anyone can set as a relay, as long as the relay user is in the room. + default_relays: [] + # The formats to use when sending messages via the relaybot. + # Available variables: + # .Sender.UserID - The Matrix user ID of the sender. + # .Sender.Displayname - The display name of the sender (if set). + # .Sender.RequiresDisambiguation - Whether the sender's name may be confused with the name of another user in the room. + # .Sender.DisambiguatedName - The disambiguated name of the sender. This will be the displayname if set, + # plus the user ID in parentheses if the displayname is not unique. + # If the displayname is not set, this is just the user ID. + # .Message - The `formatted_body` field of the message. + # .Caption - The `formatted_body` field of the message, if it's a caption. Otherwise an empty string. + # .FileName - The name of the file being sent. + message_formats: + m.text: "{{ .Sender.DisambiguatedName }}: {{ .Message }}" + m.notice: "{{ .Sender.DisambiguatedName }}: {{ .Message }}" + m.emote: "* {{ .Sender.DisambiguatedName }} {{ .Message }}" + m.file: "{{ .Sender.DisambiguatedName }} sent a file{{ if .Caption }}: {{ .Caption }}{{ end }}" + m.image: "{{ .Sender.DisambiguatedName }} sent an image{{ if .Caption }}: {{ .Caption }}{{ end }}" + m.audio: "{{ .Sender.DisambiguatedName }} sent an audio file{{ if .Caption }}: {{ .Caption }}{{ end }}" + m.video: "{{ .Sender.DisambiguatedName }} sent a video{{ if .Caption }}: {{ .Caption }}{{ end }}" + m.location: "{{ .Sender.DisambiguatedName }} sent a location{{ if .Caption }}: {{ .Caption }}{{ end }}" + # For networks that support per-message displaynames (i.e. Slack and Discord), the template for those names. + # This has all the Sender variables available under message_formats (but without the .Sender prefix). + # Note that you need to manually remove the displayname from message_formats above. + displayname_format: "{{ .DisambiguatedName }}" + + # Filter for automatically creating portals. + portal_create_filter: + # The mode for filtering, either `deny` or `allow` + mode: deny + # The list of portal IDs to deny or allow depending on the mode config. + # Items here can either be the plain portal ID as a string, or an object with `id` and `receiver` fields. + # The receiver field is necessary if you want to target a specific DM portal for example. + list: [] + # A list of user login IDs from which to always deny creating portals. + # This is meant to be used with default relays, such that the relay bot + # being added to a group wouldn't automatically trigger portal creation. + always_deny_from_login: [] + + # Permissions for using the bridge. + # Permitted values: + # relay - Talk through the relaybot (if enabled), no access otherwise + # commands - Access to use commands in the bridge, but not login. + # user - Access to use the bridge with puppeting. + # admin - Full access, user level with some additional administration tools. + # Permitted keys: + # * - All Matrix users + # domain - All users on that homeserver + # mxid - Specific user + permissions: + "*": relay + "example.com": user + "@admin:example.com": admin + +# Config for the bridge's database. +database: + # The database type. "sqlite3-fk-wal" and "postgres" are supported. + type: postgres + # The database URI. + # SQLite: A raw file path is supported, but `file:?_txlock=immediate` is recommended. + # https://github.com/mattn/go-sqlite3#connection-string + # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable + # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql + uri: postgres://user:password@host/database?sslmode=disable + # Maximum number of connections. + max_open_conns: 5 + max_idle_conns: 1 + # Maximum connection idle time and lifetime before they're closed. Disabled if null. + # Parsed with https://pkg.go.dev/time#ParseDuration + max_conn_idle_time: null + max_conn_lifetime: null + +# Homeserver details. +homeserver: + # The address that this appservice can use to connect to the homeserver. + # Local addresses without HTTPS are generally recommended when the bridge is running on the same machine, + # but https also works if they run on different machines. + address: http://example.localhost:8008 + # The domain of the homeserver (also known as server_name, used for MXIDs, etc). + domain: example.com + + # What software is the homeserver running? + # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. + software: standard + # The URL to push real-time bridge status to. + # If set, the bridge will make POST requests to this URL whenever a user's remote network connection state changes. + # The bridge will use the appservice as_token to authorize requests. + status_endpoint: + # Endpoint for reporting per-message status. + # If set, the bridge will make POST requests to this URL when processing a message from Matrix. + # It will make one request when receiving the message (step BRIDGE), one after decrypting if applicable + # (step DECRYPTED) and one after sending to the remote network (step REMOTE). Errors will also be reported. + # The bridge will use the appservice as_token to authorize requests. + message_send_checkpoint_endpoint: + # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? + async_media: false + + # Should the bridge use a websocket for connecting to the homeserver? + # The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy, + # mautrix-asmux (deprecated), and hungryserv (proprietary). + websocket: false + # How often should the websocket be pinged? Pinging will be disabled if this is zero. + ping_interval_seconds: 0 + # When requests to the homeserver fail with a 502/503/504/429 status or a network error, + # how many times should the bridge retry the request before giving up? + retry_limit: 4 + +# Application service host/registration related details. +# Changing these values requires regeneration of the registration (except when noted otherwise) +appservice: + # The address that the homeserver can use to connect to this appservice. + # Like the homeserver address, a local non-https address is recommended when the bridge is on the same machine. + # If the bridge is elsewhere, you must secure the connection yourself (e.g. with https or wireguard) + # If you want to use https, you need to use a reverse proxy. The bridge does not have TLS support built in. + address: http://localhost:$<> + # A public address that external services can use to reach this appservice. + # This is only needed for things like public media. A reverse proxy is generally necessary when using this field. + # This value doesn't affect the registration file. + public_address: https://bridge.example.com + + # The hostname and port where this appservice should listen. + # For Docker, you generally have to change the hostname to 0.0.0.0. + hostname: 127.0.0.1 + port: $<> + + # The unique ID of this appservice. + id: $<<.NetworkID>> + # Appservice bot details. + bot: + # Username of the appservice bot. + username: $<<.NetworkID>>bot + # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty + # to leave display name/avatar as-is. + displayname: $<<.DisplayName>> bridge bot + avatar: $<<.NetworkIcon>> + + # Whether to receive ephemeral events via appservice transactions. + ephemeral_events: true + # Should incoming events be handled asynchronously? + # This may be necessary for large public instances with lots of messages going through. + # However, messages will not be guaranteed to be bridged in the same order they were sent in. + # This value doesn't affect the registration file. + async_transactions: false + + # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. + as_token: "This value is generated when generating the registration" + hs_token: "This value is generated when generating the registration" + + # Localpart template of MXIDs for remote users. + # {{.}} is replaced with the internal ID of the user. + username_template: $<<.NetworkID>>_{{.}} + +# Config options that affect the Matrix connector of the bridge. +matrix: + # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. + message_status_events: false + # Whether the bridge should send a read receipt after successfully bridging a message. + delivery_receipts: false + # Whether the bridge should send error notices via m.notice events when a message fails to bridge. + message_error_notices: true + # Whether the bridge should update the m.direct account data event when double puppeting is enabled. + sync_direct_chat_list: true + # Whether created rooms should have federation enabled. If false, created portal rooms + # will never be federated. Changing this option requires recreating rooms. + federate_rooms: true + # The threshold as bytes after which the bridge should roundtrip uploads via the disk + # rather than keeping the whole file in memory. + upload_file_threshold: 5242880 + # Should the bridge set additional custom profile info for ghosts? + # This can make a lot of requests, as there's no batch profile update endpoint. + ghost_extra_profile_info: false + +# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors. +analytics: + # API key to send with tracking requests. Tracking is disabled if this is null. + token: null + # Address to send tracking requests to. + url: https://api.segment.io/v1/track + # Optional user ID for tracking events. If null, defaults to using Matrix user ID. + user_id: null + +# Settings for provisioning API +provisioning: + # Shared secret for authentication. If set to "generate" or null, a random secret will be generated, + # or if set to "disable", the provisioning API will be disabled. Must be at least 16 characters. + shared_secret: generate + # Whether to allow provisioning API requests to be authed using Matrix access tokens. + # This follows the same rules as double puppeting to determine which server to contact to check the token, + # which means that by default, it only works for users on the same server as the bridge. + allow_matrix_auth: true + # Enable debug API at /debug with provisioning authentication. + debug_endpoints: false + # Enable session transfers between bridges. Note that this only validates Matrix or shared secret + # auth before passing live network client credentials down in the response. + enable_session_transfers: false + # Should the provisioning API fail immediately if a webauthn step is returned? + # This is mostly useful for clients that don't yet support it and want a trackable error instead. + fail_on_webauthn: false + +# Some networks require publicly accessible media download links (e.g. for user avatars when using Discord webhooks). +# These settings control whether the bridge will provide such public media access. +public_media: + # Should public media be enabled at all? + # The public_address field under the appservice section MUST be set when enabling public media. + enabled: false + # A key for signing public media URLs. + # If set to "generate", a random key will be generated. + signing_key: generate + # Number of seconds that public media URLs are valid for. + # If set to 0, URLs will never expire. + expiry: 0 + # Length of hash to use for public media URLs. Must be between 0 and 32. + hash_length: 32 + # The path prefix for generated URLs. Note that this will NOT change the path where media is actually served. + # If you change this, you must configure your reverse proxy to rewrite the path accordingly. + path_prefix: /_mautrix/publicmedia + # Should the bridge store media metadata in the database in order to support encrypted media and generate shorter URLs? + # If false, the generated URLs will just have the MXC URI and a HMAC signature. + # The hash_length field will be used to decide the length of the generated URL. + # This also allows invalidating URLs by deleting the database entry. + use_database: false + +# Settings for converting remote media to custom mxc:// URIs instead of reuploading. +# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html +direct_media: + # Should custom mxc:// URIs be used instead of reuploading media? + enabled: false + # The server name to use for the custom mxc:// URIs. + # This server name will effectively be a real Matrix server, it just won't implement anything other than media. + # You must either set up .well-known delegation from this domain to the bridge, or proxy the domain directly to the bridge. + server_name: discord-media.example.com + # Optionally a custom .well-known response. This defaults to `server_name:443` + well_known_response: + # Optionally specify a custom prefix for the media ID part of the MXC URI. + media_id_prefix: + # If the remote network supports media downloads over HTTP, then the bridge will use MSC3860/MSC3916 + # media download redirects if the requester supports it. Optionally, you can force redirects + # and not allow proxying at all by setting this to false. + # This option does nothing if the remote network does not support media downloads over HTTP. + allow_proxy: true + # Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file. + # This key is also used to sign the mxc:// URIs to ensure only the bridge can generate them. + server_key: generate + +# Settings for backfilling messages. +# Note that the exact way settings are applied depends on the network connector. +# See https://docs.mau.fi/bridges/general/backfill.html for more details. +backfill: + # Whether to do backfilling at all. + enabled: false + # Maximum number of messages to backfill in empty rooms. + # If this is zero or negative, backfill will be disabled in new rooms. + max_initial_messages: 50 + # Maximum number of missed messages to backfill after bridge restarts. + max_catchup_messages: 500 + # If a backfilled chat is older than this number of hours, + # mark it as read even if it's unread on the remote network. + unread_hours_threshold: 720 + # Settings for backfilling threads within other backfills. + threads: + # Maximum number of messages to backfill in a new thread. + max_initial_messages: 50 + # Settings for the backwards backfill queue. This only applies when connecting to + # Beeper as standard Matrix servers don't support inserting messages into history. + queue: + # Should the backfill queue be enabled? + enabled: false + # Should manual calls to backfill queue tasks be allowed? + manual: false + # Number of messages to backfill in one batch. + batch_size: 100 + # Delay between batches in seconds. + batch_delay: 20 + # Maximum number of batches to backfill per portal. + # If set to -1, all available messages will be backfilled. + max_batches: -1 + # Optional network-specific overrides for max batches. + # Interpretation of this field depends on the network connector. + max_batches_override: {} + +# Settings for enabling double puppeting +double_puppet: + # Servers to always allow double puppeting from. + # This is only for other servers and should NOT contain the server the bridge is on. + servers: + anotherserver.example.org: https://matrix.anotherserver.example.org + # Whether to allow client API URL discovery for other servers. When using this option, + # users on other servers can use double puppeting even if their server URLs aren't + # explicitly added to the servers map above. + allow_discovery: false + # Shared secrets for automatic double puppeting. + # See https://docs.mau.fi/bridges/general/double-puppeting.html for instructions. + secrets: + example.com: as_token:foobar + +# End-to-bridge encryption support options. +# +# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. +encryption: + # Whether to enable encryption at all. If false, the bridge will not function in encrypted rooms. + allow: false + # Whether to force-enable encryption in all bridged rooms. + default: false + # Whether to require all messages to be encrypted and drop any unencrypted messages. + require: false + # Whether to use MSC3202/MSC4203 instead of /sync long polling for receiving encryption-related data. + # This is an experimental option, see the docs for more info. + # Changing this option requires updating the appservice registration file. + appservice: false + # Whether to use MSC4190 instead of appservice login to create the bridge bot device. + # Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202. + # Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861). + msc4190: false + # Whether to encrypt reactions and reply metadata as per MSC4392. + # This is not supported by most clients. + msc4392: false + # Should the bridge bot generate a recovery key and cross-signing keys and verify itself? + # Note that without the latest version of MSC4190, this will fail if you reset the bridge database. + # The generated recovery key will be saved in the kv_store table under `recovery_key`. + self_sign: false + # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. + # You must use a client that supports requesting keys from other users to use this feature. + allow_key_sharing: true + # Should m.mentions be sent in the unencrypted content? This is non-standard and should not be enabled. + plaintext_mentions: false + # Pickle key for encrypting encryption keys in the bridge database. + # If set to generate, a random key will be generated. + pickle_key: generate + # Options for deleting megolm sessions from the bridge. + delete_keys: + # Beeper-specific: delete outbound sessions when hungryserv confirms + # that the user has uploaded the key to key backup. + delete_outbound_on_ack: false + # Don't store outbound sessions in the inbound table. + dont_store_outbound: false + # Ratchet megolm sessions forward after decrypting messages. + ratchet_on_decrypt: false + # Delete fully used keys (index >= max_messages) after decrypting messages. + delete_fully_used_on_decrypt: false + # Delete previous megolm sessions from same device when receiving a new one. + delete_prev_on_new_session: false + # Delete megolm sessions received from a device when the device is deleted. + delete_on_device_delete: false + # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. + periodically_delete_expired: false + # Delete inbound megolm sessions that don't have the received_at field used for + # automatic ratcheting and expired session deletion. This is meant as a migration + # to delete old keys prior to the bridge update. + delete_outdated_inbound: false + # What level of device verification should be required from users? + # + # Valid levels: + # unverified - Send keys to all device in the room. + # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. + # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). + # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. + # Note that creating user signatures from the bridge bot is not currently possible. + # verified - Require manual per-device verification + # (currently only possible by modifying the `trust` column in the `crypto_device` database table). + verification_levels: + # Minimum level for which the bridge should send keys to when bridging messages from the remote network to Matrix. + receive: unverified + # Minimum level that the bridge should accept for incoming Matrix messages. + send: unverified + # Minimum level that the bridge should require for accepting key requests. + share: cross-signed-tofu + # Options for Megolm room key rotation. These options allow you to configure the m.room.encryption event content. + # See https://spec.matrix.org/v1.10/client-server-api/#mroomencryption for more information about that event. + rotation: + # Enable custom Megolm room key rotation settings. Note that these + # settings will only apply to rooms created after this option is set. + enable_custom: false + # The maximum number of milliseconds a session should be used + # before changing it. The Matrix spec recommends 604800000 (a week) + # as the default. + milliseconds: 604800000 + # The maximum number of messages that should be sent with a given a + # session before changing it. The Matrix spec recommends 100 as the + # default. + messages: 100 + # Disable rotating keys when a user's devices change? + # You should not enable this option unless you understand all the implications. + disable_device_change_key_rotation: false + +# Prefix for environment variables. All variables with this prefix must map to valid config fields. +# Nesting in variable names is represented with a dot (.). +# If there are no dots in the name, two underscores (__) are replaced with a dot. +# +# e.g. if the prefix is set to `BRIDGE_`, then `BRIDGE_APPSERVICE__AS_TOKEN` will set appservice.as_token. +# `BRIDGE_appservice.as_token` would work as well, but can't be set in a shell as easily. +# +# The variable names can also have a `_FILE` suffix to tell the bridge to read the value from the +# path set in the variable rather than using the value directly. +# +# If this is null, reading config fields from environment will be disabled. +env_config_prefix: null + +# Logging config. See https://github.com/tulir/zeroconfig for details. +logging: + min_level: debug + writers: + - type: stdout + format: pretty-colored + - type: file + format: json + filename: ./logs/bridge.log + max_size: 100 + max_backups: 10 + compress: false diff --git a/mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go b/mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go new file mode 100644 index 00000000..eab3a2b9 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go @@ -0,0 +1,270 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mxmain + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/matrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (br *BridgeMain) LegacyMigrateWithAnotherUpgrader(rawRenameTablesQuery any, copyDataQuery string, newDBVersion int, otherTable dbutil.UpgradeTable, otherTableName string, otherNewVersion int) func(ctx context.Context) error { + return func(ctx context.Context) error { + // Unique constraints must have globally unique names on postgres, and renaming the table doesn't rename them, + // so just drop the ones that may conflict with the new schema. + if br.DB.Dialect == dbutil.Postgres { + _, err := br.DB.Exec(ctx, "ALTER TABLE message DROP CONSTRAINT IF EXISTS message_mxid_unique") + if err != nil { + return fmt.Errorf("failed to drop potentially conflicting constraint on message: %w", err) + } + _, err = br.DB.Exec(ctx, "ALTER TABLE reaction DROP CONSTRAINT IF EXISTS reaction_mxid_unique") + if err != nil { + return fmt.Errorf("failed to drop potentially conflicting constraint on reaction: %w", err) + } + } + err := dbutil.DangerousInternalUpgradeVersionTable(ctx, br.DB) + if err != nil { + return err + } + switch renameTablesQuery := rawRenameTablesQuery.(type) { + case string: + _, err = br.DB.Exec(ctx, renameTablesQuery) + case func(context.Context, *dbutil.Database) error: + err = renameTablesQuery(ctx, br.DB) + default: + return fmt.Errorf("invalid type for renameTablesQuery: %T", rawRenameTablesQuery) + } + if err != nil { + return err + } + upgradesTo, compat, err := br.DB.UpgradeTable[0].DangerouslyRun(ctx, br.DB) + if err != nil { + return err + } + if upgradesTo < newDBVersion || compat > newDBVersion { + return fmt.Errorf("unexpected new database version (%d/c:%d, expected %d)", upgradesTo, compat, newDBVersion) + } + if otherTable != nil { + _, err = br.DB.Exec(ctx, fmt.Sprintf("CREATE TABLE %s (version INTEGER, compat INTEGER)", otherTableName)) + if err != nil { + return err + } + otherUpgradesTo, otherCompat, err := otherTable[0].DangerouslyRun(ctx, br.DB) + if err != nil { + return err + } else if otherUpgradesTo < otherNewVersion || otherCompat > otherNewVersion { + return fmt.Errorf("unexpected new database version for %s (%d/c:%d, expected %d)", otherTableName, otherUpgradesTo, otherCompat, otherNewVersion) + } + _, err = br.DB.Exec(ctx, fmt.Sprintf("INSERT INTO %s (version, compat) VALUES ($1, $2)", otherTableName), otherUpgradesTo, otherCompat) + if err != nil { + return err + } + } + copyDataQuery, err = br.DB.Internals().FilterSQLUpgrade(bytes.Split([]byte(copyDataQuery), []byte("\n"))) + if err != nil { + return err + } + _, err = br.DB.Exec(ctx, copyDataQuery) + if err != nil { + return err + } + _, err = br.DB.Exec(ctx, "DELETE FROM database_owner") + if err != nil { + return err + } + _, err = br.DB.Exec(ctx, "INSERT INTO database_owner (key, owner) VALUES (0, $1)", br.DB.Owner) + if err != nil { + return err + } + _, err = br.DB.Exec(ctx, "DELETE FROM version") + if err != nil { + return err + } + _, err = br.DB.Exec(ctx, "INSERT INTO version (version, compat) VALUES ($1, $2)", upgradesTo, compat) + if err != nil { + return err + } + _, err = br.DB.Exec(ctx, "CREATE TABLE database_was_migrated(empty INTEGER)") + if err != nil { + return err + } + + return nil + } +} + +func (br *BridgeMain) LegacyMigrateSimple(renameTablesQuery, copyDataQuery string, newDBVersion int) func(ctx context.Context) error { + return br.LegacyMigrateWithAnotherUpgrader(renameTablesQuery, copyDataQuery, newDBVersion, nil, "", 0) +} + +func (br *BridgeMain) CheckLegacyDB( + expectedVersion int, + minBridgeVersion, + firstMegaVersion string, + migrator func(context.Context) error, + transaction bool, +) { + log := br.Log.With().Str("action", "migrate legacy db").Logger() + ctx := log.WithContext(context.Background()) + exists, err := br.DB.TableExists(ctx, "database_owner") + if err != nil { + log.Err(err).Msg("Failed to check if database_owner table exists") + return + } else if !exists { + return + } + var owner string + err = br.DB.QueryRow(ctx, "SELECT owner FROM database_owner LIMIT 1").Scan(&owner) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Err(err).Msg("Failed to get database owner") + return + } else if owner != br.Name { + if owner != "megabridge/"+br.Name && owner != "" { + log.Warn().Str("db_owner", owner).Msg("Unexpected database owner, not migrating database") + } + return + } + var dbVersion int + err = br.DB.QueryRow(ctx, "SELECT version FROM version").Scan(&dbVersion) + if err != nil { + log.Fatal().Err(err).Msg("Failed to get database version") + return + } else if dbVersion < expectedVersion { + log.Fatal(). + Int("expected_version", expectedVersion). + Int("version", dbVersion). + Msgf("Unsupported database version. Please upgrade to %s %s or higher before upgrading to %s.", br.Name, minBridgeVersion, firstMegaVersion) // zerolog-allow-msgf + return + } else if dbVersion > expectedVersion { + log.Fatal(). + Int("expected_version", expectedVersion). + Int("version", dbVersion). + Msg("Unsupported database version (higher than expected)") + return + } + log.Info().Msg("Detected legacy database, migrating...") + if transaction { + err = br.DB.DoTxn(ctx, nil, migrator) + } else { + err = migrator(ctx) + } + if err != nil { + br.LogDBUpgradeErrorAndExit("main", err, "Failed to migrate legacy database") + } else { + log.Info().Msg("Successfully migrated legacy database") + } +} + +func (br *BridgeMain) postMigrateDMPortal(ctx context.Context, portal *bridgev2.Portal) error { + otherUserID := portal.OtherUserID + if otherUserID == "" { + zerolog.Ctx(ctx).Warn(). + Str("portal_id", string(portal.ID)). + Msg("DM portal has no other user ID") + return nil + } + ghost, err := br.Bridge.GetGhostByID(ctx, otherUserID) + if err != nil { + return fmt.Errorf("failed to get ghost for %s: %w", otherUserID, err) + } + mx := ghost.Intent.(*matrix.ASIntent).Matrix + err = br.Matrix.Bot.EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{ + BotOverride: mx.Client, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("portal_id", string(portal.ID)). + Stringer("room_id", portal.MXID). + Msg("Failed to ensure bot is joined to DM") + } + pls, err := mx.PowerLevels(ctx, portal.MXID) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("portal_id", string(portal.ID)). + Stringer("room_id", portal.MXID). + Msg("Failed to get power levels in room") + } else { + userLevel := pls.GetUserLevel(mx.UserID) + pls.EnsureUserLevel(br.Matrix.Bot.UserID, userLevel) + if userLevel > 50 { + pls.SetUserLevel(mx.UserID, 50) + } + _, err = mx.SetPowerLevels(ctx, portal.MXID, pls) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("portal_id", string(portal.ID)). + Stringer("room_id", portal.MXID). + Msg("Failed to set power levels") + } + } + portal.UpdateInfoFromGhost(ctx, ghost) + return nil +} + +func (br *BridgeMain) PostMigrate(ctx context.Context) error { + log := br.Log.With().Str("action", "post-migrate").Logger() + wasMigrated, err := br.DB.TableExists(ctx, "database_was_migrated") + if err != nil { + return fmt.Errorf("failed to check if database_was_migrated table exists: %w", err) + } else if !wasMigrated { + return nil + } + log.Info().Msg("Doing post-migration updates to Matrix rooms") + + portals, err := br.Bridge.GetAllPortalsWithMXID(ctx) + if err != nil { + return fmt.Errorf("failed to get all portals: %w", err) + } + for _, portal := range portals { + log := log.With(). + Stringer("room_id", portal.MXID). + Object("portal_key", portal.PortalKey). + Str("room_type", string(portal.RoomType)). + Logger() + log.Debug().Msg("Migrating portal") + if br.PostMigratePortal != nil { + err = br.PostMigratePortal(ctx, portal) + if err != nil { + log.Err(err).Msg("Failed to run post-migrate portal hook") + continue + } + } else { + switch portal.RoomType { + case database.RoomTypeDM: + err = br.postMigrateDMPortal(ctx, portal) + if err != nil { + return fmt.Errorf("failed to update DM portal %s: %w", portal.MXID, err) + } + } + } + _, err = br.Matrix.Bot.SendStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.ElementFunctionalMembersContent{ + ServiceMembers: []id.UserID{br.Matrix.Bot.UserID}, + }) + if err != nil { + log.Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to set service members") + } + } + + _, err = br.DB.Exec(ctx, "DROP TABLE database_was_migrated") + if err != nil { + return fmt.Errorf("failed to drop database_was_migrated table: %w", err) + } + log.Info().Msg("Post-migration updates complete") + return nil +} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/main.go b/mautrix-patched/bridgev2/matrix/mxmain/main.go new file mode 100644 index 00000000..f8303237 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/main.go @@ -0,0 +1,454 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package mxmain contains initialization code for a single-network Matrix bridge using the bridgev2 package. +package mxmain + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "runtime" + "strings" + "syscall" + "time" + + "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "go.mau.fi/util/configupgrade" + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exzerolog" + "go.mau.fi/util/progver" + "gopkg.in/yaml.v3" + flag "maunium.net/go/mauflag" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/matrix" +) + +var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String() +var writeExampleConfig = flag.MakeFull("e", "generate-example-config", "Save the example config to the config path and quit.", "false").Bool() +var dontSaveConfig = flag.MakeFull("n", "no-update", "Don't save updated config to disk.", "false").Bool() +var registrationPath = flag.MakeFull("r", "registration", "The path where to save the appservice registration.", "registration.yaml").String() +var generateRegistration = flag.MakeFull("g", "generate-registration", "Generate registration and quit.", "false").Bool() +var version = flag.MakeFull("v", "version", "View bridge version and quit.", "false").Bool() +var versionJSON = flag.Make().LongKey("version-json").Usage("Print a JSON object representing the bridge version and quit.").Default("false").Bool() +var ignoreUnsupportedDatabase = flag.Make().LongKey("ignore-unsupported-database").Usage("Run even if the database schema is too new").Default("false").Bool() +var ignoreForeignTables = flag.Make().LongKey("ignore-foreign-tables").Usage("Run even if the database contains tables from other programs (like Synapse)").Default("false").Bool() +var ignoreUnsupportedServer = flag.Make().LongKey("ignore-unsupported-server").Usage("Run even if the Matrix homeserver is outdated").Default("false").Bool() +var wantHelp, _ = flag.MakeHelpFlag() + +// BridgeMain contains the main function for a Matrix bridge. +type BridgeMain struct { + // Name is the name of the bridge project, e.g. mautrix-signal. + // Note that when making your own bridges that isn't under github.com/mautrix, + // you should invent your own name and not use the mautrix-* naming scheme. + Name string + // Description is a brief description of the bridge, usually of the form "A Matrix-OtherPlatform puppeting bridge." + Description string + // URL is the Git repository address for the bridge. + URL string + // Version is the latest release of the bridge. InitVersion will compare this to the provided + // git tag to see if the built version is the release or a dev build. + // You can either bump this right after a release or right before, as long as it matches on the release commit. + Version string + // SemCalVer defines whether this bridge uses a mix of semantic and calendar versioning, + // such that the Version field is YY.0M.patch, while git tags are major.YY0M.patch. + SemCalVer bool + + // PostInit is a function that will be called after the bridge has been initialized but before it is started. + PostInit func() + PostStart func() + + // PostMigratePortal is a function that will be called during a legacy + // migration for each portal. + PostMigratePortal func(context.Context, *bridgev2.Portal) error + + // Connector is the network connector for the bridge. + Connector bridgev2.NetworkConnector + + // All fields below are set automatically in Run or InitVersion should not be set manually. + + Log *zerolog.Logger + DB *dbutil.Database + Config *bridgeconfig.Config + Matrix *matrix.Connector + Bridge *bridgev2.Bridge + + ConfigPath string + RegistrationPath string + SaveConfig bool + + ver progver.ProgramVersion + + AdditionalShortFlags string + AdditionalLongFlags string + + manualStop chan int +} + +type VersionJSONOutput struct { + progver.ProgramVersion + + OS string + Arch string + + Mautrix struct { + Version string + Commit string + } +} + +// Run runs the bridge and waits for SIGTERM before stopping. +func (br *BridgeMain) Run() { + br.PreInit() + br.Init() + br.Start() + exitCode := br.WaitForInterrupt() + br.Stop() + os.Exit(exitCode) +} + +// PreInit parses CLI flags and loads the config file. This is called by [Run] and does not need to be called manually. +// +// This also handles all flags that cause the bridge to exit immediately (e.g. `--version` and `--generate-registration`). +func (br *BridgeMain) PreInit() { + br.manualStop = make(chan int, 1) + flag.SetHelpTitles( + fmt.Sprintf("%s - %s", br.Name, br.Description), + fmt.Sprintf("%s [-hgvn%s] [-c ] [-r ]%s", br.Name, br.AdditionalShortFlags, br.AdditionalLongFlags)) + err := flag.Parse() + br.ConfigPath = *configPath + br.RegistrationPath = *registrationPath + br.SaveConfig = !*dontSaveConfig + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + flag.PrintHelp() + os.Exit(1) + } else if *wantHelp { + flag.PrintHelp() + os.Exit(0) + } else if *version { + fmt.Println(br.ver.VersionDescription) + os.Exit(0) + } else if *versionJSON { + output := VersionJSONOutput{ + ProgramVersion: br.ver, + + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + output.Mautrix.Commit = mautrix.Commit + output.Mautrix.Version = mautrix.Version + _ = json.NewEncoder(os.Stdout).Encode(output) + os.Exit(0) + } else if *writeExampleConfig { + if *configPath != "-" && *configPath != "/dev/stdout" && *configPath != "/dev/stderr" { + if _, err = os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) { + _, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example") + os.Exit(1) + } + } + networkExample, _, _ := br.Connector.GetConfig() + fullCfg := br.makeFullExampleConfig(networkExample) + if *configPath == "-" { + fmt.Print(fullCfg) + } else { + exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(fullCfg), 0600)) + fmt.Println("Wrote example config to", *configPath) + } + os.Exit(0) + } + br.LoadConfig() + if *generateRegistration { + br.GenerateRegistration() + os.Exit(0) + } + LoadGlobalConfigEnv() +} + +func (br *BridgeMain) GenerateRegistration() { + if !br.SaveConfig { + // We need to save the generated as_token and hs_token in the config + _, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration") + os.Exit(5) + } else if br.Config.Homeserver.Domain == "example.com" { + _, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set") + os.Exit(20) + } + reg := br.Config.GenerateRegistration() + err := reg.Save(br.RegistrationPath) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err) + os.Exit(21) + } + + updateTokens := func(helper configupgrade.Helper) { + helper.Set(configupgrade.Str, reg.AppToken, "appservice", "as_token") + helper.Set(configupgrade.Str, reg.ServerToken, "appservice", "hs_token") + } + upgrader, _ := br.getConfigUpgrader() + _, _, err = configupgrade.Do(br.ConfigPath, true, upgrader, configupgrade.SimpleUpgrader(updateTokens)) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err) + os.Exit(22) + } + fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.") + os.Exit(0) +} + +// Init sets up logging, database connection and creates the Matrix connector and central Bridge struct. +// This is called by [Run] and does not need to be called manually. +func (br *BridgeMain) Init() { + var err error + br.Log, err = br.Config.Logging.Compile() + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) + os.Exit(12) + } + exzerolog.SetupDefaults(br.Log) + err = br.validateConfig() + if err != nil { + br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error") + br.Log.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info") + os.Exit(11) + } + + br.Log.Info(). + Str("name", br.Name). + Str("version", br.ver.FormattedVersion). + Time("built_at", br.ver.BuildTime). + Str("go_version", runtime.Version()). + Msg("Initializing bridge") + + br.initDB() + br.Matrix = matrix.NewConnector(br.Config) + br.Matrix.OnWebsocketReplaced = func() { + br.TriggerStop(0) + } + br.Matrix.IgnoreUnsupportedServer = *ignoreUnsupportedServer + br.Bridge = bridgev2.NewBridge("", br.DB, *br.Log, &br.Config.Bridge, br.Matrix, br.Connector, commands.NewProcessor) + br.Matrix.AS.DoublePuppetValue = br.Name + br.Bridge.Commands.(*commands.Processor).AddHandler(&commands.FullHandler{ + Func: func(ce *commands.Event) { + ce.Reply(br.ver.MarkdownDescription()) + }, + Name: "version", + Help: commands.HelpMeta{ + Section: commands.HelpSectionGeneral, + Description: "Get the bridge version.", + }, + }) + if br.PostInit != nil { + br.PostInit() + } +} + +func (br *BridgeMain) initDB() { + br.Log.Debug().Msg("Initializing database connection") + dbConfig := br.Config.Database + if dbConfig.Type == "sqlite3" { + br.Log.WithLevel(zerolog.FatalLevel).Msg("Invalid database type sqlite3. Use sqlite3-fk-wal instead.") + os.Exit(14) + } + if (dbConfig.Type == "sqlite3-fk-wal" || dbConfig.Type == "litestream") && dbConfig.MaxOpenConns != 1 && !strings.Contains(dbConfig.URI, "_txlock=immediate") { + var fixedExampleURI string + if !strings.HasPrefix(dbConfig.URI, "file:") { + fixedExampleURI = fmt.Sprintf("file:%s?_txlock=immediate", dbConfig.URI) + } else if !strings.ContainsRune(dbConfig.URI, '?') { + fixedExampleURI = fmt.Sprintf("%s?_txlock=immediate", dbConfig.URI) + } else { + fixedExampleURI = fmt.Sprintf("%s&_txlock=immediate", dbConfig.URI) + } + br.Log.Warn(). + Str("fixed_uri_example", fixedExampleURI). + Msg("Using SQLite without _txlock=immediate is not recommended") + } + var err error + br.DB, err = dbutil.NewFromConfig("megabridge/"+br.Name, dbConfig, dbutil.ZeroLogger(br.Log.With().Str("db_section", "main").Logger())) + if err != nil { + br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection") + if sqlError := (&sqlite3.Error{}); errors.As(err, sqlError) && sqlError.Code == sqlite3.ErrCorrupt { + os.Exit(18) + } + os.Exit(14) + } + br.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase + br.DB.IgnoreForeignTables = *ignoreForeignTables +} + +func (br *BridgeMain) validateConfig() error { + switch { + case br.Config.Homeserver.Address == "http://example.localhost:8008": + return errors.New("homeserver.address not configured") + case br.Config.Homeserver.Domain == "example.com": + return errors.New("homeserver.domain not configured") + case !bridgeconfig.AllowedHomeserverSoftware[br.Config.Homeserver.Software]: + return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)") + case br.Config.AppService.ASToken == "This value is generated when generating the registration": + return errors.New("appservice.as_token not configured. Did you forget to generate the registration? ") + case br.Config.AppService.HSToken == "This value is generated when generating the registration": + return errors.New("appservice.hs_token not configured. Did you forget to generate the registration? ") + case br.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable": + return errors.New("database.uri not configured") + case !br.Config.Bridge.Permissions.IsConfigured(): + return errors.New("bridge.permissions not configured") + case !strings.Contains(br.Config.AppService.FormatUsername("1234567890"), "1234567890"): + return errors.New("username template is missing user ID placeholder") + default: + cfgValidator, ok := br.Connector.(bridgev2.ConfigValidatingNetwork) + if ok { + err := cfgValidator.ValidateConfig() + if err != nil { + return err + } + } + return nil + } +} + +func (br *BridgeMain) getConfigUpgrader() (configupgrade.BaseUpgrader, any) { + networkExample, networkData, networkUpgrader := br.Connector.GetConfig() + baseConfig := br.makeFullExampleConfig(networkExample) + if networkUpgrader == nil { + networkUpgrader = configupgrade.NoopUpgrader + } + networkUpgraderProxied := &configupgrade.ProxyUpgrader{Target: networkUpgrader, Prefix: []string{"network"}} + upgrader := configupgrade.MergeUpgraders(baseConfig, networkUpgraderProxied, bridgeconfig.Upgrader) + return upgrader, networkData +} + +// LoadConfig upgrades and loads the config file. +// This is called by [Run] and does not need to be called manually. +func (br *BridgeMain) LoadConfig() { + upgrader, networkData := br.getConfigUpgrader() + configData, upgraded, err := configupgrade.Do(br.ConfigPath, br.SaveConfig, upgrader) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Error updating config:", err) + if !upgraded { + os.Exit(10) + } + } + + var cfg bridgeconfig.Config + err = yaml.Unmarshal(configData, &cfg) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err) + os.Exit(10) + } + if networkData != nil { + err = cfg.Network.Decode(networkData) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to parse network config:", err) + os.Exit(10) + } + } + cfg.Bridge.Backfill = cfg.Backfill + if cfg.EnvConfigPrefix != "" { + err = UpdateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err) + os.Exit(10) + } + } + br.Config = &cfg +} + +// Start starts the bridge after everything has been initialized. +// This is called by [Run] and does not need to be called manually. +func (br *BridgeMain) Start() { + ctx := br.Log.WithContext(context.Background()) + err := br.Bridge.StartConnectors(ctx) + var exitError matrix.ExitError + var dbUpgradeErr bridgev2.DBUpgradeError + if errors.As(err, &exitError) { + exitError.Exit() + } else if errors.As(err, &dbUpgradeErr) { + br.LogDBUpgradeErrorAndExit(dbUpgradeErr.Section, dbUpgradeErr.Err, "Failed to initialize database") + } else if errors.Is(err, bridgev2.ErrSplitPortalMigrationFailed) { + os.Exit(31) + } else if err != nil { + br.Log.Fatal().Err(err).Msg("Failed to start bridge") + } + err = br.PostMigrate(ctx) + if err != nil { + br.Log.Fatal().Err(err).Msg("Failed to run post-migration updates") + } + err = br.Bridge.StartLogins(ctx) + if err != nil { + br.Log.Fatal().Err(err).Msg("Failed to start existing user logins") + } + br.Bridge.PostStart(ctx) + if br.PostStart != nil { + br.PostStart() + } +} + +// WaitForInterrupt waits for a SIGINT or SIGTERM signal. +func (br *BridgeMain) WaitForInterrupt() int { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + select { + case <-c: + br.Log.Info().Msg("Interrupt signal received from OS") + return 0 + case exitCode := <-br.manualStop: + br.Log.Info().Msg("Internal stop signal received") + return exitCode + } +} + +func (br *BridgeMain) TriggerStop(exitCode int) { + select { + case br.manualStop <- exitCode: + default: + } +} + +// Stop cleanly stops the bridge. This is called by [Run] and does not need to be called manually. +func (br *BridgeMain) Stop() { + br.Bridge.StopWithTimeout(5 * time.Second) +} + +// InitVersion formats the bridge version and build time nicely for things like +// the `version` bridge command on Matrix and the `--version` CLI flag. +// +// The values should generally be set by the build system. For example, assuming you have +// +// var ( +// Tag = "unknown" +// Commit = "unknown" +// BuildTime = "unknown" +// ) +// +// in your main package, then you'd use the following ldflags to fill them appropriately: +// +// go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`'" +// +// You may additionally want to fill the mautrix-go version using another ldflag: +// +// export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | head -n1 | awk '{ print $2 }') +// go build -ldflags "-X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +// +// (to use both at the same time, simply merge the ldflags into one, `-ldflags "-X '...' -X ..."`) +func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) { + br.ver = progver.ProgramVersion{ + Name: br.Name, + URL: br.URL, + BaseVersion: br.Version, + SemCalVer: br.SemCalVer, + }.Init(tag, commit, rawBuildTime) + mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.ver.FormattedVersion, mautrix.DefaultUserAgent) + br.Version = br.ver.FormattedVersion +} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/main_test.go b/mautrix-patched/bridgev2/matrix/mxmain/main_test.go new file mode 100644 index 00000000..9a71344d --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/mxmain/main_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mxmain_test + +import ( + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" +) + +// Information to find out exactly which commit the bridge was built from. +// These are filled at build time with the -X linker flag. +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + +func ExampleBridgeMain() { + // Set this yourself + var yourConnector bridgev2.NetworkConnector + + m := mxmain.BridgeMain{ + Name: "example-matrix-bridge", + URL: "https://github.com/octocat/matrix-bridge", + Description: "An example Matrix bridge.", + Version: "1.0.0", + + Connector: yourConnector, + } + m.PostInit = func() { + // If you want some code to run after all the setup is done, but before the bridge is started, + // you can set a function in PostInit. This is not required if you don't need to do anything special. + } + m.InitVersion(Tag, Commit, BuildTime) + m.Run() +} diff --git a/mautrix-patched/bridgev2/matrix/no-crypto.go b/mautrix-patched/bridgev2/matrix/no-crypto.go new file mode 100644 index 00000000..fe942f83 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/no-crypto.go @@ -0,0 +1,26 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build !cgo || nocrypto + +package matrix + +import ( + "errors" +) + +func NewCryptoHelper(c *Connector) Crypto { + if c.Config.Encryption.Allow { + c.Log.Warn().Msg("Bridge built without end-to-bridge encryption, but encryption is enabled in config") + } else { + c.Log.Debug().Msg("Bridge built without end-to-bridge encryption") + } + return nil +} + +var NoSessionFound = errors.New("nil") +var UnknownMessageIndex = NoSessionFound +var DuplicateMessageIndex = NoSessionFound diff --git a/mautrix-patched/bridgev2/matrix/provisioning.go b/mautrix-patched/bridgev2/matrix/provisioning.go new file mode 100644 index 00000000..1553887c --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/provisioning.go @@ -0,0 +1,704 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/pprof" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/exstrings" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "go.mau.fi/util/requestlog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/federation" + "maunium.net/go/mautrix/id" +) + +type matrixAuthCacheEntry struct { + Expires time.Time + UserID id.UserID +} + +type ProvisioningAPI struct { + Router *http.ServeMux + + br *Connector + log zerolog.Logger + net bridgev2.NetworkConnector + + fedClient *federation.Client + + logins map[string]*ProvLogin + loginsLock sync.RWMutex + + matrixAuthCache map[string]matrixAuthCacheEntry + matrixAuthCacheLock sync.Mutex + + // Set for a given login once credentials have been exported, once in this state the finish + // API is available which will call logout on the client in question. + sessionTransfers map[networkid.UserLoginID]struct{} + sessionTransfersLock sync.Mutex + + // GetAuthFromRequest is a custom function for getting the auth token from + // the request if the Authorization header is not present. + GetAuthFromRequest func(r *http.Request) string + + // GetUserIDFromRequest is a custom function for getting the user ID to + // authenticate as instead of using the user ID provided in the query + // parameter. + GetUserIDFromRequest func(r *http.Request) id.UserID +} + +type provisioningContextKey int + +const ( + provisioningUserKey provisioningContextKey = iota + ProvisioningKeyRequest +) + +func (prov *ProvisioningAPI) GetUser(r *http.Request) *bridgev2.User { + return r.Context().Value(provisioningUserKey).(*bridgev2.User) +} + +func (prov *ProvisioningAPI) GetRouter() *http.ServeMux { + return prov.Router +} + +func (br *Connector) GetProvisioning() bridgev2.IProvisioningAPI { + return br.Provisioning +} + +func (prov *ProvisioningAPI) Init() { + prov.matrixAuthCache = make(map[string]matrixAuthCacheEntry) + prov.logins = make(map[string]*ProvLogin) + prov.sessionTransfers = make(map[networkid.UserLoginID]struct{}) + prov.net = prov.br.Bridge.Network + prov.log = prov.br.Log.With().Str("component", "provisioning").Logger() + prov.fedClient = federation.NewClient("", nil, nil, exhttp.SensibleClientSettings.WithGlobalTimeout(20*time.Second)) + prov.Router = http.NewServeMux() + prov.Router.HandleFunc("GET /v3/whoami", prov.GetWhoami) + prov.Router.HandleFunc("GET /v3/capabilities", prov.GetCapabilities) + prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows) + prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart) + prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep) + prov.Router.HandleFunc("POST /v3/login/cancel/{loginProcessID}", prov.PostLoginCancel) + prov.Router.HandleFunc("POST /v3/logout/{loginID}", prov.PostLogout) + prov.Router.HandleFunc("GET /v3/logins", prov.GetLogins) + prov.Router.HandleFunc("GET /v3/contacts", prov.GetContactList) + prov.Router.HandleFunc("POST /v3/search_users", prov.PostSearchUsers) + prov.Router.HandleFunc("GET /v3/resolve_identifier/{identifier}", prov.GetResolveIdentifier) + prov.Router.HandleFunc("POST /v3/create_dm/{identifier}", prov.PostCreateDM) + prov.Router.HandleFunc("POST /v3/create_group/{type}", prov.PostCreateGroup) + prov.Router.HandleFunc("POST /v3/backfill/{roomID}", prov.PostPaginate) + prov.Router.HandleFunc("GET /v3/image_pack/import", prov.ImportImagePack) + prov.Router.HandleFunc("POST /v3/image_pack/import", prov.ImportImagePack) + prov.Router.HandleFunc("GET /v3/image_pack/list", prov.ListImagePacks) + + if prov.br.Config.Provisioning.EnableSessionTransfers { + prov.log.Debug().Msg("Enabling session transfer API") + prov.Router.HandleFunc("POST /v3/session_transfer/init", prov.PostInitSessionTransfer) + prov.Router.HandleFunc("POST /v3/session_transfer/finish", prov.PostFinishSessionTransfer) + } + + if prov.br.Config.Provisioning.DebugEndpoints { + prov.log.Debug().Msg("Enabling debug API at /debug") + debugRouter := http.NewServeMux() + debugRouter.HandleFunc("GET /pprof/cmdline", pprof.Cmdline) + debugRouter.HandleFunc("GET /pprof/profile", pprof.Profile) + debugRouter.HandleFunc("GET /pprof/symbol", pprof.Symbol) + debugRouter.HandleFunc("GET /pprof/trace", pprof.Trace) + debugRouter.HandleFunc("/pprof/", pprof.Index) + prov.br.AS.Router.Handle("/debug/", exhttp.ApplyMiddleware( + debugRouter, + exhttp.StripPrefix("/debug"), + hlog.NewHandler(prov.br.Log.With().Str("component", "debug api").Logger()), + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + prov.DebugAuthMiddleware, + )) + } + + errorBodies := exhttp.ErrorBodies{ + NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), + MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), + } + prov.br.AS.Router.Handle("/_matrix/provision/", exhttp.ApplyMiddleware( + prov.Router, + exhttp.StripPrefix("/_matrix/provision"), + hlog.NewHandler(prov.log), + hlog.RequestIDHandler("request_id", "Request-Id"), + exhttp.CORSMiddleware, + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + exhttp.HandleErrors(errorBodies), + prov.AuthMiddleware, + )) +} + +func (prov *ProvisioningAPI) checkMatrixAuth(ctx context.Context, userID id.UserID, token string) error { + prov.matrixAuthCacheLock.Lock() + defer prov.matrixAuthCacheLock.Unlock() + if cached, ok := prov.matrixAuthCache[token]; ok && cached.Expires.After(time.Now()) && cached.UserID == userID { + return nil + } else if client, err := prov.br.DoublePuppet.newClient(ctx, userID, token); err != nil { + return err + } else if whoami, err := client.Whoami(ctx); err != nil { + return err + } else if whoami.UserID != userID { + return fmt.Errorf("mismatching user ID (%q != %q)", whoami.UserID, userID) + } else { + prov.matrixAuthCache[token] = matrixAuthCacheEntry{ + Expires: time.Now().Add(5 * time.Minute), + UserID: whoami.UserID, + } + return nil + } +} + +func (prov *ProvisioningAPI) checkFederatedMatrixAuth(ctx context.Context, userID id.UserID, token string) error { + homeserver := userID.Homeserver() + wrappedToken := fmt.Sprintf("%s:%s", homeserver, token) + // TODO smarter locking + prov.matrixAuthCacheLock.Lock() + defer prov.matrixAuthCacheLock.Unlock() + if cached, ok := prov.matrixAuthCache[wrappedToken]; ok && cached.Expires.After(time.Now()) && cached.UserID == userID { + return nil + } else if validationResult, err := prov.fedClient.GetOpenIDUserInfo(ctx, homeserver, token); err != nil { + return fmt.Errorf("failed to validate OpenID token: %w", err) + } else if validationResult.Sub != userID { + return fmt.Errorf("mismatching user ID (%q != %q)", validationResult, userID) + } else { + prov.matrixAuthCache[wrappedToken] = matrixAuthCacheEntry{ + Expires: time.Now().Add(1 * time.Hour), + UserID: userID, + } + return nil + } +} + +func disabledAuth(w http.ResponseWriter, r *http.Request) { + mautrix.MForbidden.WithMessage("Provisioning API is disabled").Write(w) +} + +func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler { + secret := prov.br.Config.Provisioning.SharedSecret + if len(secret) < 16 { + return http.HandlerFunc(disabledAuth) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if auth == "" { + mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) + } else if !exstrings.ConstantTimeEqual(auth, secret) { + mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) + } else { + h.ServeHTTP(w, r) + } + }) +} + +func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { + secret := prov.br.Config.Provisioning.SharedSecret + if len(secret) < 16 { + return http.HandlerFunc(disabledAuth) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if auth == "" && prov.GetAuthFromRequest != nil { + auth = prov.GetAuthFromRequest(r) + } + if auth == "" { + mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) + return + } + userID := id.UserID(r.URL.Query().Get("user_id")) + if userID == "" && prov.GetUserIDFromRequest != nil { + userID = prov.GetUserIDFromRequest(r) + } + if !exstrings.ConstantTimeEqual(auth, secret) { + var err error + if !prov.br.Config.Provisioning.AllowMatrixAuth { + err = errors.New("matrix auth is disabled") + } else if strings.HasPrefix(auth, "openid:") { + err = prov.checkFederatedMatrixAuth(r.Context(), userID, strings.TrimPrefix(auth, "openid:")) + } else { + err = prov.checkMatrixAuth(r.Context(), userID, auth) + } + if err != nil { + zerolog.Ctx(r.Context()).Warn().Err(err). + Msg("Provisioning API request contained invalid auth") + mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) + return + } + } + user, err := prov.br.Bridge.GetUserByMXID(r.Context(), userID) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get user") + mautrix.MUnknown.WithMessage("Failed to get user").Write(w) + return + } + // TODO handle user being nil? + // TODO per-endpoint permissions? + if !user.Permissions.Login { + mautrix.MForbidden.WithMessage("User does not have login permissions").Write(w) + return + } + + ctx := context.WithValue(r.Context(), ProvisioningKeyRequest, r) + ctx = context.WithValue(ctx, provisioningUserKey, user) + h.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +type RespWhoami struct { + Network bridgev2.BridgeName `json:"network"` + LoginFlows []bridgev2.LoginFlow `json:"login_flows"` + Homeserver string `json:"homeserver"` + BridgeBot id.UserID `json:"bridge_bot"` + CommandPrefix string `json:"command_prefix"` + + ManagementRoom id.RoomID `json:"management_room,omitempty"` + Logins []RespWhoamiLogin `json:"logins"` +} + +type RespWhoamiLogin struct { + // Deprecated + StateEvent status.BridgeStateEvent `json:"state_event"` + // Deprecated + StateTS jsontime.Unix `json:"state_ts"` + // Deprecated + StateReason string `json:"state_reason,omitempty"` + // Deprecated + StateInfo map[string]any `json:"state_info,omitempty"` + + State status.BridgeState `json:"state"` + ID networkid.UserLoginID `json:"id"` + Name string `json:"name"` + Profile status.RemoteProfile `json:"profile"` + SpaceRoom id.RoomID `json:"space_room,omitempty"` +} + +func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) { + user := prov.GetUser(r) + resp := &RespWhoami{ + Network: prov.br.Bridge.Network.GetName(), + LoginFlows: prov.br.Bridge.Network.GetLoginFlows(), + Homeserver: prov.br.AS.HomeserverDomain, + BridgeBot: prov.br.Bot.UserID, + CommandPrefix: prov.br.Config.Bridge.CommandPrefix, + ManagementRoom: user.ManagementRoom, + } + logins := user.GetUserLogins() + resp.Logins = make([]RespWhoamiLogin, len(logins)) + for i, login := range logins { + prevState := login.BridgeState.GetPrevUnsent() + // Clear redundant fields + prevState.UserID = "" + prevState.RemoteID = "" + prevState.RemoteName = "" + prevState.RemoteProfile = status.RemoteProfile{} + resp.Logins[i] = RespWhoamiLogin{ + StateEvent: prevState.StateEvent, + StateTS: prevState.Timestamp, + StateReason: prevState.Reason, + StateInfo: prevState.Info, + State: prevState, + + ID: login.ID, + Name: login.RemoteName, + Profile: login.RemoteProfile, + SpaceRoom: login.SpaceRoom, + } + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +type RespLoginFlows struct { + Flows []bridgev2.LoginFlow `json:"flows"` +} + +type RespSubmitLogin struct { + LoginID string `json:"login_id"` + *bridgev2.LoginStep +} + +func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Request) { + exhttp.WriteJSONResponse(w, http.StatusOK, &RespLoginFlows{ + Flows: prov.net.GetLoginFlows(), + }) +} + +func (prov *ProvisioningAPI) GetCapabilities(w http.ResponseWriter, r *http.Request) { + exhttp.WriteJSONResponse(w, http.StatusOK, &prov.net.GetCapabilities().Provisioning) +} + +func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request) { + user := prov.GetUser(r) + userLoginID := networkid.UserLoginID(r.PathValue("loginID")) + if userLoginID == "all" { + for { + login := user.GetDefaultLogin() + if login == nil { + break + } + login.Logout(r.Context()) + } + } else { + userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) + if userLogin == nil || userLogin.UserMXID != user.MXID { + mautrix.MNotFound.WithMessage("Login not found").Write(w) + return + } + userLogin.Logout(r.Context()) + } + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) +} + +type RespGetLogins struct { + LoginIDs []networkid.UserLoginID `json:"login_ids"` +} + +func (prov *ProvisioningAPI) GetLogins(w http.ResponseWriter, r *http.Request) { + user := prov.GetUser(r) + exhttp.WriteJSONResponse(w, http.StatusOK, &RespGetLogins{LoginIDs: user.GetUserLoginIDs()}) +} + +func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r *http.Request) (*bridgev2.UserLogin, bool) { + userLoginID := networkid.UserLoginID(r.URL.Query().Get("login_id")) + if userLoginID == "" { + return nil, false + } + userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) + if userLogin == nil || userLogin.UserMXID != prov.GetUser(r).MXID { + hlog.FromRequest(r).Warn(). + Str("login_id", string(userLoginID)). + Msg("Tried to use non-existent login, returning 404") + mautrix.MNotFound.WithMessage("Login not found").Write(w) + return nil, true + } + return userLogin, false +} + +var ErrNotLoggedIn = mautrix.RespError{ + Err: "Not logged in", + ErrCode: "FI.MAU.NOT_LOGGED_IN", + StatusCode: http.StatusBadRequest, +} + +func (prov *ProvisioningAPI) GetLoginForRequest(w http.ResponseWriter, r *http.Request) *bridgev2.UserLogin { + userLogin, failed := prov.GetExplicitLoginForRequest(w, r) + if userLogin != nil || failed { + return userLogin + } + userLogin = prov.GetUser(r).GetDefaultLogin() + if userLogin == nil { + ErrNotLoggedIn.Write(w) + return nil + } + return userLogin +} + +type WritableError interface { + Write(w http.ResponseWriter) +} + +func RespondWithError(w http.ResponseWriter, err error, message string) { + var we WritableError + if errors.As(err, &we) { + we.Write(w) + } else { + mautrix.MUnknown.WithMessage(message).WithInternalError(err).Write(w) + } +} + +func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http.Request, createChat bool) { + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + resp, err := provisionutil.ResolveIdentifier(r.Context(), login, r.PathValue("identifier"), createChat) + if err != nil { + RespondWithError(w, err, "Internal error resolving identifier") + } else if resp == nil { + mautrix.MNotFound.WithMessage("Identifier not found").Write(w) + } else { + status := http.StatusOK + if resp.JustCreated { + status = http.StatusCreated + } + exhttp.WriteJSONResponse(w, status, resp) + } +} + +func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Request) { + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + resp, err := provisionutil.GetContactList(r.Context(), login) + if err != nil { + RespondWithError(w, err, "Internal error getting contact list") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +type ReqSearchUsers struct { + Query string `json:"query"` +} + +func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Request) { + var req ReqSearchUsers + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + resp, err := provisionutil.SearchUsers(r.Context(), login, req.Query) + if err != nil { + RespondWithError(w, err, "Internal error searching users") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +func (prov *ProvisioningAPI) GetResolveIdentifier(w http.ResponseWriter, r *http.Request) { + prov.doResolveIdentifier(w, r, false) +} + +func (prov *ProvisioningAPI) PostCreateDM(w http.ResponseWriter, r *http.Request) { + prov.doResolveIdentifier(w, r, true) +} + +func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Request) { + var req bridgev2.GroupCreateParams + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + req.Type = r.PathValue("type") + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + resp, err := provisionutil.CreateGroup(r.Context(), login, &req) + if err != nil { + RespondWithError(w, err, "Internal error creating group") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +func (prov *ProvisioningAPI) PostPaginate(w http.ResponseWriter, r *http.Request) { + if !prov.br.Capabilities.BatchSending { + mautrix.MUnrecognized.WithMessage("Homeserver does not support batch sending historical messages").Write(w) + return + } else if !prov.br.Config.Backfill.Queue.Manual { + mautrix.MUnrecognized.WithMessage("Manual backfill is not enabled").Write(w) + return + } + log := zerolog.Ctx(r.Context()) + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + targetRoomID := id.RoomID(r.PathValue("roomID")) + portal, err := prov.br.Bridge.GetPortalByMXID(r.Context(), targetRoomID) + if err != nil { + log.Err(err).Msg("Failed to get portal for pagination") + RespondWithError(w, err, "Internal error getting portal") + } else if portal == nil { + log.Debug().Stringer("target_room_id", targetRoomID).Msg("Paginate requested for unknown portal room") + mautrix.MNotFound.WithMessage("Portal not found").Write(w) + } else if task, err := prov.br.Bridge.DB.BackfillTask.GetNextForPortal(r.Context(), portal.PortalKey, false); err != nil { + log.Err(err).Msg("Failed to get backfill task for portal") + RespondWithError(w, err, "Internal error getting backfill task") + } else if task == nil { + log.Debug().Msg("No backfill task found for portal") + w.WriteHeader(http.StatusNoContent) + } else { + log.Info(). + Object("portal_key", portal.PortalKey). + Any("current_backfill_task", task). + Msg("Sending manual backfill request to backfill queue") + doneChan := make(chan error, 1) + var done atomic.Bool + prov.br.Bridge.WakeupBackfillQueue(&bridgev2.ManualBackfill{ + Source: login, + Portal: portal, + DoneCallback: func(err error) { + if done.Swap(true) { + log.Warn().Err(err).Msg("Backfill done callback called multiple times, ignoring") + return + } + log.Debug().Msg("Backfill done callback called, sending result to channel") + doneChan <- err + close(doneChan) + }, + }) + select { + case err = <-doneChan: + if err != nil { + RespondWithError(w, err, "Internal error backfilling") + } else { + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) + } + case <-time.After(25 * time.Second): + log.Warn().Msg("Backfill did not complete within 25 seconds, returning timeout") + mautrix.MUnknown.WithMessage("Backfill did not complete within 25 seconds").Write(w) + case <-r.Context().Done(): + log.Warn().Msg("Request cancelled while waiting for backfill to complete") + } + } +} + +func (prov *ProvisioningAPI) ImportImagePack(w http.ResponseWriter, r *http.Request) { + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + var resp any + var err error + if r.Method == http.MethodPost { + resp, err = provisionutil.ImportImagePack(r.Context(), login, r.URL.Query().Get("pack_url")) + } else { + resp, err = provisionutil.PreviewImagePack(r.Context(), login, r.URL.Query().Get("pack_url")) + } + if err != nil { + RespondWithError(w, err, "Internal error importing image pack") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +func (prov *ProvisioningAPI) ListImagePacks(w http.ResponseWriter, r *http.Request) { + login := prov.GetLoginForRequest(w, r) + if login == nil { + return + } + resp, err := provisionutil.ListImagePacks(r.Context(), login) + if err != nil { + RespondWithError(w, err, "Internal error listing image packs") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +type ReqExportCredentials struct { + RemoteID networkid.UserLoginID `json:"remote_id"` +} + +type RespExportCredentials struct { + Credentials any `json:"credentials"` +} + +func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *http.Request) { + prov.sessionTransfersLock.Lock() + defer prov.sessionTransfersLock.Unlock() + + var req ReqExportCredentials + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + + user := prov.GetUser(r) + logins := user.GetUserLogins() + var loginToExport *bridgev2.UserLogin + for _, login := range logins { + if login.ID == req.RemoteID { + loginToExport = login + break + } + } + if loginToExport == nil { + mautrix.MNotFound.WithMessage("No matching user login found").Write(w) + return + } + + client, ok := loginToExport.Client.(bridgev2.CredentialExportingNetworkAPI) + if !ok { + mautrix.MUnrecognized.WithMessage("This bridge does not support exporting credentials").Write(w) + return + } + + if _, ok := prov.sessionTransfers[loginToExport.ID]; ok { + // Warn, but allow, double exports. This might happen if a client crashes handling creds, + // and should be safe to call multiple times. + zerolog.Ctx(r.Context()).Warn().Msg("Exporting already exported credentials") + } + + // Disconnect now so we don't use the same network session in two places at once + client.Disconnect() + exhttp.WriteJSONResponse(w, http.StatusOK, &RespExportCredentials{ + Credentials: client.ExportCredentials(r.Context()), + }) +} + +func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r *http.Request) { + prov.sessionTransfersLock.Lock() + defer prov.sessionTransfersLock.Unlock() + + var req ReqExportCredentials + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + + user := prov.GetUser(r) + logins := user.GetUserLogins() + var loginToExport *bridgev2.UserLogin + for _, login := range logins { + if login.ID == req.RemoteID { + loginToExport = login + break + } + } + if loginToExport == nil { + mautrix.MNotFound.WithMessage("No matching user login found").Write(w) + return + } else if _, ok := prov.sessionTransfers[loginToExport.ID]; !ok { + mautrix.MBadState.WithMessage("No matching credential export found").Write(w) + return + } + + zerolog.Ctx(r.Context()).Info(). + Str("remote_name", string(req.RemoteID)). + Msg("Logging out remote after finishing credential export") + + loginToExport.Client.LogoutRemote(r.Context()) + delete(prov.sessionTransfers, req.RemoteID) + + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) +} diff --git a/mautrix-patched/bridgev2/matrix/provisioninglogin.go b/mautrix-patched/bridgev2/matrix/provisioninglogin.go new file mode 100644 index 00000000..02e09789 --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/provisioninglogin.go @@ -0,0 +1,295 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "sync" + "time" + + "github.com/rs/xid" + "github.com/rs/zerolog" + "go.mau.fi/util/exhttp" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/status" +) + +type ProvLogin struct { + ID string + Process bridgev2.LoginProcess + PrevStep *bridgev2.LoginStep + NextStep *bridgev2.LoginStep + Override *bridgev2.UserLogin + Lock sync.Mutex + + Ctx context.Context + CancelCtx context.CancelFunc +} + +var ErrNilStep = errors.New("bridge returned nil step with no error") +var ErrTooManyLogins = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.TOO_MANY_LOGINS", Err: "Maximum number of logins exceeded"} +var ErrLoginCancelled = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_CANCELLED", Err: "Login process was cancelled"} +var ErrLoginTimedOut = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_TIMED_OUT", Err: "Login process timed out"} +var ErrLoginAlreadyFinished = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_ALREADY_FINISHED", Err: "Login process was already finished"} + +func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) { + overrideLogin, failed := prov.GetExplicitLoginForRequest(w, r) + if failed { + return + } + user := prov.GetUser(r) + if overrideLogin == nil && user.HasTooManyLogins() { + ErrTooManyLogins.AppendMessage(" (%d)", user.Permissions.MaxLogins).Write(w) + return + } + login, err := prov.net.CreateLogin(r.Context(), user, r.PathValue("flowID")) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create login process") + RespondWithError(w, err, "Internal error creating login process") + return + } + var firstStep *bridgev2.LoginStep + overridable, ok := login.(bridgev2.LoginProcessWithOverride) + if ok && overrideLogin != nil { + firstStep, err = overridable.StartWithOverride(r.Context(), overrideLogin) + } else { + firstStep, err = login.Start(r.Context()) + } + if err == nil && firstStep == nil { + err = ErrNilStep + } + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to start login") + RespondWithError(w, err, "Internal error starting login") + return + } + loginID := xid.New().String() + ctx, cancel := context.WithTimeout(prov.br.Bridge.BackgroundCtx, 30*time.Minute) + ctx = user.Log.With(). + Str("login_id", loginID). + Logger().WithContext(ctx) + provLogin := &ProvLogin{ + ID: loginID, + Process: login, + NextStep: firstStep, + Override: overrideLogin, + Ctx: ctx, + CancelCtx: cancel, + } + go prov.handleLoginTimeout(provLogin) + prov.loginsLock.Lock() + prov.logins[loginID] = provLogin + prov.loginsLock.Unlock() + zerolog.Ctx(r.Context()).Info(). + Str("login_id", loginID). + Any("first_step", firstStep). + Msg("Created login process") + exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep}) +} + +func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) { + loginID := r.PathValue("loginProcessID") + prov.loginsLock.RLock() + login, ok := prov.logins[loginID] + prov.loginsLock.RUnlock() + if !ok { + zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") + mautrix.MNotFound.WithMessage("Login not found").Write(w) + return + } + stepID := r.PathValue("stepID") + stepType := bridgev2.LoginStepType(r.PathValue("stepType")) + var params map[string]string + var rawParams json.RawMessage + switch stepType { + case bridgev2.LoginStepTypeUserInput, bridgev2.LoginStepTypeCookies: + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + case bridgev2.LoginStepTypeWebAuthn: + var err error + rawParams, err = io.ReadAll(r.Body) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to read request body") + mautrix.MNotJSON.WithMessage("Failed to read request body").Write(w) + return + } else if !json.Valid(rawParams) { + mautrix.MNotJSON.WithMessage("Request body is not valid JSON").Write(w) + return + } + case bridgev2.LoginStepTypeDisplayAndWait: + // no params + case bridgev2.LoginStepTypeComplete: + ErrLoginAlreadyFinished.Write(w) + return + default: + mautrix.MUnrecognized.WithMessage("Invalid step type %q", r.PathValue("stepType")).Write(w) + return + } + resp, err := prov.doLoginStep(r.Context(), login, stepType, stepID, params, rawParams) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to complete login step") + RespondWithError(w, err, "Internal error in login step") + } else if resp.Type == bridgev2.LoginStepTypeWebAuthn && prov.br.Config.Provisioning.FailOnWebAuthn { + prov.deleteLogin(login, true) + zerolog.Ctx(r.Context()).Warn().Msg("Got WebAuthn step, failing login") + bridgev2.RespError{ + ErrCode: "COM.BEEPER.WEBAUTHN_UNSUPPORTED", + Err: "Logging in with a passkey is not yet supported", + StatusCode: http.StatusBadRequest, + }.Write(w) + } else { + exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: resp}) + } +} + +func (prov *ProvisioningAPI) PostLoginCancel(w http.ResponseWriter, r *http.Request) { + loginID := r.PathValue("loginProcessID") + prov.loginsLock.RLock() + login, ok := prov.logins[loginID] + prov.loginsLock.RUnlock() + if !ok { + zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") + mautrix.MNotFound.WithMessage("Login not found").Write(w) + return + } + prov.deleteLogin(login, true) + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) +} + +func (prov *ProvisioningAPI) doLoginStep( + ctx context.Context, + login *ProvLogin, + expectedType bridgev2.LoginStepType, + expectedID string, + params map[string]string, + rawParams json.RawMessage, +) (*bridgev2.LoginStep, error) { + log := zerolog.Ctx(ctx).With().Str("login_id", login.ID).Logger() + var returnPrevIfMatch bool + if !login.Lock.TryLock() { + log.Warn().Msg("Failed to acquire login lock immediately") + returnPrevIfMatch = true + login.Lock.Lock() + } + defer login.Lock.Unlock() + if login.Ctx.Err() != nil { + prov.deleteLogin(login, true) + if login.NextStep.Type == bridgev2.LoginStepTypeComplete { + return login.NextStep, nil + } else if errors.Is(login.Ctx.Err(), context.DeadlineExceeded) { + return nil, ErrLoginTimedOut + } + return nil, ErrLoginCancelled + } + + if returnPrevIfMatch && login.PrevStep != nil && login.PrevStep.StepID == expectedID { + log.Debug(). + Str("prev_step_id", login.PrevStep.StepID). + Any("next_step", login.NextStep). + Msg("Login step that failed to acquire lock requested previous ID, returning last response") + return login.NextStep, nil + } + if login.NextStep.StepID != expectedID { + log.Warn(). + Str("request_step_id", expectedID). + Str("expected_step_id", login.NextStep.StepID). + Msg("Step ID does not match") + return nil, mautrix.MBadState.WithMessage("Step ID does not match") + } + if login.NextStep.Type != expectedType { + log.Warn(). + Str("request_step_type", string(expectedType)). + Str("expected_step_type", string(login.NextStep.Type)). + Msg("Step type does not match") + return nil, mautrix.MBadState.WithMessage("Step type does not match") + } + log.Debug(). + Str("step_id", login.NextStep.StepID). + Str("step_type", string(login.NextStep.Type)). + Msg("Submitting login step") + var nextStep *bridgev2.LoginStep + var err error + switch login.NextStep.Type { + case bridgev2.LoginStepTypeUserInput: + nextStep, err = login.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(login.Ctx, params) + case bridgev2.LoginStepTypeCookies: + nextStep, err = login.Process.(bridgev2.LoginProcessCookies).SubmitCookies(login.Ctx, params) + case bridgev2.LoginStepTypeDisplayAndWait: + nextStep, err = login.Process.(bridgev2.LoginProcessDisplayAndWait).Wait(login.Ctx) + case bridgev2.LoginStepTypeWebAuthn: + nextStep, err = login.Process.(bridgev2.LoginProcessWebAuthn).SubmitWebAuthnResponse(login.Ctx, rawParams) + default: + panic("Impossible state") + } + if err != nil { + prov.deleteLogin(login, true) + return nil, err + } else if nextStep == nil { + prov.deleteLogin(login, true) + return nil, ErrNilStep + } + login.PrevStep = login.NextStep + login.NextStep = nextStep + if nextStep.Type == bridgev2.LoginStepTypeComplete { + prov.handleCompleteStep(login, nextStep) + } else { + log.Debug().Any("next_step", nextStep).Msg("Returning next login step") + } + return nextStep, nil +} + +func (prov *ProvisioningAPI) handleCompleteStep(login *ProvLogin, step *bridgev2.LoginStep) { + ctx := login.Ctx + zerolog.Ctx(ctx).Info(). + Str("step_id", step.StepID). + Str("user_login_id", string(step.CompleteParams.UserLoginID)). + Msg("Login completed successfully") + defer login.CancelCtx() + prov.deleteLogin(login, false) + if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID { + return + } + zerolog.Ctx(ctx).Info(). + Str("old_login_id", string(login.Override.ID)). + Str("new_login_id", string(step.CompleteParams.UserLoginID)). + Msg("Login resulted in different remote ID than what was being overridden. Deleting previous login") + login.Override.Delete(ctx, status.BridgeState{ + StateEvent: status.StateLoggedOut, + Reason: "LOGIN_OVERRIDDEN", + }, bridgev2.DeleteOpts{LogoutRemote: true}) +} + +func (prov *ProvisioningAPI) handleLoginTimeout(login *ProvLogin) { + <-login.Ctx.Done() + if errors.Is(login.Ctx.Err(), context.DeadlineExceeded) { + zerolog.Ctx(login.Ctx).Warn().Msg("Login process timed out, deleting") + login.Process.Cancel() + prov.loginsLock.Lock() + delete(prov.logins, login.ID) + prov.loginsLock.Unlock() + } +} + +func (prov *ProvisioningAPI) deleteLogin(login *ProvLogin, cancel bool) { + if cancel { + login.Process.Cancel() + login.CancelCtx() + } + prov.loginsLock.Lock() + delete(prov.logins, login.ID) + prov.loginsLock.Unlock() +} diff --git a/mautrix-patched/bridgev2/matrix/publicmedia.go b/mautrix-patched/bridgev2/matrix/publicmedia.go new file mode 100644 index 00000000..82ea8c2b --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/publicmedia.go @@ -0,0 +1,278 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var _ bridgev2.MatrixConnectorWithPublicMedia = (*Connector)(nil) + +func (br *Connector) initPublicMedia() error { + if !br.Config.PublicMedia.Enabled { + return nil + } else if br.GetPublicAddress() == "" { + return fmt.Errorf("public media is enabled in config, but no public address is set") + } else if br.Config.PublicMedia.HashLength > 32 { + return fmt.Errorf("public media hash length is too long") + } else if br.Config.PublicMedia.HashLength < 0 { + return fmt.Errorf("public media hash length is negative") + } + br.pubMediaSigKey = []byte(br.Config.PublicMedia.SigningKey) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}", br.serveDatabasePublicMedia) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}/{filename}", br.serveDatabasePublicMedia) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia) + br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}/{filename}", br.servePublicMedia) + return nil +} + +func (br *Connector) hashContentURI(uri id.ContentURI, expiry []byte) []byte { + hasher := hmac.New(sha256.New, br.pubMediaSigKey) + hasher.Write([]byte(uri.String())) + hasher.Write(expiry) + return hasher.Sum(expiry)[:br.Config.PublicMedia.HashLength+len(expiry)] +} + +func (br *Connector) hashDBPublicMedia(pm *database.PublicMedia) []byte { + hasher := hmac.New(sha256.New, br.pubMediaSigKey) + hasher.Write([]byte(pm.MXC.String())) + hasher.Write([]byte(pm.MimeType)) + if pm.Keys != nil { + hasher.Write([]byte(pm.Keys.Version)) + hasher.Write([]byte(pm.Keys.Key.Algorithm)) + hasher.Write([]byte(pm.Keys.Key.Key)) + hasher.Write([]byte(pm.Keys.InitVector)) + hasher.Write([]byte(pm.Keys.Hashes.SHA256)) + } + return hasher.Sum(nil)[:br.Config.PublicMedia.HashLength] +} + +func (br *Connector) makePublicMediaChecksum(uri id.ContentURI) []byte { + var expiresAt []byte + if br.Config.PublicMedia.Expiry > 0 { + expiresAtInt := time.Now().Add(time.Duration(br.Config.PublicMedia.Expiry) * time.Second).Unix() + expiresAt = binary.BigEndian.AppendUint64(nil, uint64(expiresAtInt)) + } + return br.hashContentURI(uri, expiresAt) +} + +func (br *Connector) verifyPublicMediaChecksum(uri id.ContentURI, checksum []byte) (valid, expired bool) { + var expiryBytes []byte + if br.Config.PublicMedia.Expiry > 0 { + if len(checksum) < 8 { + return + } + expiryBytes = checksum[:8] + expiresAtInt := binary.BigEndian.Uint64(expiryBytes) + expired = time.Now().Unix() > int64(expiresAtInt) + } + valid = hmac.Equal(checksum, br.hashContentURI(uri, expiryBytes)) + return +} + +var proxyHeadersToCopy = []string{ + "Content-Type", "Content-Disposition", "Content-Length", "Content-Security-Policy", + "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", + "Cache-Control", "Cross-Origin-Resource-Policy", +} + +func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { + contentURI := id.ContentURI{ + Homeserver: r.PathValue("server"), + FileID: r.PathValue("mediaID"), + } + if !contentURI.IsValid() { + http.Error(w, "invalid content URI", http.StatusBadRequest) + return + } + checksum, err := base64.RawURLEncoding.DecodeString(r.PathValue("checksum")) + if err != nil || !hmac.Equal(checksum, br.makePublicMediaChecksum(contentURI)) { + http.Error(w, "invalid base64 in checksum", http.StatusBadRequest) + return + } else if valid, expired := br.verifyPublicMediaChecksum(contentURI, checksum); !valid { + http.Error(w, "invalid checksum", http.StatusNotFound) + return + } else if expired { + http.Error(w, "checksum expired", http.StatusGone) + return + } + br.doProxyMedia(w, r, contentURI, nil, "") +} + +func (br *Connector) serveDatabasePublicMedia(w http.ResponseWriter, r *http.Request) { + if !br.Config.PublicMedia.UseDatabase { + http.Error(w, "public media short links are disabled", http.StatusNotFound) + return + } + log := zerolog.Ctx(r.Context()) + media, err := br.Bridge.DB.PublicMedia.Get(r.Context(), r.PathValue("customID")) + if err != nil { + log.Err(err).Msg("Failed to get public media from database") + http.Error(w, "failed to get media metadata", http.StatusInternalServerError) + return + } else if media == nil { + http.Error(w, "media ID not found", http.StatusNotFound) + return + } else if !media.Expiry.IsZero() && media.Expiry.Before(time.Now()) { + // This is not gone as it can still be refreshed in the DB + http.Error(w, "media expired", http.StatusNotFound) + return + } else if media.Keys != nil && media.Keys.PrepareForDecryption() != nil { + http.Error(w, "media keys are malformed", http.StatusInternalServerError) + return + } + br.doProxyMedia(w, r, media.MXC, media.Keys, media.MimeType) +} + +var safeMimes = []string{ + "text/css", "text/plain", "text/csv", + "application/json", "application/ld+json", + "image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif", + "video/mp4", "video/webm", "video/ogg", "video/quicktime", + "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", + "audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac", +} + +func (br *Connector) doProxyMedia(w http.ResponseWriter, r *http.Request, contentURI id.ContentURI, encInfo *attachment.EncryptedFile, mimeType string) { + resp, err := br.Bot.Download(r.Context(), contentURI) + if err != nil { + zerolog.Ctx(r.Context()).Warn().Stringer("uri", contentURI).Err(err).Msg("Failed to download media to proxy") + http.Error(w, "failed to download media", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + for _, hdr := range proxyHeadersToCopy { + w.Header()[hdr] = resp.Header[hdr] + } + stream := resp.Body + if encInfo != nil { + if mimeType == "" { + mimeType = "application/octet-stream" + } + contentDisposition := "attachment" + if slices.Contains(safeMimes, mimeType) { + contentDisposition = "inline" + } + dispositionArgs := map[string]string{} + if filename := r.PathValue("filename"); filename != "" { + dispositionArgs["filename"] = filename + } + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, dispositionArgs)) + // Note: this won't check the Close result like it should, but it's probably not a big deal here + stream = encInfo.DecryptStream(stream) + } else if filename := r.PathValue("filename"); filename != "" { + contentDisposition, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) + if contentDisposition == "" { + contentDisposition = "attachment" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, map[string]string{ + "filename": filename, + })) + } + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, stream) +} + +func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) string { + return br.getPublicMediaAddressWithFileName(contentURI, "") +} + +func (br *Connector) getPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string { + if br.pubMediaSigKey == nil { + return "" + } + parsed, err := contentURI.Parse() + if err != nil || !parsed.IsValid() { + return "" + } + fileName = url.PathEscape(strings.ReplaceAll(fileName, "/", "_")) + if fileName == ".." { + fileName = "" + } + parts := []string{ + br.GetPublicAddress(), + strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), + parsed.Homeserver, + parsed.FileID, + base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)), + fileName, + } + if fileName == "" { + parts = parts[:len(parts)-1] + } + return strings.Join(parts, "/") +} + +func (br *Connector) GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) { + if br.pubMediaSigKey == nil { + return "", bridgev2.ErrPublicMediaDisabled + } + if !br.Config.PublicMedia.UseDatabase { + if evt.File != nil { + return "", fmt.Errorf("can't generate address for encrypted file: %w", bridgev2.ErrPublicMediaDatabaseDisabled) + } + return br.getPublicMediaAddressWithFileName(evt.URL, evt.GetFileName()), nil + } + mxc := evt.URL + var keys *attachment.EncryptedFile + if evt.File != nil { + mxc = evt.File.URL + keys = &evt.File.EncryptedFile + } + parsedMXC, err := mxc.Parse() + if err != nil { + return "", fmt.Errorf("%w: failed to parse MXC: %w", bridgev2.ErrPublicMediaGenerateFailed, err) + } + pm := &database.PublicMedia{ + MXC: parsedMXC, + Keys: keys, + MimeType: evt.GetInfo().MimeType, + } + if br.Config.PublicMedia.Expiry > 0 { + pm.Expiry = time.Now().Add(time.Duration(br.Config.PublicMedia.Expiry) * time.Second) + } + pm.PublicID = base64.RawURLEncoding.EncodeToString(br.hashDBPublicMedia(pm)) + err = br.Bridge.DB.PublicMedia.Put(ctx, pm) + if err != nil { + return "", fmt.Errorf("%w: failed to store public media in database: %w", bridgev2.ErrPublicMediaGenerateFailed, err) + } + fileName := url.PathEscape(strings.ReplaceAll(evt.GetFileName(), "/", "_")) + if fileName == ".." { + fileName = "" + } + parts := []string{ + br.GetPublicAddress(), + strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), + pm.PublicID, + fileName, + } + if fileName == "" { + parts = parts[:len(parts)-1] + } + return strings.Join(parts, "/"), nil +} diff --git a/mautrix-patched/bridgev2/matrix/websocket.go b/mautrix-patched/bridgev2/matrix/websocket.go new file mode 100644 index 00000000..b498cacd --- /dev/null +++ b/mautrix-patched/bridgev2/matrix/websocket.go @@ -0,0 +1,169 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" + + "maunium.net/go/mautrix/appservice" +) + +const defaultReconnectBackoff = 2 * time.Second +const maxReconnectBackoff = 2 * time.Minute +const reconnectBackoffReset = 5 * time.Minute + +func (br *Connector) startWebsocket(wg *sync.WaitGroup) { + log := br.Log.With().Str("action", "appservice websocket").Logger() + var wgOnce sync.Once + onConnect := func() { + if br.hasSentAnyStates { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, state := range br.Bridge.GetCurrentBridgeStates() { + err := br.SendBridgeStatus(ctx, &state) + if err != nil { + log.Err(err).Msg("Failed to resend latest bridge state after websocket reconnect") + } else { + log.Debug().Any("bridge_state", state).Msg("Resent bridge state after websocket reconnect") + } + } + }() + } + wgOnce.Do(wg.Done) + select { + case br.wsStarted <- struct{}{}: + default: + } + } + reconnectBackoff := defaultReconnectBackoff + lastDisconnect := time.Now().UnixNano() + br.wsStopped = make(chan struct{}) + defer func() { + log.Debug().Msg("Appservice websocket loop finished") + close(br.wsStopped) + }() + addr := br.Config.Homeserver.WSProxy + if addr == "" { + addr = br.Config.Homeserver.Address + } + for { + err := br.AS.StartWebsocket(br.Bridge.BackgroundCtx, addr, onConnect) + if errors.Is(err, appservice.ErrWebsocketManualStop) { + return + } else if closeCommand := (&appservice.CloseCommand{}); errors.As(err, &closeCommand) && closeCommand.Status == appservice.MeowConnectionReplaced { + log.Warn().Msg("Appservice websocket closed by another instance of the bridge, shutting down...") + if br.OnWebsocketReplaced != nil { + br.OnWebsocketReplaced() + } else { + os.Exit(1) + } + return + } else if err != nil { + log.Err(err).Msg("Error in appservice websocket") + } + if br.stopping { + return + } + now := time.Now().UnixNano() + if lastDisconnect+reconnectBackoffReset.Nanoseconds() < now { + reconnectBackoff = defaultReconnectBackoff + } else { + reconnectBackoff *= 2 + if reconnectBackoff > maxReconnectBackoff { + reconnectBackoff = maxReconnectBackoff + } + } + lastDisconnect = now + log.Info(). + Int("backoff_seconds", int(reconnectBackoff.Seconds())). + Msg("Websocket disconnected, reconnecting...") + select { + case <-br.wsShortCircuitReconnectBackoff: + log.Debug().Msg("Reconnect backoff was short-circuited") + case <-time.After(reconnectBackoff): + } + if br.stopping { + return + } + } +} + +type wsPingData struct { + Timestamp int64 `json:"timestamp"` +} + +func (br *Connector) PingServer() (start, serverTs, end time.Time) { + if !br.Websocket { + panic(fmt.Errorf("PingServer called without websocket enabled")) + } + if !br.AS.HasWebsocket() { + br.Log.Debug().Msg("Received server ping request, but no websocket connected. Trying to short-circuit backoff sleep") + select { + case br.wsShortCircuitReconnectBackoff <- struct{}{}: + default: + br.Log.Warn().Msg("Failed to ping websocket: not connected and no backoff?") + return + } + select { + case <-br.wsStarted: + case <-time.After(15 * time.Second): + if !br.AS.HasWebsocket() { + br.Log.Warn().Msg("Failed to ping websocket: didn't connect after 15 seconds of waiting") + return + } + } + } + start = time.Now() + var resp wsPingData + br.Log.Debug().Msg("Pinging appservice websocket") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := br.AS.RequestWebsocket(ctx, &appservice.WebsocketRequest{ + Command: "ping", + Data: &wsPingData{Timestamp: start.UnixMilli()}, + }, &resp) + end = time.Now() + if err != nil { + br.Log.Warn().Err(err).Dur("duration", end.Sub(start)).Msg("Websocket ping returned error") + br.AS.StopWebsocket(fmt.Errorf("websocket ping returned error in %s: %w", end.Sub(start), err)) + } else { + serverTs = time.Unix(0, resp.Timestamp*int64(time.Millisecond)) + br.Log.Debug(). + Dur("duration", end.Sub(start)). + Dur("req_duration", serverTs.Sub(start)). + Dur("resp_duration", end.Sub(serverTs)). + Msg("Websocket ping returned success") + } + return +} + +func (br *Connector) websocketServerPinger() { + interval := time.Duration(br.Config.Homeserver.WSPingInterval) * time.Second + clock := time.NewTicker(interval) + defer func() { + br.Log.Info().Msg("Stopping websocket pinger") + clock.Stop() + }() + br.Log.Info().Dur("interval_duration", interval).Msg("Starting websocket pinger") + for { + select { + case <-clock.C: + br.PingServer() + case <-br.wsStopPinger: + return + } + if br.stopping { + return + } + } +} diff --git a/mautrix-patched/bridgev2/matrixinterface.go b/mautrix-patched/bridgev2/matrixinterface.go new file mode 100644 index 00000000..8d94053f --- /dev/null +++ b/mautrix-patched/bridgev2/matrixinterface.go @@ -0,0 +1,243 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + "go.mau.fi/util/exhttp" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type MatrixCapabilities struct { + AutoJoinInvites bool + BatchSending bool + ArbitraryMemberChange bool + ExtraProfileMeta bool + ReplaceEntireProfile bool +} + +type BeeperStreamPublisher interface { + NewDescriptor(ctx context.Context, roomID id.RoomID, streamType string) (*event.BeeperStreamInfo, error) + Register(ctx context.Context, roomID id.RoomID, eventID id.EventID, descriptor *event.BeeperStreamInfo) error + Publish(ctx context.Context, roomID id.RoomID, eventID id.EventID, delta map[string]any) error + Unregister(roomID id.RoomID, eventID id.EventID) +} + +type MatrixConnector interface { + Init(*Bridge) + Start(ctx context.Context) error + PreStop() + Stop() + + GetCapabilities() *MatrixCapabilities + + ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) + GhostIntent(userID networkid.UserID) MatrixAPI + NewUserIntent(ctx context.Context, userID id.UserID, accessToken string) (MatrixAPI, string, error) + BotIntent() MatrixAPI + + SendBridgeStatus(ctx context.Context, state *status.BridgeState) error + SendMessageStatus(ctx context.Context, status *MessageStatus, evt *MessageStatusEventInfo) + + GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) + ParseContentURI(ctx context.Context, contentURI id.ContentURIString) (networkid.MediaID, error) + + GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) + GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) + GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) + + BatchSend(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBeeperBatchSend, extras []*MatrixSendExtra) (*mautrix.RespBeeperBatchSend, error) + GenerateDeterministicRoomID(portalKey networkid.PortalKey) id.RoomID + GenerateDeterministicEventID(roomID id.RoomID, portalKey networkid.PortalKey, messageID networkid.MessageID, partID networkid.PartID) id.EventID + GenerateReactionEventID(roomID id.RoomID, targetMessage *database.Message, sender networkid.UserID, emojiID networkid.EmojiID) id.EventID + + ServerName() string +} + +type MatrixConnectorWithArbitraryRoomState interface { + MatrixConnector + GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) +} + +type MatrixConnectorWithServer interface { + MatrixConnector + GetPublicAddress() string + GetRouter() *http.ServeMux +} + +type IProvisioningAPI interface { + GetRouter() *http.ServeMux + GetUser(r *http.Request) *User +} + +type MatrixConnectorWithProvisioning interface { + MatrixConnector + GetProvisioning() IProvisioningAPI +} + +type MatrixConnectorWithPublicMedia interface { + MatrixConnector + GetPublicMediaAddress(contentURI id.ContentURIString) string + GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) +} + +type MatrixConnectorWithNameDisambiguation interface { + MatrixConnector + IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error) +} + +type MatrixConnectorWithBridgeIdentifier interface { + MatrixConnector + GetUniqueBridgeID() string +} + +type MatrixConnectorWithURLPreviews interface { + MatrixConnector + GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error) +} + +type MatrixConnectorWithPostRoomBridgeHandling interface { + MatrixConnector + HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error +} + +type MatrixConnectorWithAnalytics interface { + MatrixConnector + TrackAnalytics(userID id.UserID, event string, properties map[string]any) +} + +type MatrixConnectorWithBeeperStreams interface { + MatrixConnector + GetBeeperStreamPublisher() BeeperStreamPublisher +} + +type DirectNotificationData struct { + Portal *Portal + Sender *Ghost + MessageID networkid.MessageID + Message string + + FormattedNotification string + FormattedTitle string +} + +type MatrixConnectorWithNotifications interface { + MatrixConnector + DisplayNotification(ctx context.Context, data *DirectNotificationData) +} + +type MatrixConnectorWithHTTPSettings interface { + MatrixConnector + GetHTTPClientSettings() exhttp.ClientSettings +} + +type MatrixSendExtra struct { + Timestamp time.Time + MessageMeta *database.Message + ReactionMeta *database.Reaction + StreamOrder int64 + PartIndex int +} + +// FileStreamResult is the result of a FileStreamCallback. +type FileStreamResult struct { + // ReplacementFile is the path to a new file that replaces the original file provided to the callback. + // Providing a replacement file is only allowed if the requireFile flag was set for the UploadMediaStream call. + ReplacementFile string + // FileName is the name of the file to be specified when uploading to the server. + // This should be the same as the file name that will be included in the Matrix event (body or filename field). + // If the file gets encrypted, this field will be ignored. + FileName string + // MimeType is the type of field to be specified when uploading to the server. + // This should be the same as the mime type that will be included in the Matrix event (info -> mimetype field). + // If the file gets encrypted, this field will be replaced with application/octet-stream. + MimeType string +} + +// FileStreamCallback is a callback function for file uploads that roundtrip via disk. +// +// The parameter is either a file or an in-memory buffer depending on the size of the file and whether the requireFile flag was set. +// +// The return value must be non-nil unless there's an error, and should always include FileName and MimeType. +type FileStreamCallback func(file io.Writer) (*FileStreamResult, error) + +type CallbackError struct { + Type string + Wrapped error +} + +func (ce CallbackError) Error() string { + return fmt.Sprintf("%s callback failed: %s", ce.Type, ce.Wrapped.Error()) +} + +func (ce CallbackError) Unwrap() error { + return ce.Wrapped +} + +type EnsureJoinedParams struct { + Via []string +} + +type MatrixAPI interface { + GetMXID() id.UserID + IsDoublePuppet() bool + + SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *MatrixSendExtra) (*mautrix.RespSendEvent, error) + SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (*mautrix.RespSendEvent, error) + MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, ts time.Time) error + MarkUnread(ctx context.Context, roomID id.RoomID, unread bool) error + MarkTyping(ctx context.Context, roomID id.RoomID, typingType TypingType, timeout time.Duration) error + DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) + DownloadMediaToFile(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo, writable bool, callback func(*os.File) error) error + UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) + UploadMediaStream(ctx context.Context, roomID id.RoomID, size int64, requireFile bool, cb FileStreamCallback) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) + + SetDisplayName(ctx context.Context, name string) error + SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error + SetExtraProfileMeta(ctx context.Context, data any) error + SetProfile(ctx context.Context, data any) error + + CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) + DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error + EnsureJoined(ctx context.Context, roomID id.RoomID, params ...EnsureJoinedParams) error + EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error + + TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error + MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error + + GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) +} + +type StreamOrderReadingMatrixAPI interface { + MatrixAPI + MarkStreamOrderRead(ctx context.Context, roomID id.RoomID, streamOrder int64, ts time.Time) error +} + +type MarkAsDMMatrixAPI interface { + MatrixAPI + MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error +} + +// MatrixAPIWithArbitraryRoomState is an extension of MatrixAPI that allows fetching arbitrary state events from a room. +// This should only be used with double puppets when the bridge wants to ensure that the caller has access to the room. +// For any other use case, use MatrixConnectorWithArbitraryRoomState instead, which uses the bridge bot. +type MatrixAPIWithArbitraryRoomState interface { + MatrixAPI + GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) +} diff --git a/mautrix-patched/bridgev2/matrixinvite.go b/mautrix-patched/bridgev2/matrixinvite.go new file mode 100644 index 00000000..19fb6950 --- /dev/null +++ b/mautrix-patched/bridgev2/matrixinvite.go @@ -0,0 +1,294 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { + log := zerolog.Ctx(ctx) + // These invites should already be rejected in QueueMatrixEvent + if !sender.Permissions.Commands { + log.Warn().Msg("Received bot invite from user without permission to send commands") + return EventHandlingResultIgnored + } + err := br.Bot.EnsureJoined(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to accept invite to room") + return EventHandlingResultFailed.WithError(err) + } + log.Debug().Msg("Accepted invite to room as bot") + members, err := br.Matrix.GetMembers(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to get members of room after accepting invite") + } + if len(members) == 2 { + var message string + if sender.ManagementRoom == "" { + message = fmt.Sprintf("Hello, I'm a %s bridge bot.\n\nUse `help` for help or `login` to log in.\n\nThis room has been marked as your management room.", br.Network.GetName().DisplayName) + sender.ManagementRoom = evt.RoomID + err = br.DB.User.Update(ctx, sender.User) + if err != nil { + log.Err(err).Msg("Failed to update user's management room in database") + } + } else { + message = fmt.Sprintf("Hello, I'm a %s bridge bot.\n\nUse `%s help` for help.", br.Network.GetName().DisplayName, br.Config.CommandPrefix) + } + _, err = br.Bot.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{ + Parsed: format.RenderMarkdown(message, true, false), + }, nil) + if err != nil { + log.Err(err).Msg("Failed to send welcome message to room") + } + } + return EventHandlingResultSuccess +} + +func sendNotice(ctx context.Context, evt *event.Event, intent MatrixAPI, message string, args ...any) { + if len(args) > 0 { + message = fmt.Sprintf(message, args...) + } + content := format.RenderMarkdown(message, true, false) + content.MsgType = event.MsgNotice + resp, err := intent.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("room_id", evt.RoomID). + Stringer("inviter_id", evt.Sender). + Stringer("invitee_id", intent.GetMXID()). + Str("notice_text", message). + Msg("Failed to send notice") + } else { + zerolog.Ctx(ctx).Debug(). + Stringer("notice_event_id", resp.EventID). + Stringer("room_id", evt.RoomID). + Stringer("inviter_id", evt.Sender). + Stringer("invitee_id", intent.GetMXID()). + Str("notice_text", message). + Msg("Sent notice") + } +} + +func sendErrorAndLeave(ctx context.Context, evt *event.Event, intent MatrixAPI, message string, args ...any) { + sendNotice(ctx, evt, intent, message, args...) + rejectInvite(ctx, evt, intent, "") +} + +func (portal *Portal) CleanupOrphanedDM(ctx context.Context, userMXID id.UserID) { + if portal.MXID == "" { + return + } + log := zerolog.Ctx(ctx) + existingPortalMembers, err := portal.Bridge.Matrix.GetMembers(ctx, portal.MXID) + if err != nil { + log.Err(err). + Stringer("old_portal_mxid", portal.MXID). + Msg("Failed to check existing portal members, deleting room") + } else if targetUserMember, ok := existingPortalMembers[userMXID]; !ok { + log.Debug(). + Stringer("old_portal_mxid", portal.MXID). + Msg("Inviter has no member event in old portal, deleting room") + } else if targetUserMember.Membership.IsInviteOrJoin() { + return + } else { + log.Debug(). + Stringer("old_portal_mxid", portal.MXID). + Str("membership", string(targetUserMember.Membership)). + Msg("Inviter is not in old portal, deleting room") + } + + if err = portal.RemoveMXID(ctx); err != nil { + log.Err(err).Msg("Failed to delete old portal mxid") + } else if err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { + log.Err(err).Msg("Failed to clean up old portal room") + } +} + +func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { + ghostID, _ := br.Matrix.ParseGhostMXID(id.UserID(evt.GetStateKey())) + validator, ok := br.Network.(IdentifierValidatingNetwork) + if ghostID == "" || (ok && !validator.ValidateUserID(ghostID)) { + rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "Malformed user ID") + return EventHandlingResultIgnored + } + log := zerolog.Ctx(ctx).With(). + Str("invitee_network_id", string(ghostID)). + Stringer("room_id", evt.RoomID). + Logger() + // TODO sort in preference order + logins := sender.GetUserLogins() + if len(logins) == 0 { + rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "You're not logged in") + return EventHandlingResultIgnored + } + _, ok = logins[0].Client.(IdentifierResolvingNetworkAPI) + if !ok { + rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "This bridge does not support starting chats") + return EventHandlingResultIgnored + } + invitedGhost, err := br.GetGhostByID(ctx, ghostID) + if err != nil { + log.Err(err).Msg("Failed to get invited ghost") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get invited ghost: %w", err)) + } + err = invitedGhost.Intent.EnsureJoined(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to accept invite to room") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to accept invite: %w", err)) + } + var resp *CreateChatResponse + var sourceLogin *UserLogin + // TODO this should somehow lock incoming event processing to avoid race conditions where a new portal room is created + // between ResolveIdentifier returning and the portal MXID being updated. + for _, login := range logins { + api, ok := login.Client.(IdentifierResolvingNetworkAPI) + if !ok { + continue + } + var resolveResp *ResolveIdentifierResponse + ghostAPI, ok := login.Client.(GhostDMCreatingNetworkAPI) + if ok { + resp, err = ghostAPI.CreateChatWithGhost(ctx, invitedGhost) + } else { + resolveResp, err = api.ResolveIdentifier(ctx, string(ghostID), true) + if resolveResp != nil { + resp = resolveResp.Chat + } + } + if errors.Is(err, ErrResolveIdentifierTryNext) { + log.Debug().Err(err).Str("login_id", string(login.ID)).Msg("Failed to resolve identifier, trying next login") + continue + } else if err != nil { + log.Err(err).Msg("Failed to resolve identifier") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to create chat") + return EventHandlingResultFailed.WithError(err) + } else { + sourceLogin = login + break + } + } + if resp == nil { + log.Warn().Msg("No login could resolve the identifier") + sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create chat via any login") + return EventHandlingResultFailed.WithError(fmt.Errorf("no login could resolve the invited identifier")) + } + portal := resp.Portal + if portal == nil { + portal, err = br.GetPortalByKey(ctx, resp.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get portal by key") + sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create portal entry") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get portal by key: %w", err)) + } + } + portal.CleanupOrphanedDM(ctx, sender.MXID) + err = invitedGhost.Intent.EnsureInvited(ctx, evt.RoomID, br.Bot.GetMXID()) + if err != nil { + log.Err(err).Msg("Failed to ensure bot is invited to room") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to invite bridge bot") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to ensure bot is invited: %w", err)) + } + err = br.Bot.EnsureJoined(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to ensure bot is joined to room") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to join with bridge bot") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to ensure bot is joined: %w", err)) + } + + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + portalMXID := portal.MXID + if portalMXID != "" { + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portalMXID, portalMXID.URI(br.Matrix.ServerName()).MatrixToURL()) + rejectInvite(ctx, evt, br.Bot, "") + return EventHandlingResultSuccess + } + err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) + if err != nil { + log.Err(err).Msg("Failed to give permissions to bridge bot") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to give permissions to bridge bot") + rejectInvite(ctx, evt, br.Bot, "") + return EventHandlingResultSuccess + } + overrideIntent := invitedGhost.Intent + if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { + log.Debug(). + Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). + Msg("Created DM was redirected to another user ID") + _, err = invitedGhost.Intent.SendState(ctx, evt.RoomID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: "Direct chat redirected to another internal user ID", + }, + }, time.Time{}) + if err != nil { + log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") + } + if resp.DMRedirectedTo == SpecialValueDMRedirectedToBot { + overrideIntent = br.Bot + } else if otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo); err != nil { + log.Err(err).Msg("Failed to get ghost of real portal other user ID") + } else { + invitedGhost = otherUserGhost + overrideIntent = otherUserGhost.Intent + } + } + err = portal.UpdateMatrixRoomID(ctx, evt.RoomID, UpdateMatrixRoomIDParams{ + // We locked it before checking the mxid + RoomCreateAlreadyLocked: true, + + FailIfMXIDSet: true, + ChatInfo: resp.PortalInfo, + ChatInfoSource: sourceLogin, + }) + if err != nil { + log.Err(err).Msg("Failed to update Matrix room ID for new DM portal") + sendNotice(ctx, evt, overrideIntent, "Failed to finish configuring portal. The chat may or may not work") + return EventHandlingResultSuccess + } + message := "Private chat portal created" + mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling) + if ok { + err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Error in connector newly bridged room handler") + message += fmt.Sprintf("\n\nWarning: %s", err.Error()) + } + } + sendNotice(ctx, evt, overrideIntent, message) + return EventHandlingResultSuccess +} + +func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWithPower MatrixAPI) error { + powers, err := br.Matrix.GetPowerLevels(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to get power levels: %w", err) + } + userLevel := powers.GetUserLevel(userWithPower.GetMXID()) + if powers.EnsureUserLevelAs(userWithPower.GetMXID(), br.Bot.GetMXID(), userLevel) { + if userLevel > powers.UsersDefault { + powers.SetUserLevel(userWithPower.GetMXID(), userLevel-1) + } + _, err = userWithPower.SendState(ctx, roomID, event.StatePowerLevels, "", &event.Content{ + Parsed: powers, + }, time.Time{}) + if err != nil { + return fmt.Errorf("failed to give power to bot: %w", err) + } + } + return nil +} diff --git a/mautrix-patched/bridgev2/messagestatus.go b/mautrix-patched/bridgev2/messagestatus.go new file mode 100644 index 00000000..462b668e --- /dev/null +++ b/mautrix-patched/bridgev2/messagestatus.go @@ -0,0 +1,243 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "errors" + "fmt" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type MessageStatusEventInfo struct { + RoomID id.RoomID + TransactionID string + SourceEventID id.EventID + NewEventID id.EventID + EventType event.Type + MessageType event.MessageType + Sender id.UserID + ThreadRoot id.EventID + StreamOrder int64 + + IsSourceEventDoublePuppeted bool +} + +func StatusEventInfoFromEvent(evt *event.Event) *MessageStatusEventInfo { + var threadRoot id.EventID + if relatable, ok := evt.Content.Parsed.(event.Relatable); ok { + threadRoot = relatable.OptionalGetRelatesTo().GetThreadParent() + } + + _, isDoublePuppeted := evt.Content.Raw[appservice.DoublePuppetKey] + + return &MessageStatusEventInfo{ + RoomID: evt.RoomID, + TransactionID: evt.Unsigned.TransactionID, + SourceEventID: evt.ID, + EventType: evt.Type, + MessageType: evt.Content.AsMessage().MsgType, + Sender: evt.Sender, + ThreadRoot: threadRoot, + + IsSourceEventDoublePuppeted: isDoublePuppeted, + } +} + +// MessageStatus represents the status of a message. It also implements the error interface to allow network connectors +// to return errors which get translated into user-friendly error messages and/or status events. +type MessageStatus struct { + Step status.MessageCheckpointStep + RetryNum int + + Status event.MessageStatus + ErrorReason event.MessageStatusReason + DeliveredTo []id.UserID + InternalError error // Internal error to be tracked in message checkpoints + Message string // Human-readable message shown to users + + ErrorAsMessage bool + IsCertain bool + SendNotice bool + DisableMSS bool +} + +func WrapErrorInStatus(err error) MessageStatus { + var alreadyWrapped MessageStatus + var ok bool + if alreadyWrapped, ok = err.(MessageStatus); ok { + return alreadyWrapped + } else if errors.As(err, &alreadyWrapped) { + alreadyWrapped.InternalError = err + return alreadyWrapped + } + return MessageStatus{ + Status: event.MessageStatusRetriable, + ErrorReason: event.MessageStatusGenericError, + InternalError: err, + } +} + +func (ms MessageStatus) WithSendNotice(send bool) MessageStatus { + ms.SendNotice = send + return ms +} + +func (ms MessageStatus) WithIsCertain(certain bool) MessageStatus { + ms.IsCertain = certain + return ms +} + +func (ms MessageStatus) WithMessage(msg string) MessageStatus { + ms.Message = msg + ms.ErrorAsMessage = false + return ms +} + +func (ms MessageStatus) WithStep(step status.MessageCheckpointStep) MessageStatus { + ms.Step = step + return ms +} + +func (ms MessageStatus) WithStatus(status event.MessageStatus) MessageStatus { + ms.Status = status + return ms +} + +func (ms MessageStatus) WithErrorReason(reason event.MessageStatusReason) MessageStatus { + ms.ErrorReason = reason + return ms +} + +func (ms MessageStatus) WithErrorAsMessage() MessageStatus { + ms.ErrorAsMessage = true + return ms +} + +func (ms MessageStatus) Error() string { + return ms.InternalError.Error() +} + +func (ms MessageStatus) Unwrap() error { + return ms.InternalError +} + +func (ms MessageStatus) Is(other error) bool { + return errors.Is(other, ms.InternalError) +} + +func (ms *MessageStatus) checkpointStatus() status.MessageCheckpointStatus { + switch ms.Status { + case event.MessageStatusSuccess: + if len(ms.DeliveredTo) > 0 { + return status.MsgStatusDelivered + } + return status.MsgStatusSuccess + case event.MessageStatusPending: + return status.MsgStatusWillRetry + case event.MessageStatusRetriable, event.MessageStatusFail: + switch ms.ErrorReason { + case event.MessageStatusTooOld: + return status.MsgStatusTimeout + case event.MessageStatusUnsupported: + return status.MsgStatusUnsupported + default: + return status.MsgStatusPermFailure + } + default: + return "UNKNOWN" + } +} + +func (ms *MessageStatus) ToCheckpoint(evt *MessageStatusEventInfo) *status.MessageCheckpoint { + step := status.MsgStepRemote + if ms.Step != "" { + step = ms.Step + } + checkpoint := &status.MessageCheckpoint{ + RoomID: evt.RoomID, + EventID: evt.SourceEventID, + Step: step, + Timestamp: jsontime.UnixMilliNow(), + Status: ms.checkpointStatus(), + RetryNum: ms.RetryNum, + ReportedBy: status.MsgReportedByBridge, + EventType: evt.EventType, + MessageType: evt.MessageType, + } + if ms.InternalError != nil { + checkpoint.Info = ms.InternalError.Error() + } else if ms.Message != "" { + checkpoint.Info = ms.Message + } + return checkpoint +} + +func (ms *MessageStatus) ToMSSEvent(evt *MessageStatusEventInfo) *event.BeeperMessageStatusEventContent { + content := &event.BeeperMessageStatusEventContent{ + RelatesTo: event.RelatesTo{ + Type: event.RelReference, + EventID: evt.SourceEventID, + }, + TargetTxnID: evt.TransactionID, + Status: ms.Status, + Reason: ms.ErrorReason, + Message: ms.Message, + } + if ms.InternalError != nil { + content.InternalError = ms.InternalError.Error() + if ms.ErrorAsMessage { + content.Message = content.InternalError + } + } + if ms.DeliveredTo != nil { + content.DeliveredToUsers = &ms.DeliveredTo + } + return content +} + +func (ms *MessageStatus) ToNoticeEvent(evt *MessageStatusEventInfo) *event.MessageEventContent { + certainty := "may not have been" + if ms.IsCertain { + certainty = "was not" + } + evtType := "message" + switch evt.EventType { + case event.EventReaction: + evtType = "reaction" + case event.EventRedaction: + evtType = "redaction" + } + msg := ms.Message + if ms.ErrorAsMessage || msg == "" { + msg = ms.InternalError.Error() + } + messagePrefix := fmt.Sprintf("Your %s %s bridged", evtType, certainty) + if ms.Step == status.MsgStepCommand { + messagePrefix = "Handling your command panicked" + } + content := &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: fmt.Sprintf("\u26a0\ufe0f %s: %s", messagePrefix, msg), + RelatesTo: &event.RelatesTo{}, + Mentions: &event.Mentions{}, + } + if evt.ThreadRoot != "" { + content.RelatesTo.SetThread(evt.ThreadRoot, evt.SourceEventID) + } else { + content.RelatesTo.SetReplyTo(evt.SourceEventID) + } + if evt.Sender != "" { + content.Mentions.UserIDs = []id.UserID{evt.Sender} + } + return content +} diff --git a/mautrix-patched/bridgev2/networkid/bridgeid.go b/mautrix-patched/bridgev2/networkid/bridgeid.go new file mode 100644 index 00000000..e3a6df70 --- /dev/null +++ b/mautrix-patched/bridgev2/networkid/bridgeid.go @@ -0,0 +1,146 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package networkid contains string types used to represent different kinds of identifiers on remote networks. +// +// Except for [BridgeID], all types in this package are only generated by network connectors. +// Network connectors may generate and parse these types any way they want, all other components +// will treat them as opaque identifiers and will not parse them nor assume anything about them. +// However, identifiers are stored in the bridge database, so backwards-compatibility must be +// considered when changing the format. +// +// All IDs are scoped to a bridge, i.e. they don't need to be unique across different bridges. +// However, most IDs need to be globally unique within the bridge, i.e. the same ID must refer +// to the same entity even from another user's point of view. If the remote network does not +// directly provide such globally unique identifiers, the network connector should prefix them +// with a user ID or other identifier to make them unique. +package networkid + +import ( + "fmt" + + "github.com/rs/zerolog" +) + +// BridgeID is an opaque identifier for a bridge +type BridgeID string + +// PortalID is the ID of a room on the remote network. A portal ID alone should identify group chats +// uniquely, and also DMs when scoped to a user login ID (see [PortalKey]). +type PortalID string + +// PortalKey is the unique key of a room on the remote network. It combines a portal ID and a receiver ID. +// +// The Receiver field is generally only used for DMs, and should be empty for group chats. +// The purpose is to segregate DMs by receiver, so that the same DM has separate rooms even +// if both sides are logged into the bridge. Also, for networks that use user IDs as DM chat IDs, +// the receiver is necessary to have separate rooms for separate users who have a DM with the same +// remote user. +// +// It is also permitted to use a non-empty receiver for group chats if there is a good reason to +// segregate them. For example, Telegram's non-supergroups have user-scoped message IDs instead +// of chat-scoped IDs, which is easier to manage with segregated rooms. +// +// As a special case, Receiver MUST be set if the Bridge.Config.SplitPortals flag is set to true. +// The flag is intended for puppeting-only bridges which want multiple logins to create separate portals for each user. +type PortalKey struct { + ID PortalID `json:"portal_id"` + Receiver UserLoginID `json:"portal_receiver,omitempty"` +} + +func (pk PortalKey) IsEmpty() bool { + return pk.ID == "" && pk.Receiver == "" +} + +func (pk PortalKey) String() string { + if pk.Receiver == "" { + return string(pk.ID) + } + return fmt.Sprintf("%s/%s", pk.ID, pk.Receiver) +} + +func (pk PortalKey) MarshalZerologObject(evt *zerolog.Event) { + evt.Str("portal_id", string(pk.ID)) + if pk.Receiver != "" { + evt.Str("portal_receiver", string(pk.Receiver)) + } +} + +// UserID is the ID of a user on the remote network. +// +// User IDs must be globally unique within the bridge for identifying a specific remote user. +type UserID string + +// UserLoginID is the ID of the user being controlled on the remote network. +// +// It may be the same shape as [UserID]. However, being the same shape is not required, and the +// central bridge module and Matrix connectors will never assume it is. Instead, the bridge will +// use methods like [maunium.net/go/mautrix/bridgev2.NetworkAPI.IsThisUser] to check if a user ID +// is associated with a given UserLogin. +// The network connector is of course allowed to assume a UserLoginID is equivalent to a UserID, +// because it is the one defining both types. +type UserLoginID string + +// MessageID is the ID of a message on the remote network. +// +// Message IDs must be unique across rooms and consistent across users (i.e. globally unique within the bridge). +type MessageID string + +// TransactionID is a client-generated identifier for a message send operation on the remote network. +// +// Transaction IDs must be unique across users in a room, but don't need to be unique across different rooms. +type TransactionID string + +// RawTransactionID is a client-generated identifier for a message send operation on the remote network. +// +// Unlike TransactionID, RawTransactionID's are only used for sending and don't have any uniqueness requirements. +type RawTransactionID string + +// PartID is the ID of a message part on the remote network (e.g. index of image in album). +// +// Part IDs are only unique within a message, not globally. +// To refer to a specific message part globally, use the MessagePartID tuple struct. +type PartID string + +// MessagePartID refers to a specific part of a message by combining a message ID and a part ID. +type MessagePartID struct { + MessageID MessageID + PartID PartID +} + +// MessageOptionalPartID refers to a specific part of a message by combining a message ID and an optional part ID. +// If the part ID is not set, this should refer to the first part ID sorted alphabetically. +type MessageOptionalPartID struct { + MessageID MessageID + PartID *PartID +} + +// PaginationCursor is a cursor used for paginating message history. +type PaginationCursor string + +// AvatarID is the ID of a user or room avatar on the remote network. +// +// It may be a real URL, an opaque identifier, or anything in between. It should be an identifier that +// can be acquired from the remote network without downloading the entire avatar. +// +// In general, it is preferred to use a stable identifier which only changes when the avatar changes. +// However, the bridge will also hash the avatar data to check for changes before sending an avatar +// update to Matrix, so the avatar ID being slightly unstable won't be the end of the world. +type AvatarID string + +// EmojiID is the ID of a reaction emoji on the remote network. +// +// On networks that only allow one reaction per message, an empty string should be used +// to apply the unique constraints in the database appropriately. +// On networks that allow multiple emojis, this is the unicode emoji or a network-specific shortcode. +type EmojiID string + +// MediaID represents a media identifier that can be downloaded from the remote network at any point in the future. +// +// This is used to implement on-demand media downloads. The network connector can ask the Matrix connector +// to generate a content URI from a media ID. Then, when the Matrix connector wants to download the media, +// it will parse the content URI and ask the network connector for the data using the media ID. +type MediaID []byte diff --git a/mautrix-patched/bridgev2/networkinterface.go b/mautrix-patched/bridgev2/networkinterface.go new file mode 100644 index 00000000..22559ee3 --- /dev/null +++ b/mautrix-patched/bridgev2/networkinterface.go @@ -0,0 +1,1501 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/configupgrade" + "go.mau.fi/util/ptr" + "go.mau.fi/util/random" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mediaproxy" +) + +type ConvertedMessagePart struct { + ID networkid.PartID + Type event.Type + Content *event.MessageEventContent + Extra map[string]any + DBMetadata any + DontBridge bool +} + +func (cmp *ConvertedMessagePart) ToEditPart(part *database.Message) *ConvertedEditPart { + if cmp == nil { + return nil + } + if cmp.DBMetadata != nil { + merger, ok := part.Metadata.(database.MetaMerger) + if ok { + merger.CopyFrom(cmp.DBMetadata) + } else { + part.Metadata = cmp.DBMetadata + } + } + return &ConvertedEditPart{ + Part: part, + Type: cmp.Type, + Content: cmp.Content, + Extra: cmp.Extra, + DontBridge: cmp.DontBridge, + } +} + +// EventSender represents a specific user in a chat. +type EventSender struct { + // If IsFromMe is true, the UserLogin who the event was received through is used as the sender. + // Double puppeting will be used if available. + IsFromMe bool + // SenderLogin is the ID of the UserLogin who sent the event. This may be different from the + // login the event was received through. It is used to ensure double puppeting can still be + // used even if the event is received through another login. + SenderLogin networkid.UserLoginID + // Sender is the remote user ID of the user who sent the event. + // For new events, this will not be used for double puppeting. + // + // However, in the member list, [ChatMemberList.CheckAllLogins] can be specified to go through every login + // and call [NetworkAPI.IsThisUser] to check if this ID belongs to that login. This method is not recommended, + // it is better to fill the IsFromMe and SenderLogin fields appropriately. + Sender networkid.UserID + + // ForceDMUser can be set if the event should be sent as the DM user even if the Sender is different. + // This only applies in DM rooms where [database.Portal.OtherUserID] is set and is ignored if IsFromMe is true. + // A warning will be logged if the sender is overridden due to this flag. + ForceDMUser bool + + // Force using the original message sender to send the edit + ForceEditOrigSender bool +} + +func (es EventSender) MarshalZerologObject(evt *zerolog.Event) { + evt.Str("user_id", string(es.Sender)) + if string(es.SenderLogin) != string(es.Sender) { + evt.Str("sender_login", string(es.SenderLogin)) + } + if es.IsFromMe { + evt.Bool("is_from_me", true) + } + if es.ForceDMUser { + evt.Bool("force_dm_user", true) + } +} + +type ConvertedMessage struct { + ReplyTo *networkid.MessageOptionalPartID + // Optional additional info about the reply. This is only used when backfilling messages + // on Beeper, where replies may target messages that haven't been bridged yet. + // Standard Matrix servers can't backwards backfill, so these are never used. + ReplyToRoom networkid.PortalKey + ReplyToUser networkid.UserID + ReplyToLogin networkid.UserLoginID + + ThreadRoot *networkid.MessageID + Parts []*ConvertedMessagePart + Disappear database.DisappearingSetting +} + +func MergeCaption(textPart, mediaPart *ConvertedMessagePart) *ConvertedMessagePart { + if textPart == nil { + return mediaPart + } else if mediaPart == nil { + return textPart + } + mediaPart = ptr.Clone(mediaPart) + if mediaPart.Content.MsgType == event.MsgNotice || (mediaPart.Content.Body != "" && mediaPart.Content.FileName != "" && mediaPart.Content.Body != mediaPart.Content.FileName) { + textPart = ptr.Clone(textPart) + textPart.Content.EnsureHasHTML() + mediaPart.Content.EnsureHasHTML() + mediaPart.Content.Body += "\n\n" + textPart.Content.Body + mediaPart.Content.FormattedBody += "

" + textPart.Content.FormattedBody + mediaPart.Content.Mentions = mediaPart.Content.Mentions.Merge(textPart.Content.Mentions) + mediaPart.Content.BeeperLinkPreviews = append(mediaPart.Content.BeeperLinkPreviews, textPart.Content.BeeperLinkPreviews...) + } else { + mediaPart.Content.FileName = mediaPart.Content.Body + mediaPart.Content.Body = textPart.Content.Body + mediaPart.Content.Format = textPart.Content.Format + mediaPart.Content.FormattedBody = textPart.Content.FormattedBody + mediaPart.Content.Mentions = textPart.Content.Mentions + mediaPart.Content.BeeperLinkPreviews = textPart.Content.BeeperLinkPreviews + } + if metaMerger, ok := mediaPart.DBMetadata.(database.MetaMerger); ok { + metaMerger.CopyFrom(textPart.DBMetadata) + } + mediaPart.ID = textPart.ID + return mediaPart +} + +func (cm *ConvertedMessage) MergeCaption() bool { + if len(cm.Parts) != 2 { + return false + } + textPart, mediaPart := cm.Parts[1], cm.Parts[0] + if textPart.Content.MsgType != event.MsgText { + textPart, mediaPart = mediaPart, textPart + } + if (!mediaPart.Content.MsgType.IsMedia() && mediaPart.Content.MsgType != event.MsgNotice) || textPart.Content.MsgType != event.MsgText { + return false + } + merged := MergeCaption(textPart, mediaPart) + if merged != nil { + cm.Parts = []*ConvertedMessagePart{merged} + return true + } + return false +} + +type ConvertedEditPart struct { + Part *database.Message + + Type event.Type + // The Content and Extra fields will be put inside `m.new_content` automatically. + // SetEdit must NOT be called by the network connector. + Content *event.MessageEventContent + Extra map[string]any + // TopLevelExtra can be used to specify custom fields at the top level of the content rather than inside `m.new_content`. + TopLevelExtra map[string]any + // NewMentions can be used to specify new mentions that should ping the users again. + // Mentions inside the edited content will not ping. + NewMentions *event.Mentions + + DontBridge bool +} + +type ConvertedEdit struct { + ModifiedParts []*ConvertedEditPart + DeletedParts []*database.Message + // Warning: added parts will be sent at the end of the room. + // If other messages have been sent after the message being edited, + // these new parts will not be next to the existing parts. + AddedParts *ConvertedMessage +} + +// BridgeName contains information about the network that a connector bridges to. +type BridgeName struct { + // The displayname of the network, e.g. `Discord` + DisplayName string `json:"displayname"` + // The URL to the website of the network, e.g. `https://discord.com` + NetworkURL string `json:"network_url"` + // The icon of the network as a mxc:// URI + NetworkIcon id.ContentURIString `json:"network_icon"` + // An identifier uniquely identifying the network, e.g. `discord` + NetworkID string `json:"network_id"` + // An identifier uniquely identifying the bridge software. + // The Go import path is a good choice here (e.g. github.com/octocat/discordbridge) + BeeperBridgeType string `json:"beeper_bridge_type"` + // The default appservice port to use in the example config, defaults to 8080 if unset + // Official mautrix bridges will use ports defined in https://mau.fi/ports + DefaultPort uint16 `json:"default_port,omitempty"` + // The default command prefix to use in the example config, defaults to NetworkID if unset. Must include the ! prefix. + DefaultCommandPrefix string `json:"default_command_prefix,omitempty"` +} + +func (bn BridgeName) AsBridgeInfoSection() event.BridgeInfoSection { + return event.BridgeInfoSection{ + ID: bn.BeeperBridgeType, + DisplayName: bn.DisplayName, + AvatarURL: bn.NetworkIcon, + ExternalURL: bn.NetworkURL, + } +} + +// NetworkConnector is the main interface that a network connector must implement. +type NetworkConnector interface { + // Init is called when the bridge is initialized. The connector should store the bridge instance for later use. + // This should not do any network calls or other blocking operations. + Init(*Bridge) + // Start is called when the bridge is starting. + // The connector should do any non-user-specific startup actions necessary. + // User logins will be loaded separately, so the connector should not load them here. + Start(context.Context) error + + // GetName returns the name of the bridge and some additional metadata, + // which is used to fill `m.bridge` events among other things. + // + // The first call happens *before* the config is loaded, because the data here is also used to + // fill parts of the example config (like the default username template and bot localpart). + // The output can still be adjusted based on config variables, but the function must have + // default values when called without a config. + GetName() BridgeName + // GetDBMetaTypes returns struct types that are used to store connector-specific metadata in various tables. + // All fields are optional. If a field isn't provided, then the corresponding table will have no custom metadata. + // This will be called before Init, it should have a hardcoded response. + GetDBMetaTypes() database.MetaTypes + // GetCapabilities returns the general capabilities of the network connector. + // Note that most capabilities are scoped to rooms and are returned by [NetworkAPI.GetCapabilities] instead. + GetCapabilities() *NetworkGeneralCapabilities + // GetConfig returns all the parts of the network connector's config file. Specifically: + // - example: a string containing an example config file + // - data: an interface to unmarshal the actual config into + // - upgrader: a config upgrader to ensure all fields are present and to do any migrations from old configs + GetConfig() (example string, data any, upgrader configupgrade.Upgrader) + + // LoadUserLogin is called when a UserLogin is loaded from the database in order to fill the [UserLogin.Client] field. + // + // This is called within the bridge's global cache lock, so it must not do any slow operations, + // such as connecting to the network. Instead, connecting should happen when [NetworkAPI.Connect] is called later. + LoadUserLogin(ctx context.Context, login *UserLogin) error + + // GetLoginFlows returns a list of login flows that the network supports. + GetLoginFlows() []LoginFlow + // CreateLogin is called when a user wants to log in to the network. + // + // This should generally not do any work, it should just return a LoginProcess that remembers + // the user and will execute the requested flow. The actual work should start when [LoginProcess.Start] is called. + CreateLogin(ctx context.Context, user *User, flowID string) (LoginProcess, error) + + // GetBridgeInfoVersion returns version numbers for bridge info and room capabilities respectively. + // When the versions change, the bridge will automatically resend bridge info to all rooms. + GetBridgeInfoVersion() (info, capabilities int) +} + +type StoppableNetwork interface { + NetworkConnector + // Stop is called when the bridge is stopping, after all network clients have been disconnected. + Stop() +} + +// DirectMediableNetwork is an optional interface that network connectors can implement to support direct media access. +// +// If the Matrix connector has direct media enabled, SetUseDirectMedia will be called +// before the Start method of the network connector. Download will then be called +// whenever someone wants to download a direct media `mxc://` URI which was generated +// by calling GenerateContentURI on the Matrix connector. +type DirectMediableNetwork interface { + NetworkConnector + SetUseDirectMedia() + Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) +} + +// IdentifierValidatingNetwork is an optional interface that network connectors can implement to validate the shape of user IDs. +// +// This should not perform any checks to see if the user ID actually exists on the network, just that the user ID looks valid. +type IdentifierValidatingNetwork interface { + NetworkConnector + ValidateUserID(id networkid.UserID) bool +} + +type TransactionIDGeneratingNetwork interface { + NetworkConnector + GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID +} + +type PortalBridgeInfoFillingNetwork interface { + NetworkConnector + FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent) +} + +// ConfigValidatingNetwork is an optional interface that network connectors can implement to validate config fields +// before the bridge is started. +// +// When the ValidateConfig method is called, the config data will already be unmarshaled into the +// object returned by [NetworkConnector.GetConfig]. +// +// This mechanism is usually used to refuse bridge startup if a mandatory field has an invalid value. +type ConfigValidatingNetwork interface { + NetworkConnector + ValidateConfig() error +} + +// MaxFileSizeingNetwork is an optional interface that network connectors can implement +// to find out the maximum file size that can be uploaded to Matrix. +// +// The SetMaxFileSize will be called asynchronously soon after startup. +// Before the function is called, the connector may assume a default limit of 50 MiB. +type MaxFileSizeingNetwork interface { + NetworkConnector + SetMaxFileSize(maxSize int64) +} + +type NetworkResettingNetwork interface { + NetworkConnector + // ResetHTTPTransport should recreate the HTTP client used by the bridge. + // It should refetch settings from the Matrix connector using GetHTTPClientSettings if applicable. + ResetHTTPTransport() + // ResetNetworkConnections should forcefully disconnect and restart any persistent network connections. + // ResetHTTPTransport will usually be called before this, so resetting the transport is not necessary here. + ResetNetworkConnections() +} + +type RemoteEchoHandler func(RemoteMessage, *database.Message) (bool, error) + +type MatrixMessageResponse struct { + DB *database.Message + StreamOrder int64 + // If Pending is set, the bridge will not save the provided message to the database. + // This should only be used if AddPendingToSave has been called. + Pending bool + // If RemovePending is set, the bridge will remove the provided transaction ID from pending messages + // after saving the provided message to the database. This should be used with AddPendingToIgnore. + RemovePending networkid.TransactionID + // An optional function that is called after the message is saved to the database. + // Will not be called if the message is not saved for some reason. + PostSave func(context.Context, *database.Message) +} + +type OutgoingTimeoutConfig struct { + CheckInterval time.Duration + NoEchoTimeout time.Duration + NoEchoMessage string + NoAckTimeout time.Duration + NoAckMessage string +} + +type NetworkGeneralCapabilities struct { + // Does the network connector support disappearing messages? + // This flag enables the message disappearing loop in the bridge. + DisappearingMessages bool + // Should the bridge re-request user info on incoming messages even if the ghost already has info? + // By default, info is only requested for ghosts with no name, and other updating is left to events. + AggressiveUpdateInfo bool + // Should the bridge call HandleMatrixReadReceipt with fake data when receiving a new message? + // This should be enabled if the network requires each message to be marked as read independently, + // and doesn't automatically do it when sending a message. + ImplicitReadReceipts bool + // If the bridge uses the pending message mechanism ([MatrixMessage.AddPendingToSave]) + // to handle asynchronous message responses, this field can be set to enable + // automatic timeout errors in case the asynchronous response never arrives. + OutgoingMessageTimeouts *OutgoingTimeoutConfig + // Capabilities related to the provisioning API. + Provisioning ProvisioningCapabilities +} + +// NetworkAPI is an interface representing a remote network client for a single user login. +// +// Implementations of this interface are stored in [UserLogin.Client]. +// The [NetworkConnector.LoadUserLogin] method is responsible for filling the Client field with a NetworkAPI. +type NetworkAPI interface { + // Connect is called to actually connect to the remote network. + // If there's no persistent connection, this may just check access token validity, or even do nothing at all. + // This method isn't allowed to return errors, because any connection errors should be sent + // using the bridge state mechanism (UserLogin.BridgeState.Send) + Connect(ctx context.Context) + // Disconnect should disconnect from the remote network. + // A clean disconnection is preferred, but it should not take too long. + Disconnect() + // IsLoggedIn should return whether the access tokens in this NetworkAPI are valid. + // This should not do any IO operations, it should only return cached data which is updated elsewhere. + IsLoggedIn() bool + // LogoutRemote should invalidate the access tokens in this NetworkAPI if possible + // and disconnect from the remote network. + LogoutRemote(ctx context.Context) + + // IsThisUser should return whether the given remote network user ID is the same as this login. + // This is used when the bridge wants to convert a user login ID to a user ID. + IsThisUser(ctx context.Context, userID networkid.UserID) bool + // GetChatInfo returns info for a given chat. Any fields that are nil will be ignored and not processed at all, + // while empty strings will change the relevant value in the room to be an empty string. + // For example, a nil name will mean the room name is not changed, while an empty string name will remove the name. + GetChatInfo(ctx context.Context, portal *Portal) (*ChatInfo, error) + // GetUserInfo returns info for a given user. Like chat info, fields can be nil to skip them. + GetUserInfo(ctx context.Context, ghost *Ghost) (*UserInfo, error) + // GetCapabilities returns the bridging capabilities in a given room. + // This can simply return a static list if the remote network has no per-chat capability differences, + // but all calls will include the portal, because some networks do have per-chat differences. + GetCapabilities(ctx context.Context, portal *Portal) *event.RoomFeatures + + // HandleMatrixMessage is called when a message is sent from Matrix in an existing portal room. + // This function should convert the message as appropriate, send it over to the remote network, + // and return the info so the central bridge can store it in the database. + // + // This is only called for normal non-edit messages. For other types of events, see the optional extra interfaces (`XHandlingNetworkAPI`). + HandleMatrixMessage(ctx context.Context, msg *MatrixMessage) (message *MatrixMessageResponse, err error) +} + +// NetworkAPIWithUserID is an optional interface for fetching the remote user ID of the logged-in user. +// Networks where such mapping is not possible should not implement this interface. +// The GetUserID method may also return an empty string to indicate the user ID is not available. +type NetworkAPIWithUserID interface { + NetworkAPI + // GetUserID returns the user ID of this client on the remote network. + GetUserID() networkid.UserID +} + +type ConnectBackgroundParams struct { + // RawData is the raw data in the push that triggered the background connection. + RawData json.RawMessage + // ExtraData is the data returned by [PushParsingNetwork.ParsePushNotification]. + // It's only present for native pushes. Relayed pushes will only have the raw data. + ExtraData any +} + +// BackgroundSyncingNetworkAPI is an optional interface that network connectors can implement to support background resyncs. +type BackgroundSyncingNetworkAPI interface { + NetworkAPI + // ConnectBackground is called in place of Connect for background resyncs. + // The client should connect to the remote network, handle pending messages, and then disconnect. + // This call should block until the entire sync is complete and the client is disconnected. + ConnectBackground(ctx context.Context, params *ConnectBackgroundParams) error +} + +// CredentialExportingNetworkAPI is an optional interface that networks connectors can implement to support export of +// the credentials associated with that login. Credential type is bridge specific. +type CredentialExportingNetworkAPI interface { + NetworkAPI + ExportCredentials(ctx context.Context) any +} + +// FetchMessagesParams contains the parameters for a message history pagination request. +type FetchMessagesParams struct { + // The portal to fetch messages in. Always present. + Portal *Portal + // When fetching messages inside a thread, the ID of the thread. + ThreadRoot networkid.MessageID + // Whether to fetch new messages instead of old ones. + Forward bool + // The oldest known message in the thread or the portal. If Forward is true, this is the newest known message instead. + // If the portal doesn't have any bridged messages, this will be nil. + AnchorMessage *database.Message + // The cursor returned by the previous call to FetchMessages with the same portal and thread root. + // This will not be present in Forward calls. + Cursor networkid.PaginationCursor + // The preferred number of messages to return. The returned batch can be bigger or smaller + // without any side effects, but the network connector should aim for this number. + Count int + // Whether the network connector should allow slow fetches of messages. + // If false and the network connector hits a case where a slow fetch is necessary, + // the response should set MoreRequiresSlowFetch in the response instead of fetching messages. + AllowSlowFetch bool + + // When a forward backfill is triggered by a [RemoteChatResyncBackfillBundle], this will contain + // the bundled data returned by the event. It can be used as an optimization to avoid fetching + // messages that were already provided by the remote network, while still supporting fetching + // more messages if the limit is higher. + BundledData any + + // When the messages are being fetched for a queued backfill, this is the task object. + Task *database.BackfillTask +} + +// BackfillReaction is an individual reaction to a message in a history pagination request. +// +// The target message is always the BackfillMessage that contains this item. +// Optionally, the reaction can target a specific part by specifying TargetPart. +// If not specified, the first part (sorted lexicographically) is targeted. +type BackfillReaction struct { + // Optional part of the message that the reaction targets. + // If nil, the reaction targets the first part of the message. + TargetPart *networkid.PartID + // Optional timestamp for the reaction. + // If unset, the reaction will have a fake timestamp that is slightly after the message timestamp. + Timestamp time.Time + + Sender EventSender + EmojiID networkid.EmojiID + Emoji string + ExtraContent map[string]any + DBMetadata any +} + +// BackfillMessage is an individual message in a history pagination request. +type BackfillMessage struct { + *ConvertedMessage + Sender EventSender + ID networkid.MessageID + TxnID networkid.TransactionID + Timestamp time.Time + StreamOrder int64 + Reactions []*BackfillReaction + + ShouldBackfillThread bool + LastThreadMessage networkid.MessageID +} + +var ( + _ RemoteMessageWithTransactionID = (*BackfillMessage)(nil) + _ RemoteEventWithTimestamp = (*BackfillMessage)(nil) +) + +func (b *BackfillMessage) GetType() RemoteEventType { + return RemoteEventMessage +} + +func (b *BackfillMessage) GetPortalKey() networkid.PortalKey { + panic("GetPortalKey called for BackfillMessage") +} + +func (b *BackfillMessage) AddLogContext(c zerolog.Context) zerolog.Context { + return c +} + +func (b *BackfillMessage) GetSender() EventSender { + return b.Sender +} + +func (b *BackfillMessage) GetID() networkid.MessageID { + return b.ID +} + +func (b *BackfillMessage) GetTransactionID() networkid.TransactionID { + return b.TxnID +} + +func (b *BackfillMessage) GetTimestamp() time.Time { + return b.Timestamp +} + +func (b *BackfillMessage) ConvertMessage(ctx context.Context, portal *Portal, intent MatrixAPI) (*ConvertedMessage, error) { + return b.ConvertedMessage, nil +} + +// FetchMessagesResponse contains the response for a message history pagination request. +type FetchMessagesResponse struct { + // The messages to backfill. Messages should always be sorted in chronological order (oldest to newest). + Messages []*BackfillMessage + // The next cursor to use for fetching more messages. + Cursor networkid.PaginationCursor + // Whether there are more messages that can be backfilled. + // This field is required. If it is false, FetchMessages will not be called again. + HasMore bool + // Whether the batch contains new messages rather than old ones. + // Cursor, HasMore and the progress fields will be ignored when this is present. + Forward bool + // When sending forward backfill (or the first batch in a room), this field can be set + // to mark the messages as read immediately after backfilling. + MarkRead bool + + // If further fetches require requests that take longer than normal message fetches, this can be set to true. + // The bridge will then skip this portal in the backfill queue and only paginate further if the user requests it. + // This should not be set if AllowSlowFetch is true in the fetch params. + MoreRequiresSlowFetch bool + // If a backfill event was requested in the background and will later be sent using RemoteBackfill, + // this should be set to true. The queue will suspend the task for at least 24 hours until the event. + Pending bool + + // Should the bridge check each message against the database to ensure it's not a duplicate before bridging? + // By default, the bridge will only drop messages that are older than the last bridged message for forward backfills, + // or newer than the first for backward. + AggressiveDeduplication bool + + // When HasMore is true, one of the following fields can be set to report backfill progress: + + // Approximate backfill progress as a number between 0 and 1. + ApproxProgress float64 + // Approximate number of messages remaining that can be backfilled. + ApproxRemainingCount int + // Approximate total number of messages in the chat. + ApproxTotalCount int + + // An optional function that is called after the backfill batch has been sent. + CompleteCallback func() +} + +// BackfillingNetworkAPI is an optional interface that network connectors can implement to support backfilling message history. +type BackfillingNetworkAPI interface { + NetworkAPI + // FetchMessages returns a batch of messages to backfill in a portal room. + // For details on the input and output, see the documentation of [FetchMessagesParams] and [FetchMessagesResponse]. + FetchMessages(ctx context.Context, fetchParams FetchMessagesParams) (*FetchMessagesResponse, error) +} + +// BackfillingNetworkAPIWithLimits is an optional interface that network connectors can implement to customize +// the limit for backwards backfilling tasks. It is recommended to implement this by reading the MaxBatchesOverride +// config field with network-specific keys for different room types. +type BackfillingNetworkAPIWithLimits interface { + BackfillingNetworkAPI + // GetBackfillMaxBatchCount is called before a backfill task is executed to determine the maximum number of batches + // that should be backfilled. Return values less than 0 are treated as unlimited. + GetBackfillMaxBatchCount(ctx context.Context, portal *Portal, task *database.BackfillTask) int +} + +// EditHandlingNetworkAPI is an optional interface that network connectors can implement to handle message edits. +type EditHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixEdit is called when a previously bridged message is edited in a portal room. + // The central bridge module will save the [*database.Message] after this function returns, + // so the network connector is allowed to mutate the provided object. + HandleMatrixEdit(ctx context.Context, msg *MatrixEdit) error +} + +type PollHandlingNetworkAPI interface { + NetworkAPI + HandleMatrixPollStart(ctx context.Context, msg *MatrixPollStart) (*MatrixMessageResponse, error) + HandleMatrixPollVote(ctx context.Context, msg *MatrixPollVote) (*MatrixMessageResponse, error) +} + +// ReactionHandlingNetworkAPI is an optional interface that network connectors can implement to handle message reactions. +type ReactionHandlingNetworkAPI interface { + NetworkAPI + // PreHandleMatrixReaction is called as the first step of handling a reaction. It returns the emoji ID, + // sender user ID and max reaction count to allow the central bridge module to de-duplicate the reaction + // if appropriate. + PreHandleMatrixReaction(ctx context.Context, msg *MatrixReaction) (MatrixReactionPreResponse, error) + // HandleMatrixReaction is called after confirming that the reaction is not a duplicate. + // This is the method that should actually send the reaction to the remote network. + // The returned [database.Reaction] object may be empty: the central bridge module already has + // all the required fields and will fill them automatically if they're empty. However, network + // connectors are allowed to set fields themselves if any extra fields are necessary. + HandleMatrixReaction(ctx context.Context, msg *MatrixReaction) (reaction *database.Reaction, err error) + // HandleMatrixReactionRemove is called when a redaction event is received pointing at a previously + // bridged reaction. The network connector should remove the reaction from the remote network. + HandleMatrixReactionRemove(ctx context.Context, msg *MatrixReactionRemove) error +} + +// RedactionHandlingNetworkAPI is an optional interface that network connectors can implement to handle message deletions. +type RedactionHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixMessageRemove is called when a previously bridged message is deleted in a portal room. + HandleMatrixMessageRemove(ctx context.Context, msg *MatrixMessageRemove) error +} + +// ReadReceiptHandlingNetworkAPI is an optional interface that network connectors can implement to handle read receipts. +type ReadReceiptHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixReadReceipt is called when a read receipt is sent in a portal room. + // This will be called even if the target message is not a bridged message. + // Network connectors must gracefully handle [MatrixReadReceipt.ExactMessage] being nil. + // The exact handling is up to the network connector. + HandleMatrixReadReceipt(ctx context.Context, msg *MatrixReadReceipt) error +} + +// ChatViewingNetworkAPI is an optional interface that network connectors can implement to handle viewing chat status. +type ChatViewingNetworkAPI interface { + NetworkAPI + // HandleMatrixViewingChat is called when the user opens a portal room. + // This will never be called by the standard appservice connector, + // as Matrix doesn't have any standard way of signaling chat open status. + // Clients are expected to call this every 5 seconds. There is no signal for closing a chat. + HandleMatrixViewingChat(ctx context.Context, msg *MatrixViewingChat) error +} + +// TypingHandlingNetworkAPI is an optional interface that network connectors can implement to handle typing events. +type TypingHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixTyping is called when a user starts typing in a portal room. + // In the future, the central bridge module will likely get a loop to automatically repeat + // calls to this function until the user stops typing. + HandleMatrixTyping(ctx context.Context, msg *MatrixTyping) error +} + +type MarkedUnreadHandlingNetworkAPI interface { + NetworkAPI + HandleMarkedUnread(ctx context.Context, msg *MatrixMarkedUnread) error +} + +type MuteHandlingNetworkAPI interface { + NetworkAPI + HandleMute(ctx context.Context, msg *MatrixMute) error +} + +type TagHandlingNetworkAPI interface { + NetworkAPI + HandleRoomTag(ctx context.Context, msg *MatrixRoomTag) error +} + +// RoomNameHandlingNetworkAPI is an optional interface that network connectors can implement to handle room name changes. +type RoomNameHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixRoomName is called when the name of a portal room is changed. + // This method should update the Name and NameSet fields of the Portal with + // the new name and return true if the change was successful. + // If the change is not successful, then the fields should not be updated. + HandleMatrixRoomName(ctx context.Context, msg *MatrixRoomName) (bool, error) +} + +// RoomAvatarHandlingNetworkAPI is an optional interface that network connectors can implement to handle room avatar changes. +type RoomAvatarHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixRoomAvatar is called when the avatar of a portal room is changed. + // This method should update the AvatarID, AvatarHash and AvatarMXC fields + // with the new avatar details and return true if the change was successful. + // If the change is not successful, then the fields should not be updated. + HandleMatrixRoomAvatar(ctx context.Context, msg *MatrixRoomAvatar) (bool, error) +} + +// RoomTopicHandlingNetworkAPI is an optional interface that network connectors can implement to handle room topic changes. +type RoomTopicHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixRoomTopic is called when the topic of a portal room is changed. + // This method should update the Topic and TopicSet fields of the Portal with + // the new topic and return true if the change was successful. + // If the change is not successful, then the fields should not be updated. + HandleMatrixRoomTopic(ctx context.Context, msg *MatrixRoomTopic) (bool, error) +} + +type DisappearTimerChangingNetworkAPI interface { + NetworkAPI + // HandleMatrixDisappearingTimer is called when the disappearing timer of a portal room is changed. + // This method should update the Disappear field of the Portal with the new timer and return true + // if the change was successful. If the change is not successful, then the field should not be updated. + HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error) +} + +// DeleteChatHandlingNetworkAPI is an optional interface that network connectors +// can implement to delete a chat from the remote network. +type DeleteChatHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixDeleteChat is called when the user explicitly deletes a chat. + HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error +} + +// MessageRequestAcceptingNetworkAPI is an optional interface that network connectors +// can implement to accept message requests from the remote network. +type MessageRequestAcceptingNetworkAPI interface { + NetworkAPI + // HandleMatrixAcceptMessageRequest is called when the user accepts a message request. + HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error +} + +type ResolveIdentifierResponse struct { + // Ghost is the ghost of the user that the identifier resolves to. + // This field should be set whenever possible. However, it is not required, + // and the central bridge module will not try to create a ghost if it is not set. + Ghost *Ghost + + // UserID is the user ID of the user that the identifier resolves to. + UserID networkid.UserID + // UserInfo contains the info of the user that the identifier resolves to. + // If both this and the Ghost field are set, the central bridge module will + // automatically update the ghost's info with the data here. + UserInfo *UserInfo + + // Chat contains info about the direct chat with the resolved user. + // This field is required when createChat is true in the ResolveIdentifier call, + // and optional otherwise. + Chat *CreateChatResponse +} + +var SpecialValueDMRedirectedToBot = networkid.UserID("__fi.mau.bridgev2.dm_redirected_to_bot::" + random.String(10)) + +type CreateChatResponse struct { + PortalKey networkid.PortalKey + // Portal and PortalInfo are not required, the caller will fetch them automatically based on PortalKey if necessary. + Portal *Portal + PortalInfo *ChatInfo + // If a start DM request (CreateChatWithGhost or ResolveIdentifier) returns the DM to a different user, + // this field should have the user ID of said different user. + DMRedirectedTo networkid.UserID + + FailedParticipants map[networkid.UserID]*CreateChatFailedParticipant +} + +type CreateChatFailedParticipant struct { + Reason string `json:"reason"` + InviteEventType string `json:"invite_event_type,omitempty"` + InviteContent *event.Content `json:"invite_content,omitempty"` + + UserMXID id.UserID `json:"user_mxid,omitempty"` + DMRoomMXID id.RoomID `json:"dm_room_mxid,omitempty"` +} + +// IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats. +type IdentifierResolvingNetworkAPI interface { + NetworkAPI + // ResolveIdentifier is called when the user wants to start a new chat. + // This can happen via the `resolve-identifier` or `start-chat` bridge bot commands, + // or the corresponding provisioning API endpoints. + ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*ResolveIdentifierResponse, error) +} + +// GhostDMCreatingNetworkAPI is an optional extension to IdentifierResolvingNetworkAPI for starting chats with pre-validated user IDs. +type GhostDMCreatingNetworkAPI interface { + IdentifierResolvingNetworkAPI + // CreateChatWithGhost may be called instead of [IdentifierResolvingNetworkAPI.ResolveIdentifier] + // when starting a chat with an internal user identifier that has been pre-validated using + // [IdentifierValidatingNetwork.ValidateUserID]. If this is not implemented, ResolveIdentifier + // will be used instead (by stringifying the ghost ID). + CreateChatWithGhost(ctx context.Context, ghost *Ghost) (*CreateChatResponse, error) +} + +// ContactListingNetworkAPI is an optional interface that network connectors can implement to provide the user's contact list. +type ContactListingNetworkAPI interface { + NetworkAPI + GetContactList(ctx context.Context) ([]*ResolveIdentifierResponse, error) +} + +type UserSearchingNetworkAPI interface { + IdentifierResolvingNetworkAPI + SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) +} + +type GroupCreatingNetworkAPI interface { + IdentifierResolvingNetworkAPI + CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error) +} + +type PersonalFilteringCustomizingNetworkAPI interface { + NetworkAPI + CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom) +} + +type ProvisioningCapabilities struct { + ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"` + GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"` + ImagePackImport bool `json:"image_pack_import,omitempty"` +} + +type ResolveIdentifierCapabilities struct { + // Can DMs be created after resolving an identifier? + CreateDM bool `json:"create_dm"` + // Can users be looked up by phone number? + LookupPhone bool `json:"lookup_phone"` + // Can users be looked up by email address? + LookupEmail bool `json:"lookup_email"` + // Can users be looked up by network-specific username? + LookupUsername bool `json:"lookup_username"` + // Can any phone number be contacted without having to validate it via lookup first? + AnyPhone bool `json:"any_phone"` + // Can a contact list be retrieved from the bridge? + ContactList bool `json:"contact_list"` + // Can users be searched by name on the remote network? + Search bool `json:"search"` +} + +type GroupTypeCapabilities struct { + TypeDescription string `json:"type_description"` + + Name GroupFieldCapability `json:"name"` + Username GroupFieldCapability `json:"username"` + Avatar GroupFieldCapability `json:"avatar"` + Topic GroupFieldCapability `json:"topic"` + Disappear GroupFieldCapability `json:"disappear"` + Participants GroupFieldCapability `json:"participants"` + Parent GroupFieldCapability `json:"parent"` +} + +type GroupFieldCapability struct { + // Is setting this field allowed at all in the create request? + // Even if false, the network connector should attempt to set the metadata after group creation, + // as the allowed flag can't be enforced properly when creating a group for an existing Matrix room. + Allowed bool `json:"allowed"` + // Is setting this field mandatory for the creation to succeed? + Required bool `json:"required,omitempty"` + // The minimum/maximum length of the field, if applicable. + // For members, length means the number of members excluding the creator. + MinLength int `json:"min_length,omitempty"` + MaxLength int `json:"max_length,omitempty"` + + // Only for the disappear field: allowed disappearing settings + DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"` + + // This can be used to tell provisionutil not to call ValidateUserID on each participant. + // It only meant to allow hacks where ResolveIdentifier returns a fake ID that isn't actually valid for MXIDs. + SkipIdentifierValidation bool `json:"-"` +} + +type GroupCreateParams struct { + Type string `json:"type,omitempty"` + + Username string `json:"username,omitempty"` + // Clients may also provide MXIDs here, but provisionutil will normalize them, so bridges only need to handle network IDs + Participants []networkid.UserID `json:"participants,omitempty"` + Parent *networkid.PortalKey `json:"parent,omitempty"` + + Name *event.RoomNameEventContent `json:"name,omitempty"` + Avatar *event.RoomAvatarEventContent `json:"avatar,omitempty"` + Topic *event.TopicEventContent `json:"topic,omitempty"` + Disappear *event.BeeperDisappearingTimer `json:"disappear,omitempty"` + + // An existing room ID to bridge to. If unset, a new room will be created. + RoomID id.RoomID `json:"room_id,omitempty"` +} + +type MembershipChangeType struct { + From event.Membership + To event.Membership + IsSelf bool +} + +var ( + AcceptInvite = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipJoin, IsSelf: true} + RevokeInvite = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipLeave} + RejectInvite = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipLeave, IsSelf: true} + BanInvited = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipBan} + ProfileChange = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipJoin, IsSelf: true} + Leave = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipLeave, IsSelf: true} + Kick = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipLeave} + BanJoined = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipBan} + Invite = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipInvite} + Join = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipJoin, IsSelf: true} + BanLeft = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipBan} + Knock = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipKnock, IsSelf: true} + AcceptKnock = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipInvite} + RejectKnock = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipLeave} + RetractKnock = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipLeave, IsSelf: true} + BanKnocked = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipBan} + Unban = MembershipChangeType{From: event.MembershipBan, To: event.MembershipLeave} +) + +type GhostOrUserLogin interface { + isGhostOrUserLogin() +} + +func (*Ghost) isGhostOrUserLogin() {} +func (*UserLogin) isGhostOrUserLogin() {} + +type MatrixMembershipChange struct { + MatrixRoomMeta[*event.MemberEventContent] + Target GhostOrUserLogin + Type MembershipChangeType +} + +type MatrixMembershipResult struct { + RedirectTo networkid.UserID +} + +type MembershipHandlingNetworkAPI interface { + NetworkAPI + HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (*MatrixMembershipResult, error) +} + +type SinglePowerLevelChange struct { + OrigLevel int + NewLevel int + NewIsSet bool +} + +type UserPowerLevelChange struct { + Target GhostOrUserLogin + SinglePowerLevelChange +} + +type MatrixPowerLevelChange struct { + MatrixRoomMeta[*event.PowerLevelsEventContent] + Users map[id.UserID]*UserPowerLevelChange + Events map[string]*SinglePowerLevelChange + UsersDefault *SinglePowerLevelChange + EventsDefault *SinglePowerLevelChange + StateDefault *SinglePowerLevelChange + Invite *SinglePowerLevelChange + Kick *SinglePowerLevelChange + Ban *SinglePowerLevelChange + Redact *SinglePowerLevelChange +} + +type PowerLevelHandlingNetworkAPI interface { + NetworkAPI + HandleMatrixPowerLevels(ctx context.Context, msg *MatrixPowerLevelChange) (bool, error) +} + +type PushType int + +func (pt PushType) String() string { + return pt.GoString() +} + +func PushTypeFromString(str string) PushType { + switch strings.TrimPrefix(strings.ToLower(str), "pushtype") { + case "web": + return PushTypeWeb + case "apns": + return PushTypeAPNs + case "fcm": + return PushTypeFCM + default: + return PushTypeUnknown + } +} + +func (pt PushType) GoString() string { + switch pt { + case PushTypeUnknown: + return "PushTypeUnknown" + case PushTypeWeb: + return "PushTypeWeb" + case PushTypeAPNs: + return "PushTypeAPNs" + case PushTypeFCM: + return "PushTypeFCM" + default: + return fmt.Sprintf("PushType(%d)", int(pt)) + } +} + +const ( + PushTypeUnknown PushType = iota + PushTypeWeb + PushTypeAPNs + PushTypeFCM +) + +type WebPushConfig struct { + VapidKey string `json:"vapid_key"` +} + +type FCMPushConfig struct { + SenderID string `json:"sender_id"` +} + +type APNsPushConfig struct { + BundleID string `json:"bundle_id"` +} + +type PushConfig struct { + Web *WebPushConfig `json:"web,omitempty"` + FCM *FCMPushConfig `json:"fcm,omitempty"` + APNs *APNsPushConfig `json:"apns,omitempty"` + // If Native is true, it means the network supports registering for pushes + // that are delivered directly to the app without the use of a push relay. + Native bool `json:"native,omitempty"` +} + +// PushableNetworkAPI is an optional interface that network connectors can implement +// to support waking up the wrapper app using push notifications. +type PushableNetworkAPI interface { + NetworkAPI + + // RegisterPushNotifications is called when the wrapper app wants to register a push token with the remote network. + RegisterPushNotifications(ctx context.Context, pushType PushType, token string) error + // GetPushConfigs is used to find which types of push notifications the remote network can provide. + GetPushConfigs() *PushConfig +} + +// PushParsingNetwork is an optional interface that network connectors can implement +// to support parsing native push notifications from networks. +type PushParsingNetwork interface { + NetworkConnector + + // ParsePushNotification is called when a native push is received. + // It must return the corresponding user login ID to wake up, plus optionally data to pass to the wakeup call. + ParsePushNotification(ctx context.Context, data json.RawMessage) (networkid.UserLoginID, any, error) +} + +type ImportedImagePack struct { + // The main content of the converted image pack + Content *event.ImagePackEventContent + // Extra top-level content + Extra map[string]any + // A unique identifier (within the network) for the image pack, used as the state key. + // If unset, falls back to Content.Metadata.BridgedPack.URL + Shortcode string +} + +type StickerImportingNetworkAPI interface { + NetworkAPI + + DownloadImagePack(ctx context.Context, url string) (*ImportedImagePack, error) + ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) +} + +type RemoteEventType int + +func (ret RemoteEventType) String() string { + switch ret { + case RemoteEventUnknown: + return "RemoteEventUnknown" + case RemoteEventMessage: + return "RemoteEventMessage" + case RemoteEventMessageUpsert: + return "RemoteEventMessageUpsert" + case RemoteEventEdit: + return "RemoteEventEdit" + case RemoteEventReaction: + return "RemoteEventReaction" + case RemoteEventReactionRemove: + return "RemoteEventReactionRemove" + case RemoteEventReactionSync: + return "RemoteEventReactionSync" + case RemoteEventMessageRemove: + return "RemoteEventMessageRemove" + case RemoteEventReadReceipt: + return "RemoteEventReadReceipt" + case RemoteEventDeliveryReceipt: + return "RemoteEventDeliveryReceipt" + case RemoteEventMarkUnread: + return "RemoteEventMarkUnread" + case RemoteEventTyping: + return "RemoteEventTyping" + case RemoteEventChatInfoChange: + return "RemoteEventChatInfoChange" + case RemoteEventChatResync: + return "RemoteEventChatResync" + case RemoteEventChatDelete: + return "RemoteEventChatDelete" + case RemoteEventBackfill: + return "RemoteEventBackfill" + default: + return fmt.Sprintf("RemoteEventType(%d)", int(ret)) + } +} + +const ( + RemoteEventUnknown RemoteEventType = iota + RemoteEventMessage + RemoteEventMessageUpsert + RemoteEventEdit + RemoteEventReaction + RemoteEventReactionRemove + RemoteEventReactionSync + RemoteEventMessageRemove + RemoteEventReadReceipt + RemoteEventDeliveryReceipt + RemoteEventMarkUnread + RemoteEventTyping + RemoteEventChatInfoChange + RemoteEventChatResync + RemoteEventChatDelete + RemoteEventBackfill +) + +// RemoteEvent represents a single event from the remote network, such as a message or a reaction. +// +// When a [NetworkAPI] receives an event from the remote network, it should convert it into a [RemoteEvent] +// and pass it to the bridge for processing using [Bridge.QueueRemoteEvent]. +type RemoteEvent interface { + GetType() RemoteEventType + GetPortalKey() networkid.PortalKey + AddLogContext(c zerolog.Context) zerolog.Context + GetSender() EventSender +} + +type RemoteEventWithContextMutation interface { + RemoteEvent + MutateContext(ctx context.Context) context.Context +} + +type RemoteEventWithUncertainPortalReceiver interface { + RemoteEvent + PortalReceiverIsUncertain() bool +} + +type RemoteEventWithUncertainPortalReceiverFetcher interface { + RemoteEventWithUncertainPortalReceiver + FetchCertainPortalKey(context.Context) networkid.PortalKey +} + +type RemotePreHandler interface { + RemoteEvent + PreHandle(ctx context.Context, portal *Portal) +} + +type RemotePostHandler interface { + RemoteEvent + PostHandle(ctx context.Context, portal *Portal) +} + +type RemoteChatInfoChange interface { + RemoteEvent + GetChatInfoChange(ctx context.Context) (*ChatInfoChange, error) +} + +type RemoteChatResync interface { + RemoteEvent +} + +type RemoteChatResyncWithInfo interface { + RemoteChatResync + GetChatInfo(ctx context.Context, portal *Portal) (*ChatInfo, error) +} + +type RemoteChatResyncBackfill interface { + RemoteChatResync + CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) +} + +type RemoteChatResyncBackfillBundle interface { + RemoteChatResyncBackfill + GetBundledBackfillData() any +} + +type RemoteBackfill interface { + RemoteEvent + GetBackfillData(ctx context.Context, portal *Portal) (*FetchMessagesResponse, error) +} + +type RemoteDeleteOnlyForMe interface { + RemoteEvent + DeleteOnlyForMe() bool +} + +type RemoteChatDelete interface { + RemoteDeleteOnlyForMe +} + +type RemoteChatDeleteWithChildren interface { + RemoteChatDelete + DeleteChildren() bool +} + +type RemoteEventThatMayCreatePortal interface { + RemoteEvent + ShouldCreatePortal() bool +} + +type RemoteEventWithTargetMessage interface { + RemoteEvent + GetTargetMessage() networkid.MessageID +} + +type RemoteEventWithBundledParts interface { + RemoteEventWithTargetMessage + GetTargetDBMessage() []*database.Message +} + +type RemoteEventWithTargetPart interface { + RemoteEventWithTargetMessage + GetTargetMessagePart() networkid.PartID +} + +type RemoteEventWithTimestamp interface { + RemoteEvent + GetTimestamp() time.Time +} + +type RemoteEventWithStreamOrder interface { + RemoteEvent + GetStreamOrder() int64 +} + +type RemoteMessage interface { + RemoteEvent + GetID() networkid.MessageID + ConvertMessage(ctx context.Context, portal *Portal, intent MatrixAPI) (*ConvertedMessage, error) +} + +type UpsertResult struct { + SubEvents []RemoteEvent + SaveParts bool + ContinueMessageHandling bool +} + +type RemoteMessageUpsert interface { + RemoteMessage + HandleExisting(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message) (UpsertResult, error) +} + +type RemoteMessageWithTransactionID interface { + RemoteMessage + GetTransactionID() networkid.TransactionID +} + +type RemoteEdit interface { + RemoteEventWithTargetMessage + ConvertEdit(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message) (*ConvertedEdit, error) +} + +type RemoteReaction interface { + RemoteEventWithTargetMessage + GetReactionEmoji() (string, networkid.EmojiID) +} + +type ReactionSyncUser struct { + Reactions []*BackfillReaction + // Whether the list contains all reactions the user has sent + HasAllReactions bool + // If the list doesn't contain all reactions from the user, + // then this field can be set to remove old reactions if there are more than a certain number. + MaxCount int +} + +type ReactionSyncData struct { + Users map[networkid.UserID]*ReactionSyncUser + // Whether the map contains all users who have reacted to the message + HasAllUsers bool +} + +func (rsd *ReactionSyncData) ToBackfill() []*BackfillReaction { + var reactions []*BackfillReaction + for _, user := range rsd.Users { + reactions = append(reactions, user.Reactions...) + } + return reactions +} + +type RemoteReactionSync interface { + RemoteEventWithTargetMessage + GetReactions() *ReactionSyncData +} + +type RemoteReactionWithExtraContent interface { + RemoteReaction + GetReactionExtraContent() map[string]any +} + +type RemoteReactionWithMeta interface { + RemoteReaction + GetReactionDBMetadata() any +} + +type RemoteReactionRemove interface { + RemoteEventWithTargetMessage + GetRemovedEmojiID() networkid.EmojiID +} + +type RemoteMessageRemove interface { + RemoteEventWithTargetMessage +} + +type RemoteMessageRemoveWithoutPlaceholder interface { + RemoteMessageRemove + DontRenderPlaceholder() bool +} + +// Deprecated: Renamed to RemoteReadReceipt. +type RemoteReceipt = RemoteReadReceipt + +type RemoteReadReceipt interface { + RemoteEvent + GetLastReceiptTarget() networkid.MessageID + GetReceiptTargets() []networkid.MessageID + GetReadUpTo() time.Time +} + +type RemoteReadReceiptWithStreamOrder interface { + RemoteReadReceipt + GetReadUpToStreamOrder() int64 +} + +type RemoteDeliveryReceipt interface { + RemoteEvent + GetReceiptTargets() []networkid.MessageID +} + +type RemoteMarkUnread interface { + RemoteEvent + GetUnread() bool +} + +type RemoteTyping interface { + RemoteEvent + GetTimeout() time.Duration +} + +type TypingType int + +const ( + TypingTypeText TypingType = iota + TypingTypeUploadingMedia + TypingTypeRecordingMedia +) + +type RemoteTypingWithType interface { + RemoteTyping + GetTypingType() TypingType +} + +type OrigSender struct { + User *User + UserID id.UserID + + RequiresDisambiguation bool + DisambiguatedName string + FormattedName string + PerMessageProfile event.BeeperPerMessageProfile + + event.MemberEventContent +} + +type MatrixEventBase[ContentType any] struct { + // The raw event being bridged. + Event *event.Event + // The parsed content struct of the event. Custom fields can be found in Event.Content.Raw. + Content ContentType + // The room where the event happened. + Portal *Portal + + // The original sender user ID. Only present in case the event is being relayed (and Sender is not the same user). + OrigSender *OrigSender + + InputTransactionID networkid.RawTransactionID +} + +type MatrixMessage struct { + MatrixEventBase[*event.MessageEventContent] + ThreadRoot *database.Message + ReplyTo *database.Message + + pendingSaves []*outgoingMessage +} + +type MatrixEdit struct { + MatrixEventBase[*event.MessageEventContent] + EditTarget *database.Message +} + +type MatrixPollStart struct { + MatrixMessage + Content *event.PollStartEventContent +} + +type MatrixPollVote struct { + MatrixMessage + VoteTo *database.Message + Content *event.PollResponseEventContent +} + +type MatrixReaction struct { + MatrixEventBase[*event.ReactionEventContent] + TargetMessage *database.Message + PreHandleResp *MatrixReactionPreResponse + + // When EmojiID is blank and there's already an existing reaction, this is the old reaction that is being overridden. + ReactionToOverride *database.Reaction + // When MaxReactions is >0 in the pre-response, this is the list of previous reactions that should be preserved. + ExistingReactionsToKeep []*database.Reaction +} + +type MatrixReactionPreResponse struct { + SenderID networkid.UserID + EmojiID networkid.EmojiID + Emoji string + MaxReactions int +} + +type MatrixReactionRemove struct { + MatrixEventBase[*event.RedactionEventContent] + TargetReaction *database.Reaction +} + +type MatrixMessageRemove struct { + MatrixEventBase[*event.RedactionEventContent] + TargetMessage *database.Message +} + +type MatrixRoomMeta[ContentType any] struct { + MatrixEventBase[ContentType] + PrevContent ContentType + IsStateRequest bool +} + +type MatrixRoomName = MatrixRoomMeta[*event.RoomNameEventContent] +type MatrixRoomAvatar = MatrixRoomMeta[*event.RoomAvatarEventContent] +type MatrixRoomTopic = MatrixRoomMeta[*event.TopicEventContent] +type MatrixDisappearingTimer = MatrixRoomMeta[*event.BeeperDisappearingTimer] + +type MatrixReadReceipt struct { + Portal *Portal + // The event ID that the receipt is targeting + EventID id.EventID + // The exact message that was read. This may be nil if the event ID isn't a message. + ExactMessage *database.Message + // The timestamp that the user has read up to. This is either the timestamp of the message + // (if one is present) or the timestamp of the receipt. + ReadUpTo time.Time + // The ReadUpTo timestamp of the previous message + LastRead time.Time + // The receipt metadata. + Receipt event.ReadReceipt + // Whether the receipt is implicit, i.e. triggered by an incoming timeline event rather than an explicit receipt. + Implicit bool +} + +type MatrixTyping struct { + Portal *Portal + IsTyping bool + Type TypingType +} + +type MatrixViewingChat struct { + // The portal that the user is viewing. This will be nil when the user switches to a chat from a different bridge. + Portal *Portal +} + +type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent] +type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent] +type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] +type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] +type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] diff --git a/mautrix-patched/bridgev2/portal.go b/mautrix-patched/bridgev2/portal.go new file mode 100644 index 00000000..50300556 --- /dev/null +++ b/mautrix-patched/bridgev2/portal.go @@ -0,0 +1,5557 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "cmp" + "context" + "errors" + "fmt" + "runtime/debug" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/rs/zerolog" + "go.mau.fi/util/exfmt" + "go.mau.fi/util/exmaps" + "go.mau.fi/util/exslices" + "go.mau.fi/util/exsync" + "go.mau.fi/util/ptr" + "go.mau.fi/util/variationselector" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type portalMatrixEvent struct { + evt *event.Event + sender *User +} + +type portalRemoteEvent struct { + evt RemoteEvent + source *UserLogin + evtType RemoteEventType +} + +type portalCreateEvent struct { + ctx context.Context + source *UserLogin + info *ChatInfo + cb func(error) +} + +func (pme *portalMatrixEvent) isPortalEvent() {} +func (pre *portalRemoteEvent) isPortalEvent() {} +func (pre *portalCreateEvent) isPortalEvent() {} + +type portalEvent interface { + isPortalEvent() +} + +type outgoingMessage struct { + db *database.Message + evt *event.Event + ignore bool + handle func(RemoteMessage, *database.Message) (bool, error) + ackedAt time.Time + timeouted bool +} + +type Portal struct { + *database.Portal + Bridge *Bridge + Log zerolog.Logger + Parent *Portal + Relay *UserLogin + + currentlyTyping []id.UserID + currentlyTypingLogins map[id.UserID]*UserLogin + currentlyTypingLock sync.Mutex + currentlyTypingGhosts *exsync.Set[id.UserID] + + outgoingMessages map[networkid.TransactionID]*outgoingMessage + outgoingMessagesLock sync.Mutex + + lastCapUpdate time.Time + + roomCreateLock sync.Mutex + cancelRoomCreate atomic.Pointer[context.CancelFunc] + backgroundCtx context.Context + cancelBackground context.CancelFunc + deleted <-chan struct{} + RoomCreated *exsync.Event + + functionalMembersLock sync.Mutex + functionalMembersCache *event.ElementFunctionalMembersContent + + events chan portalEvent + + eventsLock sync.Mutex + eventIdx int + + backfillLock sync.Mutex + forwardBackfillLock sync.Mutex + nextBackfillDoneCallback func(error) +} + +var PortalEventBuffer = 64 +var PanicOnStuckEvent = false +var EventHandlingTimeoutTicks = 10 + +func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, queryErr error, key *networkid.PortalKey) (*Portal, error) { + if queryErr != nil { + return nil, fmt.Errorf("failed to query db: %w", queryErr) + } + if dbPortal == nil { + if key == nil { + return nil, nil + } + dbPortal = &database.Portal{ + BridgeID: br.ID, + PortalKey: *key, + } + err := br.DB.Portal.Insert(ctx, dbPortal) + if err != nil { + return nil, fmt.Errorf("failed to insert new portal: %w", err) + } + } + portal := &Portal{ + Portal: dbPortal, + Bridge: br, + + currentlyTypingLogins: make(map[id.UserID]*UserLogin), + currentlyTypingGhosts: exsync.NewSet[id.UserID](), + outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), + + RoomCreated: exsync.NewEvent(), + } + portal.backgroundCtx, portal.cancelBackground = context.WithCancel(br.BackgroundCtx) + portal.deleted = portal.backgroundCtx.Done() + if portal.MXID != "" { + portal.RoomCreated.Set() + } + // Putting the portal in the cache before it's fully initialized is mildly dangerous, + // but loading the relay user login may depend on it. + br.portalsByKey[portal.PortalKey] = portal + if portal.MXID != "" { + br.portalsByMXID[portal.MXID] = portal + } + var err error + if portal.ParentKey.ID != "" { + portal.Parent, err = br.UnlockedGetPortalByKey(ctx, portal.ParentKey, false) + if err != nil { + delete(br.portalsByKey, portal.PortalKey) + if portal.MXID != "" { + delete(br.portalsByMXID, portal.MXID) + } + return nil, fmt.Errorf("failed to load parent portal (%s): %w", portal.ParentKey, err) + } + } + if portal.RelayLoginID != "" { + portal.Relay, err = br.unlockedGetExistingUserLoginByID(ctx, portal.RelayLoginID) + if err != nil { + delete(br.portalsByKey, portal.PortalKey) + if portal.MXID != "" { + delete(br.portalsByMXID, portal.MXID) + } + return nil, fmt.Errorf("failed to load relay login (%s): %w", portal.RelayLoginID, err) + } + } + portal.updateLogger() + if PortalEventBuffer != 0 { + portal.events = make(chan portalEvent, PortalEventBuffer) + go portal.eventLoop() + } + return portal, nil +} + +func (portal *Portal) updateLogger() { + logWith := portal.Bridge.Log.With(). + Str("portal_id", string(portal.ID)). + Str("portal_receiver", string(portal.Receiver)). + Str("portal_pointer", strconv.FormatUint(uint64(uintptr(unsafe.Pointer(portal))), 16)) + if portal.MXID != "" { + logWith = logWith.Stringer("portal_mxid", portal.MXID) + } + portal.Log = logWith.Logger() +} + +func (br *Bridge) loadManyPortals(ctx context.Context, portals []*database.Portal) ([]*Portal, error) { + output := make([]*Portal, 0, len(portals)) + for _, dbPortal := range portals { + if cached, ok := br.portalsByKey[dbPortal.PortalKey]; ok { + output = append(output, cached) + } else { + loaded, err := br.loadPortal(ctx, dbPortal, nil, nil) + if err != nil { + return nil, err + } else if loaded != nil { + output = append(output, loaded) + } + } + } + return output, nil +} + +func (br *Bridge) loadPortalWithCacheCheck(ctx context.Context, dbPortal *database.Portal) (*Portal, error) { + if dbPortal == nil { + return nil, nil + } else if cached, ok := br.portalsByKey[dbPortal.PortalKey]; ok { + return cached, nil + } else { + return br.loadPortal(ctx, dbPortal, nil, nil) + } +} + +func (br *Bridge) UnlockedGetPortalByKey(ctx context.Context, key networkid.PortalKey, onlyIfExists bool) (*Portal, error) { + if br.Config.SplitPortals && key.Receiver == "" { + return nil, fmt.Errorf("receiver must always be set when split portals is enabled") + } + cached, ok := br.portalsByKey[key] + if ok { + return cached, nil + } + keyPtr := &key + if onlyIfExists { + keyPtr = nil + } + db, err := br.DB.Portal.GetByKey(ctx, key) + return br.loadPortal(ctx, db, err, keyPtr) +} + +func (br *Bridge) FindPortalReceiver(ctx context.Context, id networkid.PortalID, maybeReceiver networkid.UserLoginID) (networkid.PortalKey, error) { + key := br.FindCachedPortalReceiver(id, maybeReceiver) + if !key.IsEmpty() { + return key, nil + } + key, err := br.DB.Portal.FindReceiver(ctx, id, maybeReceiver) + if err != nil { + return networkid.PortalKey{}, err + } + return key, nil +} + +func (br *Bridge) FindCachedPortalReceiver(id networkid.PortalID, maybeReceiver networkid.UserLoginID) networkid.PortalKey { + if br.Config.SplitPortals { + return networkid.PortalKey{ID: id, Receiver: maybeReceiver} + } + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + portal, ok := br.portalsByKey[networkid.PortalKey{ + ID: id, + Receiver: maybeReceiver, + }] + if ok { + return portal.PortalKey + } + portal, ok = br.portalsByKey[networkid.PortalKey{ID: id}] + if ok { + return portal.PortalKey + } + return networkid.PortalKey{} +} + +func (br *Bridge) GetPortalByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + cached, ok := br.portalsByMXID[mxid] + if ok { + return cached, nil + } + db, err := br.DB.Portal.GetByMXID(ctx, mxid) + return br.loadPortal(ctx, db, err, nil) +} + +func (br *Bridge) GetAllPortalsWithMXID(ctx context.Context) ([]*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + rows, err := br.DB.Portal.GetAllWithMXID(ctx) + if err != nil { + return nil, err + } + return br.loadManyPortals(ctx, rows) +} + +func (br *Bridge) GetAllPortals(ctx context.Context) ([]*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + rows, err := br.DB.Portal.GetAll(ctx) + if err != nil { + return nil, err + } + return br.loadManyPortals(ctx, rows) +} + +func (br *Bridge) GetDMPortalsWith(ctx context.Context, otherUserID networkid.UserID) ([]*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + rows, err := br.DB.Portal.GetAllDMsWith(ctx, otherUserID) + if err != nil { + return nil, err + } + return br.loadManyPortals(ctx, rows) +} + +func (br *Bridge) GetChildPortals(ctx context.Context, parent networkid.PortalKey) ([]*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + rows, err := br.DB.Portal.GetChildren(ctx, parent) + if err != nil { + return nil, err + } + return br.loadManyPortals(ctx, rows) +} + +func (br *Bridge) GetDMPortal(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + dbPortal, err := br.DB.Portal.GetDM(ctx, receiver, otherUserID) + if err != nil { + return nil, err + } + return br.loadPortalWithCacheCheck(ctx, dbPortal) +} + +func (br *Bridge) GetPortalByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.UnlockedGetPortalByKey(ctx, key, false) +} + +func (br *Bridge) GetExistingPortalByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + if key.Receiver == "" || br.Config.SplitPortals { + return br.UnlockedGetPortalByKey(ctx, key, true) + } + cached, ok := br.portalsByKey[key] + if ok { + return cached, nil + } + cached, ok = br.portalsByKey[networkid.PortalKey{ID: key.ID}] + if ok { + return cached, nil + } + db, err := br.DB.Portal.GetByIDWithUncertainReceiver(ctx, key) + return br.loadPortal(ctx, db, err, nil) +} + +func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { + if portal.backgroundCtx.Err() != nil { + if portal.Bridge.BackgroundCtx.Err() != nil { + return EventHandlingResultFailed.WithError(fmt.Errorf("bridge shutting down: %w", portal.Bridge.BackgroundCtx.Err())) + } + return EventHandlingResultIgnored + } + if PortalEventBuffer == 0 { + portal.eventsLock.Lock() + defer portal.eventsLock.Unlock() + portal.eventIdx++ + return portal.handleSingleEventWithDelayLogging(portal.eventIdx, evt) + } else { + if portal.events == nil { + panic(fmt.Errorf("queueEvent into uninitialized portal %s", portal.PortalKey)) + } + select { + case portal.events <- evt: + return EventHandlingResultQueued + case <-portal.deleted: + return EventHandlingResultIgnored + default: + zerolog.Ctx(ctx).Error(). + Str("portal_id", string(portal.ID)). + Msg("Portal event channel is full, queue will block") + for { + select { + case portal.events <- evt: + return EventHandlingResultQueued + case <-portal.deleted: + return EventHandlingResultIgnored + case <-time.After(5 * time.Second): + zerolog.Ctx(ctx).Error(). + Str("portal_id", string(portal.ID)). + Msg("Portal event channel is still full") + } + } + } + } +} + +func (portal *Portal) eventLoop() { + if cfg := portal.Bridge.Network.GetCapabilities().OutgoingMessageTimeouts; cfg != nil { + ctx, cancel := context.WithCancel(portal.Log.WithContext(portal.backgroundCtx)) + go portal.pendingMessageTimeoutLoop(ctx, cfg) + defer cancel() + } + for i := 0; ; i++ { + select { + case rawEvt := <-portal.events: + if rawEvt == nil { + return + } + if portal.Bridge.Config.AsyncEvents { + go portal.handleSingleEventWithDelayLogging(i, rawEvt) + } else { + portal.handleSingleEventWithDelayLogging(i, rawEvt) + } + case <-portal.deleted: + return + } + } +} + +func (portal *Portal) handleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) { + ctx := portal.getEventCtxWithLog(rawEvt, idx) + log := zerolog.Ctx(ctx) + doneCh := make(chan struct{}) + var backgrounded atomic.Bool + start := time.Now() + var handleDuration time.Duration + // Note: this will assume success if the handler times out + outerRes = EventHandlingResult{Queued: true, Success: true, Error: ErrHandlerBackgrounded} + go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { + // If the portal was deleted, ignore errors. + // The bridge background ctx check distinguishes bridge stops from portal deletions. + if res.Error != nil && portal.backgroundCtx.Err() != nil && portal.Bridge.BackgroundCtx.Err() == nil { + res = EventHandlingResultIgnored + } + outerRes = res + handleDuration = time.Since(start) + close(doneCh) + if backgrounded.Load() { + log.Debug(). + Time("started_at", start). + Stringer("duration", handleDuration). + Msg("Event that took too long finally finished handling") + } + }) + tick := time.NewTicker(30 * time.Second) + _, isCreate := rawEvt.(*portalCreateEvent) + defer tick.Stop() + for i := 0; i < EventHandlingTimeoutTicks; i++ { + select { + case <-doneCh: + if i > 0 { + log.Debug(). + Time("started_at", start). + Stringer("duration", handleDuration). + Msg("Event that took long finished handling") + } + return + case <-tick.C: + log.Warn(). + Time("started_at", start). + Msg("Event handling is taking long") + if isCreate { + // Never background portal creation events + i = 1 + } + } + } + backgrounded.Store(true) + if PanicOnStuckEvent { + panic(fmt.Errorf("event handling started at %v is still not finished", start)) + } + log.Warn(). + Time("started_at", start). + Msg("Event handling is taking too long, continuing in background") + return +} + +type contextKey int + +const ( + contextKeyRemoteEvent contextKey = iota + contextKeyMatrixEvent +) + +func GetMatrixEventFromContext(ctx context.Context) (evt *event.Event) { + evt, _ = ctx.Value(contextKeyMatrixEvent).(*event.Event) + return +} + +func GetRemoteEventFromContext(ctx context.Context) (evt RemoteEvent) { + evt, _ = ctx.Value(contextKeyRemoteEvent).(RemoteEvent) + return +} + +func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { + var logWith zerolog.Context + switch evt := rawEvt.(type) { + case *portalMatrixEvent: + logWith = portal.Log.With().Int("event_loop_index", idx). + Str("action", "handle matrix event"). + Stringer("event_id", evt.evt.ID). + Str("event_type", evt.evt.Type.Type) + if evt.evt.Type.Class != event.EphemeralEventType { + logWith = logWith. + Stringer("event_id", evt.evt.ID). + Stringer("sender", evt.sender.MXID) + } + ctx := portal.backgroundCtx + ctx = context.WithValue(ctx, contextKeyMatrixEvent, evt.evt) + ctx = logWith.Logger().WithContext(ctx) + return ctx + case *portalRemoteEvent: + evt.evtType = evt.evt.GetType() + logWith = portal.Log.With().Int("event_loop_index", idx). + Str("action", "handle remote event"). + Str("source_id", string(evt.source.ID)). + Stringer("bridge_evt_type", evt.evtType) + logWith = evt.evt.AddLogContext(logWith) + if remoteSender := evt.evt.GetSender(); remoteSender.Sender != "" || remoteSender.IsFromMe { + logWith = logWith.Object("remote_sender", remoteSender) + } + if remoteMsg, ok := evt.evt.(RemoteMessage); ok { + if remoteMsgID := remoteMsg.GetID(); remoteMsgID != "" { + logWith = logWith.Str("remote_message_id", string(remoteMsgID)) + } + } + if remoteMsg, ok := evt.evt.(RemoteEventWithTargetMessage); ok { + if targetMsgID := remoteMsg.GetTargetMessage(); targetMsgID != "" { + logWith = logWith.Str("remote_target_message_id", string(targetMsgID)) + } + } + if remoteMsg, ok := evt.evt.(RemoteEventWithStreamOrder); ok { + if remoteStreamOrder := remoteMsg.GetStreamOrder(); remoteStreamOrder != 0 { + logWith = logWith.Int64("remote_stream_order", remoteStreamOrder) + } + } + if remoteMsg, ok := evt.evt.(RemoteEventWithTimestamp); ok { + if remoteTimestamp := remoteMsg.GetTimestamp(); !remoteTimestamp.IsZero() { + logWith = logWith.Time("remote_timestamp", remoteTimestamp) + } + } + ctx := portal.backgroundCtx + ctx = context.WithValue(ctx, contextKeyRemoteEvent, evt.evt) + ctx = logWith.Logger().WithContext(ctx) + if ctxMut, ok := evt.evt.(RemoteEventWithContextMutation); ok { + ctx = ctxMut.MutateContext(ctx) + } + return ctx + case *portalCreateEvent: + return evt.ctx + default: + panic(fmt.Errorf("invalid type %T in getEventCtxWithLog", evt)) + } +} + +func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(res EventHandlingResult)) { + log := zerolog.Ctx(ctx) + var res EventHandlingResult + defer func() { + doneCallback(res) + if err := recover(); err != nil { + logEvt := log.Error() + var errorString string + if realErr, ok := err.(error); ok { + logEvt = logEvt.Err(realErr) + errorString = realErr.Error() + } else { + logEvt = logEvt.Any(zerolog.ErrorFieldName, err) + errorString = fmt.Sprintf("%v", err) + } + stack := debug.Stack() + logEvt. + Bytes("stack", stack). + Msg("Event handling panicked") + switch evt := rawEvt.(type) { + case *portalMatrixEvent: + if evt.evt.ID != "" { + go portal.sendErrorStatus(ctx, evt.evt, ErrPanicInEventHandler) + } + case *portalCreateEvent: + evt.cb(fmt.Errorf("portal creation panicked")) + } + portal.Bridge.TrackAnalytics("", "Bridge Event Handler Panic", map[string]any{ + "error": errorString, + "stack": string(stack), + "handler_type": fmt.Sprintf("%T", rawEvt), + }) + } + }() + switch evt := rawEvt.(type) { + case *portalMatrixEvent: + isStateRequest := evt.evt.Type == event.BeeperSendState + if isStateRequest { + if err := portal.unwrapBeeperSendState(ctx, evt.evt); err != nil { + portal.sendErrorStatus(ctx, evt.evt, err) + return + } + } + res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt, isStateRequest) + if portal.backgroundCtx.Err() != nil { + return + } + if res.SendMSS { + if res.Error != nil { + portal.sendErrorStatus(ctx, evt.evt, res.Error) + } else { + portal.sendSuccessStatus(ctx, evt.evt, 0, "") + } + } + if !isStateRequest && res.Error != nil && evt.evt.StateKey != nil { + portal.revertRoomMeta(ctx, evt.evt) + } + if isStateRequest && res.Success && !res.SkipStateEcho { + portal.sendRoomMeta( + ctx, + evt.sender.DoublePuppet(ctx), + time.UnixMilli(evt.evt.Timestamp), + evt.evt.Type, + evt.evt.GetStateKey(), + evt.evt.Content.Parsed, + false, + evt.evt.Content.Raw, + ) + } + case *portalRemoteEvent: + res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) + case *portalCreateEvent: + err := portal.createMatrixRoomInLoop(evt.ctx, evt.source, evt.info, nil) + res.Success = err == nil + evt.cb(err) + default: + panic(fmt.Errorf("illegal type %T in eventLoop", evt)) + } +} + +func (portal *Portal) unwrapBeeperSendState(ctx context.Context, evt *event.Event) error { + if !portal.Bridge.Config.EnableSendStateRequests { + return fmt.Errorf("send state requests are not enabled") + } + content, ok := evt.Content.Parsed.(*event.BeeperSendStateEventContent) + if !ok { + return fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) + } + evt.Content = content.Content + evt.StateKey = &content.StateKey + evt.Type = event.Type{Type: content.Type, Class: event.StateEventType} + _ = evt.Content.ParseRaw(evt.Type) + mx, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if !ok { + return fmt.Errorf("matrix connector doesn't support fetching state") + } + prevEvt, err := mx.GetStateEvent(ctx, portal.MXID, evt.Type, evt.GetStateKey()) + if err != nil && !errors.Is(err, mautrix.MNotFound) { + return fmt.Errorf("failed to get prev event: %w", err) + } else if prevEvt != nil { + evt.Unsigned.PrevContent = &prevEvt.Content + evt.Unsigned.PrevSender = prevEvt.Sender + } + return nil +} + +func (portal *Portal) FindPreferredLogin(ctx context.Context, user *User, allowRelay bool) (*UserLogin, *database.UserPortal, error) { + if portal.Receiver != "" { + login, err := portal.Bridge.GetExistingUserLoginByID(ctx, portal.Receiver) + if err != nil { + return nil, nil, err + } + if login == nil { + return nil, nil, fmt.Errorf("%w (receiver login is nil)", ErrNotLoggedIn) + } else if !login.Client.IsLoggedIn() { + return nil, nil, fmt.Errorf("%w (receiver login is not logged in)", ErrNotLoggedIn) + } else if login.UserMXID != user.MXID { + if allowRelay && portal.Relay != nil { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("%w (relay not set and receiver login is owned by %s, not %s)", ErrNotLoggedIn, login.UserMXID, user.MXID) + } + up, err := portal.Bridge.DB.UserPortal.Get(ctx, login.UserLogin, portal.PortalKey) + return login, up, err + } + logins, err := portal.Bridge.DB.UserPortal.GetAllForUserInPortal(ctx, user.MXID, portal.PortalKey) + if err != nil { + return nil, nil, err + } + portal.Bridge.cacheLock.Lock() + defer portal.Bridge.cacheLock.Unlock() + for _, up := range logins { + login, ok := user.logins[up.LoginID] + if ok && login.Client != nil && login.Client.IsLoggedIn() { + return login, up, nil + } + } + if !allowRelay { + if len(logins) > 0 { + return nil, nil, fmt.Errorf("%w (no logins found that are in portal)", ErrNotLoggedIn) + } else if portal.Relay != nil { + return nil, nil, fmt.Errorf("%w (relay not allowed for this event)", ErrNotLoggedIn) + } + return nil, nil, ErrNotLoggedIn + } + // Portal has relay, use it + if portal.Relay != nil { + return nil, nil, nil + } + var firstLogin *UserLogin + for _, login := range user.logins { + firstLogin = login + break + } + if firstLogin != nil && firstLogin.Client.IsLoggedIn() { + zerolog.Ctx(ctx).Warn(). + Str("chosen_login_id", string(firstLogin.ID)). + Msg("No usable user portal rows found, returning random login") + return firstLogin, nil, nil + } else { + return nil, nil, fmt.Errorf("%w (no usable logins found)", ErrNotLoggedIn) + } +} + +func (portal *Portal) sendSuccessStatus(ctx context.Context, evt *event.Event, streamOrder int64, newEventID id.EventID) { + info := StatusEventInfoFromEvent(evt) + info.StreamOrder = streamOrder + if newEventID != evt.ID { + info.NewEventID = newEventID + } + portal.Bridge.Matrix.SendMessageStatus(ctx, &MessageStatus{Status: event.MessageStatusSuccess}, info) +} + +func (portal *Portal) sendErrorStatus(ctx context.Context, evt *event.Event, err error) { + status := WrapErrorInStatus(err) + if status.Status == "" { + status.Status = event.MessageStatusRetriable + } + if status.ErrorReason == "" { + status.ErrorReason = event.MessageStatusGenericError + } + if status.InternalError == nil { + status.InternalError = err + } + portal.Bridge.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) +} + +func (portal *Portal) checkConfusableName(ctx context.Context, userID id.UserID, name string) bool { + conn, ok := portal.Bridge.Matrix.(MatrixConnectorWithNameDisambiguation) + if !ok { + return false + } + confusableWith, err := conn.IsConfusableName(ctx, portal.MXID, userID, name) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to check if name is confusable") + return true + } + for _, confusable := range confusableWith { + // Don't disambiguate names that only conflict with ghosts of this bridge + if !portal.Bridge.IsGhostMXID(confusable) { + return true + } + } + return false +} + +var fakePerMessageProfileEventType = event.Type{Class: event.StateEventType, Type: "m.per_message_profile"} + +func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult { + log := zerolog.Ctx(ctx) + if evt.Mautrix.EventSource&event.SourceEphemeral != 0 { + switch evt.Type { + case event.EphemeralEventReceipt: + return portal.handleMatrixReceipts(ctx, evt) + case event.EphemeralEventTyping: + return portal.handleMatrixTyping(ctx, evt) + default: + return EventHandlingResultIgnored + } + } + if evt.Type == event.StateTombstone { + // Tombstones aren't bridged so they don't need a login + return portal.handleMatrixTombstone(ctx, evt) + } + login, userPortal, err := portal.FindPreferredLogin(ctx, sender, true) + if err != nil { + log.Err(err).Msg("Failed to get user login to handle Matrix event") + if errors.Is(err, ErrNotLoggedIn) { + shouldSendNotice := evt.Type == event.EventMessage && evt.Content.AsMessage().MsgType != event.MsgNotice + return EventHandlingResultFailed.WithMSSError(WrapErrorInStatus(err). + WithMessage(fmt.Sprintf("You're %s", err)). + WithIsCertain(true). + WithSendNotice(shouldSendNotice)) + } else { + return EventHandlingResultFailed.WithMSSError( + WrapErrorInStatus(err).WithMessage("Failed to get login to handle event").WithIsCertain(true).WithSendNotice(true), + ) + } + } + var origSender *OrigSender + if login == nil { + if isStateRequest { + return EventHandlingResultFailed.WithMSSError(ErrCantRelayStateRequest) + } + login = portal.Relay + origSender = &OrigSender{ + User: sender, + UserID: sender.MXID, + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Bool("is_relay", true) + }) + + memberInfo, err := portal.Bridge.Matrix.GetMemberInfo(ctx, portal.MXID, sender.MXID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get member info for user being relayed") + } else if memberInfo != nil { + origSender.MemberEventContent = *memberInfo + if memberInfo.Displayname == "" { + origSender.DisambiguatedName = sender.MXID.String() + } else if origSender.RequiresDisambiguation = portal.checkConfusableName(ctx, sender.MXID, memberInfo.Displayname); origSender.RequiresDisambiguation { + origSender.DisambiguatedName = fmt.Sprintf("%s (%s)", memberInfo.Displayname, sender.MXID) + } else { + origSender.DisambiguatedName = memberInfo.Displayname + } + } else { + origSender.DisambiguatedName = sender.MXID.String() + } + msg := evt.Content.AsMessage() + if msg != nil && msg.BeeperPerMessageProfile != nil && msg.BeeperPerMessageProfile.Displayname != "" { + pmp := msg.BeeperPerMessageProfile + origSender.PerMessageProfile = *pmp + roomPLs, err := portal.Bridge.Matrix.GetPowerLevels(ctx, portal.MXID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get power levels to check relay profile") + } + if roomPLs != nil && + roomPLs.GetUserLevel(sender.MXID) >= roomPLs.GetEventLevel(fakePerMessageProfileEventType) && + !portal.checkConfusableName(ctx, sender.MXID, pmp.Displayname) { + origSender.DisambiguatedName = pmp.Displayname + origSender.RequiresDisambiguation = false + } else { + origSender.DisambiguatedName = fmt.Sprintf("%s via %s", pmp.Displayname, origSender.DisambiguatedName) + } + } + + origSender.FormattedName = portal.Bridge.Config.Relay.FormatName(origSender) + } + // Copy logger because many of the handlers will use UpdateContext + ctx = log.With().Str("login_id", string(login.ID)).Logger().WithContext(ctx) + + if origSender == nil && portal.Bridge.Network.GetCapabilities().ImplicitReadReceipts && !evt.Type.IsAccountData() { + rrLog := log.With().Str("subaction", "implicit read receipt").Logger() + rrCtx := rrLog.WithContext(ctx) + rrLog.Debug().Msg("Sending implicit read receipt for event") + evtTS := time.UnixMilli(evt.Timestamp) + portal.callReadReceiptHandler(rrCtx, login, nil, &MatrixReadReceipt{ + Portal: portal, + EventID: evt.ID, + Implicit: true, + ReadUpTo: evtTS, + Receipt: event.ReadReceipt{Timestamp: evtTS}, + }, userPortal) + } + + switch evt.Type { + case event.EventMessage, event.EventSticker, event.EventUnstablePollStart, event.EventUnstablePollResponse: + return portal.handleMatrixMessage(ctx, login, origSender, evt) + case event.EventReaction: + return portal.handleMatrixReaction(ctx, login, origSender, evt) + case event.EventRedaction: + return portal.handleMatrixRedaction(ctx, login, origSender, evt) + case event.StateRoomName: + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomNameHandlingNetworkAPI.HandleMatrixRoomName) + case event.StateTopic: + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) + case event.StateRoomAvatar: + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) + case event.StateBeeperDisappearingTimer: + return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, DisappearTimerChangingNetworkAPI.HandleMatrixDisappearingTimer) + case event.StateEncryption: + // TODO? + return EventHandlingResultIgnored + case event.AccountDataMarkedUnread: + return handleMatrixAccountData(portal, ctx, login, evt, MarkedUnreadHandlingNetworkAPI.HandleMarkedUnread) + case event.AccountDataRoomTags: + return handleMatrixAccountData(portal, ctx, login, evt, TagHandlingNetworkAPI.HandleRoomTag) + case event.AccountDataBeeperMute: + return handleMatrixAccountData(portal, ctx, login, evt, MuteHandlingNetworkAPI.HandleMute) + case event.StateMember: + return portal.handleMatrixMembership(ctx, login, origSender, evt, isStateRequest) + case event.StatePowerLevels: + return portal.handleMatrixPowerLevels(ctx, login, origSender, evt, isStateRequest) + case event.BeeperDeleteChat: + return portal.handleMatrixDeleteChat(ctx, login, origSender, evt) + case event.BeeperAcceptMessageRequest: + return portal.handleMatrixAcceptMessageRequest(ctx, login, origSender, evt) + default: + return EventHandlingResultIgnored + } +} + +func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { + content, ok := evt.Content.Parsed.(*event.ReceiptEventContent) + if !ok { + return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + for evtID, receipts := range *content { + readReceipts, ok := receipts[event.ReceiptTypeRead] + if !ok { + continue + } + for userID, receipt := range readReceipts { + sender, err := portal.Bridge.GetUserByMXID(ctx, userID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle read receipt") + return EventHandlingResultFailed.WithError(err) + } + portal.handleMatrixReadReceipt(ctx, sender, evtID, receipt) + } + } + // TODO actual status + return EventHandlingResultSuccess +} + +func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) { + log := zerolog.Ctx(ctx) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c. + Stringer("event_id", eventID). + Stringer("user_id", user.MXID). + Stringer("receipt_ts", receipt.Timestamp) + }) + login, userPortal, err := portal.FindPreferredLogin(ctx, user, false) + if err != nil { + if !errors.Is(err, ErrNotLoggedIn) { + log.Err(err).Msg("Failed to get preferred login for user") + } + return + } else if login == nil { + return + } + rrClient, ok := login.Client.(ReadReceiptHandlingNetworkAPI) + if !ok { + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("user_login_id", string(login.ID)) + }) + evt := &MatrixReadReceipt{ + Portal: portal, + EventID: eventID, + Receipt: receipt, + } + evt.ExactMessage, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, eventID) + if err != nil { + log.Err(err).Msg("Failed to get exact message from database") + evt.ReadUpTo = receipt.Timestamp + } else if evt.ExactMessage != nil { + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("exact_message_id", string(evt.ExactMessage.ID)).Time("exact_message_ts", evt.ExactMessage.Timestamp) + }) + evt.ReadUpTo = evt.ExactMessage.Timestamp + } else { + evt.ReadUpTo = receipt.Timestamp + } + portal.callReadReceiptHandler(ctx, login, rrClient, evt, userPortal) +} + +func (portal *Portal) callReadReceiptHandler( + ctx context.Context, + login *UserLogin, + rrClient ReadReceiptHandlingNetworkAPI, + evt *MatrixReadReceipt, + userPortal *database.UserPortal, +) { + if rrClient == nil { + var ok bool + rrClient, ok = login.Client.(ReadReceiptHandlingNetworkAPI) + if !ok { + return + } + } + if userPortal == nil { + userPortal = database.UserPortalFor(login.UserLogin, portal.PortalKey) + } else { + evt.LastRead = userPortal.LastRead + userPortal = userPortal.CopyWithoutValues() + } + err := rrClient.HandleMatrixReadReceipt(ctx, evt) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to handle read receipt") + return + } + userPortal.LastRead = evt.ReadUpTo + err = portal.Bridge.DB.UserPortal.Put(ctx, userPortal) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save user portal metadata") + } + portal.Bridge.DisappearLoop.StartAllBefore(ctx, portal.MXID, evt.ReadUpTo) +} + +func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { + content, ok := evt.Content.Parsed.(*event.TypingEventContent) + if !ok { + return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + portal.currentlyTypingLock.Lock() + defer portal.currentlyTypingLock.Unlock() + slices.Sort(content.UserIDs) + stoppedTyping, startedTyping := exslices.SortedDiff(portal.currentlyTyping, content.UserIDs, func(a, b id.UserID) int { + return strings.Compare(string(a), string(b)) + }) + portal.sendTypings(ctx, stoppedTyping, false) + portal.sendTypings(ctx, startedTyping, true) + portal.currentlyTyping = content.UserIDs + // TODO actual status + return EventHandlingResultSuccess +} + +func (portal *Portal) sendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { + for _, userID := range userIDs { + login, ok := portal.currentlyTypingLogins[userID] + if !ok && !typing { + continue + } else if !ok { + user, err := portal.Bridge.GetUserByMXID(ctx, userID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to get user to send typing event") + continue + } else if user == nil { + continue + } + login, _, err = portal.FindPreferredLogin(ctx, user, false) + if err != nil { + if !errors.Is(err, ErrNotLoggedIn) { + zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to get user login to send typing event") + } + continue + } else if login == nil { + continue + } else if _, ok = login.Client.(TypingHandlingNetworkAPI); !ok { + continue + } + portal.currentlyTypingLogins[userID] = login + } + if !typing { + delete(portal.currentlyTypingLogins, userID) + } + typingAPI, ok := login.Client.(TypingHandlingNetworkAPI) + if !ok { + continue + } + err := typingAPI.HandleMatrixTyping(ctx, &MatrixTyping{ + Portal: portal, + IsTyping: typing, + Type: TypingTypeText, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to bridge Matrix typing event") + } else { + zerolog.Ctx(ctx).Debug(). + Stringer("user_id", userID). + Bool("typing", typing). + Msg("Sent typing event") + } + } +} + +func (portal *Portal) periodicTypingUpdater() { + // TODO actually call this function + log := portal.Log.With().Str("component", "typing updater").Logger() + ctx := log.WithContext(context.Background()) + for { + // TODO make delay configurable by network connector + time.Sleep(5 * time.Second) + portal.currentlyTypingLock.Lock() + if len(portal.currentlyTyping) == 0 { + portal.currentlyTypingLock.Unlock() + continue + } + for _, userID := range portal.currentlyTyping { + login, ok := portal.currentlyTypingLogins[userID] + if !ok { + continue + } + typingAPI, ok := login.Client.(TypingHandlingNetworkAPI) + if !ok { + continue + } + err := typingAPI.HandleMatrixTyping(ctx, &MatrixTyping{ + Portal: portal, + IsTyping: true, + Type: TypingTypeText, + }) + if err != nil { + log.Err(err).Stringer("user_id", userID).Msg("Failed to repeat Matrix typing event") + } else { + log.Debug(). + Stringer("user_id", userID). + Bool("typing", true). + Msg("Sent repeated typing event") + } + } + portal.currentlyTypingLock.Unlock() + } +} + +func (portal *Portal) checkMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error { + switch content.MsgType { + case event.MsgText, event.MsgNotice, event.MsgEmote: + // No checks for now, message length is safer to check after conversion inside connector + case event.MsgLocation: + if caps.LocationMessage.Reject() { + return ErrLocationMessagesNotAllowed + } + case event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile, event.CapMsgSticker: + capMsgType := content.GetCapMsgType() + feat, ok := caps.File[capMsgType] + if !ok { + return ErrUnsupportedMessageType + } + if content.MsgType != event.CapMsgSticker && + content.FileName != "" && + content.Body != content.FileName && + feat.Caption.Reject() { + return ErrCaptionsNotAllowed + } + if content.Info != nil { + dur := time.Duration(content.Info.Duration) * time.Millisecond + if feat.MaxDuration != nil && dur > feat.MaxDuration.Duration { + if capMsgType == event.CapMsgVoice { + return fmt.Errorf("%w: %s supports voice messages up to %s long", ErrVoiceMessageDurationTooLong, portal.Bridge.Network.GetName().DisplayName, exfmt.Duration(feat.MaxDuration.Duration)) + } + return fmt.Errorf("%w: %s is longer than the maximum of %s", ErrMediaDurationTooLong, exfmt.Duration(dur), exfmt.Duration(feat.MaxDuration.Duration)) + } + if feat.MaxSize != 0 && int64(content.Info.Size) > feat.MaxSize { + return fmt.Errorf("%w: %.1f MiB is larger than the maximum of %.1f MiB", ErrMediaTooLarge, float64(content.Info.Size)/1024/1024, float64(feat.MaxSize)/1024/1024) + } + if content.Info.MimeType != "" && feat.GetMimeSupport(content.Info.MimeType).Reject() { + return fmt.Errorf("%w (%s in %s)", ErrUnsupportedMediaType, content.Info.MimeType, capMsgType) + } + } + fallthrough + default: + } + return nil +} + +func (portal *Portal) parseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { + if origSender != nil || !strings.HasPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix) { + return "" + } + return networkid.RawTransactionID(strings.TrimPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix)) +} + +func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + log := zerolog.Ctx(ctx) + var relatesTo *event.RelatesTo + var msgContent *event.MessageEventContent + var pollContent *event.PollStartEventContent + var pollResponseContent *event.PollResponseEventContent + var ok bool + if evt.Type == event.EventUnstablePollStart { + pollContent, ok = evt.Content.Parsed.(*event.PollStartEventContent) + relatesTo = pollContent.RelatesTo + } else if evt.Type == event.EventUnstablePollResponse { + pollResponseContent, ok = evt.Content.Parsed.(*event.PollResponseEventContent) + relatesTo = &pollResponseContent.RelatesTo + } else { + msgContent, ok = evt.Content.Parsed.(*event.MessageEventContent) + relatesTo = msgContent.RelatesTo + if evt.Type == event.EventSticker { + msgContent.MsgType = event.CapMsgSticker + } + if msgContent.MsgType == event.MsgNotice && !portal.Bridge.Config.BridgeNotices { + return EventHandlingResultIgnored.WithMSSError(ErrIgnoringMNotice) + } + } + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed. + WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + caps := sender.Client.GetCapabilities(ctx, portal) + + if relatesTo.GetReplaceID() != "" { + if msgContent == nil { + log.Warn().Msg("Ignoring edit of poll") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w of polls", ErrEditsNotSupported)) + } + return portal.handleMatrixEdit(ctx, sender, origSender, evt, msgContent, caps) + } + var err error + if origSender != nil { + if msgContent == nil { + log.Debug().Msg("Ignoring poll event from relayed user") + return EventHandlingResultIgnored.WithMSSError(ErrIgnoringPollFromRelayedUser) + } + if !caps.PerMessageProfileRelay { + if evt.Type == event.EventSticker { + msgContent.MsgType = event.MsgImage + evt.Type = event.EventMessage + } + msgContent, err = portal.Bridge.Config.Relay.FormatMessage(msgContent, origSender) + if err != nil { + log.Err(err).Msg("Failed to format message for relaying") + return EventHandlingResultFailed.WithMSSError(err) + } + } + } + if msgContent != nil { + if err = portal.checkMessageContentCaps(caps, msgContent); err != nil { + return EventHandlingResultFailed.WithMSSError(err) + } + } else if pollResponseContent != nil || pollContent != nil { + if _, ok = sender.Client.(PollHandlingNetworkAPI); !ok { + log.Debug().Msg("Ignoring poll event as network connector doesn't implement PollHandlingNetworkAPI") + return EventHandlingResultIgnored.WithMSSError(ErrPollsNotSupported) + } + } + + var threadRoot, replyTo, voteTo *database.Message + if evt.Type == event.EventUnstablePollResponse { + voteTo, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, relatesTo.GetReferenceID()) + if err != nil { + log.Err(err).Msg("Failed to get poll target message from database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get poll message: %w", ErrDatabaseError, err)) + } else if voteTo == nil { + log.Warn().Stringer("vote_to_id", relatesTo.GetReferenceID()).Msg("Poll target message not found") + return EventHandlingResultFailed.WithMSSError(ErrUnknownPoll) + } + } + var replyToID id.EventID + threadRootID := relatesTo.GetThreadParent() + if caps.Thread.Partial() { + replyToID = relatesTo.GetNonFallbackReplyTo() + if threadRootID != "" { + threadRoot, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, threadRootID) + if err != nil { + log.Err(err).Msg("Failed to get thread root message from database") + } else if threadRoot == nil { + log.Warn().Stringer("thread_root_id", threadRootID).Msg("Thread root message not found") + } + } + } else { + replyToID = relatesTo.GetReplyTo() + } + if replyToID != "" && (caps.Reply.Partial() || caps.Thread.Partial()) { + replyTo, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, replyToID) + if err != nil { + log.Err(err).Msg("Failed to get reply target message from database") + } else if replyTo == nil { + log.Warn().Stringer("reply_to_id", replyToID).Msg("Reply target message not found") + } else { + // Support replying to threads from non-thread-capable clients. + // The fallback happens if the message is not a Matrix thread and either + // * the replied-to message is in a thread, or + // * the network only supports threads (assume the user wants to start a new thread) + if caps.Thread.Partial() && threadRoot == nil && (replyTo.ThreadRoot != "" || !caps.Reply.Partial()) { + threadRootRemoteID := replyTo.ThreadRoot + if threadRootRemoteID == "" { + threadRootRemoteID = replyTo.ID + } + threadRoot, err = portal.Bridge.DB.Message.GetFirstThreadMessage(ctx, portal.PortalKey, threadRootRemoteID) + if err != nil { + log.Err(err).Msg("Failed to get thread root message from database (via reply fallback)") + } + } + if !caps.Reply.Partial() { + replyTo = nil + } + } + } + var messageTimer *event.BeeperDisappearingTimer + if msgContent != nil { + messageTimer = msgContent.BeeperDisappearingTimer + } + if messageTimer != nil && *portal.Disappear.ToEventContent() != *messageTimer { + log.Warn(). + Any("event_timer", messageTimer). + Any("portal_timer", portal.Disappear.ToEventContent()). + Msg("Mismatching disappearing timer in event") + } + + wrappedMsgEvt := &MatrixMessage{ + MatrixEventBase: MatrixEventBase[*event.MessageEventContent]{ + Event: evt, + Content: msgContent, + OrigSender: origSender, + Portal: portal, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + ThreadRoot: threadRoot, + ReplyTo: replyTo, + } + if portal.Bridge.Config.DeduplicateMatrixMessages { + if part, err := portal.Bridge.DB.Message.GetPartByTxnID(ctx, portal.Receiver, evt.ID, wrappedMsgEvt.InputTransactionID); err != nil { + log.Err(err).Msg("Failed to check db if message is already sent") + } else if part != nil { + log.Debug(). + Stringer("message_mxid", part.MXID). + Stringer("input_event_id", evt.ID). + Msg("Message already sent, ignoring") + return EventHandlingResultIgnored + } + } + + err = portal.autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) + if err != nil { + log.Warn().Err(err).Msg("Failed to auto-accept message request on message") + // TODO stop processing? + } + + var resp *MatrixMessageResponse + if msgContent != nil { + resp, err = sender.Client.HandleMatrixMessage(ctx, wrappedMsgEvt) + } else if pollContent != nil { + resp, err = sender.Client.(PollHandlingNetworkAPI).HandleMatrixPollStart(ctx, &MatrixPollStart{ + MatrixMessage: *wrappedMsgEvt, + Content: pollContent, + }) + } else if pollResponseContent != nil { + resp, err = sender.Client.(PollHandlingNetworkAPI).HandleMatrixPollVote(ctx, &MatrixPollVote{ + MatrixMessage: *wrappedMsgEvt, + VoteTo: voteTo, + Content: pollResponseContent, + }) + } else { + log.Error().Msg("Failed to handle Matrix message: all contents are nil?") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("all contents are nil")) + } + if err != nil { + log.Err(err).Msg("Failed to handle Matrix message") + return EventHandlingResultFailed.WithMSSError(err) + } + message := wrappedMsgEvt.fillDBMessage(resp.DB) + if resp.Pending { + for _, save := range wrappedMsgEvt.pendingSaves { + save.ackedAt = time.Now() + } + } else { + if resp.DB == nil { + log.Error().Msg("Network connector didn't return a message to save") + } else { + if portal.Bridge.Config.OutgoingMessageReID { + message.MXID = portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, message.ID, message.PartID) + } + // Hack to ensure the ghost row exists + // TODO move to better place (like login) + portal.Bridge.GetGhostByID(ctx, message.SenderID) + err = portal.Bridge.DB.Message.Insert(ctx, message) + if err != nil { + log.Err(err).Msg("Failed to save message to database") + } else if resp.PostSave != nil { + resp.PostSave(ctx, message) + } + if resp.RemovePending != "" { + portal.outgoingMessagesLock.Lock() + delete(portal.outgoingMessages, resp.RemovePending) + portal.outgoingMessagesLock.Unlock() + } + } + portal.sendSuccessStatus(ctx, evt, resp.StreamOrder, message.MXID) + } + ds := portal.Disappear + if messageTimer != nil { + ds = database.DisappearingSettingFromEvent(messageTimer) + } + if ds.Type != event.DisappearingTypeNone { + go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ + RoomID: portal.MXID, + EventID: message.MXID, + Timestamp: message.Timestamp, + DisappearingSetting: ds.StartingAt(message.Timestamp), + }) + } + if resp.Pending { + // Not exactly queued, but not finished either + return EventHandlingResultQueued + } + return EventHandlingResultSuccess.WithEventID(message.MXID).WithStreamOrder(resp.StreamOrder) +} + +// AddPendingToIgnore adds a transaction ID that should be ignored if encountered as a new message. +// +// This should be used when the network connector will return the real message ID from HandleMatrixMessage. +// The [MatrixMessageResponse] should include RemovePending with the transaction ID sto remove it from the lit +// after saving to database. +// +// See also: [MatrixMessage.AddPendingToSave] +func (evt *MatrixMessage) AddPendingToIgnore(txnID networkid.TransactionID) { + evt.Portal.outgoingMessagesLock.Lock() + evt.Portal.outgoingMessages[txnID] = &outgoingMessage{ + ignore: true, + } + evt.Portal.outgoingMessagesLock.Unlock() +} + +// AddPendingToSave adds a transaction ID that should be processed and pointed at the existing event if encountered. +// +// This should be used when the network connector returns `Pending: true` from HandleMatrixMessage, +// i.e. when the network connector does not know the message ID at the end of the handler. +// The [MatrixMessageResponse] should set Pending to true to prevent saving the returned message to the database. +// +// The provided function will be called when the message is encountered. +func (evt *MatrixMessage) AddPendingToSave(message *database.Message, txnID networkid.TransactionID, handleEcho RemoteEchoHandler) { + pending := &outgoingMessage{ + db: evt.fillDBMessage(message), + evt: evt.Event, + handle: handleEcho, + } + evt.Portal.outgoingMessagesLock.Lock() + evt.Portal.outgoingMessages[txnID] = pending + evt.pendingSaves = append(evt.pendingSaves, pending) + evt.Portal.outgoingMessagesLock.Unlock() +} + +// RemovePending removes a transaction ID from the list of pending messages. +// This should only be called if sending the message fails. +func (evt *MatrixMessage) RemovePending(txnID networkid.TransactionID) { + evt.Portal.outgoingMessagesLock.Lock() + pendingSave := evt.Portal.outgoingMessages[txnID] + if pendingSave != nil { + evt.pendingSaves = slices.DeleteFunc(evt.pendingSaves, func(save *outgoingMessage) bool { + return save == pendingSave + }) + } + delete(evt.Portal.outgoingMessages, txnID) + evt.Portal.outgoingMessagesLock.Unlock() +} + +func (evt *MatrixMessage) fillDBMessage(message *database.Message) *database.Message { + if message == nil { + message = &database.Message{} + } + if message.MXID == "" { + message.MXID = evt.Event.ID + } + if message.Room.ID == "" { + message.Room = evt.Portal.PortalKey + } + if message.Timestamp.IsZero() { + message.Timestamp = time.UnixMilli(evt.Event.Timestamp) + } + if message.ReplyTo.MessageID == "" && evt.ReplyTo != nil { + message.ReplyTo.MessageID = evt.ReplyTo.ID + message.ReplyTo.PartID = &evt.ReplyTo.PartID + } + if message.ThreadRoot == "" && evt.ThreadRoot != nil { + message.ThreadRoot = evt.ThreadRoot.ID + if evt.ThreadRoot.ThreadRoot != "" { + message.ThreadRoot = evt.ThreadRoot.ThreadRoot + } + } + if message.SenderMXID == "" { + message.SenderMXID = evt.Event.Sender + } + if message.SendTxnID != "" { + message.SendTxnID = evt.InputTransactionID + } + return message +} + +func (portal *Portal) pendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { + ticker := time.NewTicker(cfg.CheckInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + portal.checkPendingMessages(ctx, cfg) + case <-ctx.Done(): + return + } + } +} + +func (portal *Portal) checkPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) { + portal.outgoingMessagesLock.Lock() + defer portal.outgoingMessagesLock.Unlock() + for _, msg := range portal.outgoingMessages { + if msg.evt != nil && !msg.timeouted { + if cfg.NoEchoTimeout > 0 && !msg.ackedAt.IsZero() && time.Since(msg.ackedAt) > cfg.NoEchoTimeout { + msg.timeouted = true + portal.sendErrorStatus(ctx, msg.evt, ErrRemoteEchoTimeout.WithMessage(cfg.NoEchoMessage)) + } else if cfg.NoAckTimeout > 0 && time.Since(msg.db.Timestamp) > cfg.NoAckTimeout { + msg.timeouted = true + portal.sendErrorStatus(ctx, msg.evt, ErrRemoteAckTimeout.WithMessage(cfg.NoAckMessage)) + } + } + } +} + +func (portal *Portal) handleMatrixEdit( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, + content *event.MessageEventContent, + caps *event.RoomFeatures, +) EventHandlingResult { + log := zerolog.Ctx(ctx) + editTargetID := content.RelatesTo.GetReplaceID() + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("edit_target_mxid", editTargetID) + }) + if content.NewContent != nil { + content = content.NewContent + if evt.Type == event.EventSticker { + content.MsgType = event.CapMsgSticker + } + } + if origSender != nil && !caps.PerMessageProfileRelay { + if evt.Type == event.EventSticker { + content.MsgType = event.MsgImage + evt.Type = event.EventMessage + } + var err error + content, err = portal.Bridge.Config.Relay.FormatMessage(content, origSender) + if err != nil { + log.Err(err).Msg("Failed to format message for relaying") + return EventHandlingResultFailed.WithMSSError(err) + } + } + + editingAPI, ok := sender.Client.(EditHandlingNetworkAPI) + if !ok { + log.Debug().Msg("Ignoring edit as network connector doesn't implement EditHandlingNetworkAPI") + return EventHandlingResultIgnored.WithMSSError(ErrEditsNotSupported) + } else if !caps.Edit.Partial() { + log.Debug().Msg("Ignoring edit as room doesn't support edits") + return EventHandlingResultIgnored.WithMSSError(ErrEditsNotSupportedInPortal) + } else if err := portal.checkMessageContentCaps(caps, content); err != nil { + return EventHandlingResultFailed.WithMSSError(err) + } + editTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, editTargetID) + if err != nil { + log.Err(err).Msg("Failed to get edit target message from database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get edit target: %w", ErrDatabaseError, err)) + } else if editTarget == nil { + log.Warn().Msg("Edit target message not found in database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("edit %w", ErrTargetMessageNotFound)) + } else if caps.EditMaxAge != nil && caps.EditMaxAge.Duration > 0 && time.Since(editTarget.Timestamp) > caps.EditMaxAge.Duration { + return EventHandlingResultFailed.WithMSSError(ErrEditTargetTooOld) + } else if caps.EditMaxCount > 0 && editTarget.EditCount >= caps.EditMaxCount { + return EventHandlingResultFailed.WithMSSError(ErrEditTargetTooManyEdits) + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("edit_target_remote_id", string(editTarget.ID)) + }) + err = editingAPI.HandleMatrixEdit(ctx, &MatrixEdit{ + MatrixEventBase: MatrixEventBase[*event.MessageEventContent]{ + Event: evt, + Content: content, + OrigSender: origSender, + Portal: portal, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + EditTarget: editTarget, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix edit") + return EventHandlingResultFailed.WithMSSError(err) + } + err = portal.Bridge.DB.Message.Update(ctx, editTarget) + if err != nil { + log.Err(err).Msg("Failed to save message to database after editing") + } + // TODO allow returning stream order from HandleMatrixEdit + portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultSuccess +} + +func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) (handleRes EventHandlingResult) { + log := zerolog.Ctx(ctx) + reactingAPI, ok := sender.Client.(ReactionHandlingNetworkAPI) + if !ok { + log.Debug().Msg("Ignoring reaction as network connector doesn't implement ReactionHandlingNetworkAPI") + return EventHandlingResultIgnored.WithMSSError(ErrReactionsNotSupported) + } + content, ok := evt.Content.Parsed.(*event.ReactionEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("reaction_target_mxid", content.RelatesTo.EventID) + }) + reactionTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.RelatesTo.EventID) + if err != nil { + log.Err(err).Msg("Failed to get reaction target message from database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get reaction target: %w", ErrDatabaseError, err)) + } else if reactionTarget == nil { + log.Warn().Msg("Reaction target message not found in database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("reaction %w", ErrTargetMessageNotFound)) + } + caps := sender.Client.GetCapabilities(ctx, portal) + err = portal.autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) + if err != nil { + log.Warn().Err(err).Msg("Failed to auto-accept message request on reaction") + // TODO stop processing? + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("reaction_target_remote_id", string(reactionTarget.ID)) + }) + react := &MatrixReaction{ + MatrixEventBase: MatrixEventBase[*event.ReactionEventContent]{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + TargetMessage: reactionTarget, + } + preResp, err := reactingAPI.PreHandleMatrixReaction(ctx, react) + if err != nil { + log.Err(err).Msg("Failed to pre-handle Matrix reaction") + return EventHandlingResultFailed.WithMSSError(err) + } + var deterministicID id.EventID + if portal.Bridge.Config.OutgoingMessageReID { + deterministicID = portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, reactionTarget, preResp.SenderID, preResp.EmojiID) + } + defer func() { + // Do this in a defer so that it happens after any potential defer calls to removeOutdatedReaction + if handleRes.Success { + portal.sendSuccessStatus(ctx, evt, 0, deterministicID) + } + }() + removeOutdatedReaction := func(oldReact *database.Reaction, deleteDB bool) { + if !handleRes.Success { + return + } + _, err := portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: oldReact.MXID, + }, + }, nil) + if err != nil { + log.Err(err).Msg("Failed to remove old reaction") + } + if deleteDB { + err = portal.Bridge.DB.Reaction.Delete(ctx, oldReact) + if err != nil { + log.Err(err).Msg("Failed to delete old reaction from database") + } + } + } + existing, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, reactionTarget.ID, reactionTarget.PartID, preResp.SenderID, preResp.EmojiID) + if err != nil { + log.Err(err).Msg("Failed to check if reaction is a duplicate") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to check for existing reaction: %w", ErrDatabaseError, err)) + } else if existing != nil { + if existing.EmojiID != "" || existing.Emoji == preResp.Emoji { + log.Debug().Msg("Ignoring duplicate reaction") + portal.sendSuccessStatus(ctx, evt, 0, deterministicID) + return EventHandlingResultIgnored.WithEventID(deterministicID) + } + react.ReactionToOverride = existing + defer removeOutdatedReaction(existing, false) + } + react.PreHandleResp = &preResp + if preResp.MaxReactions > 0 { + allReactions, err := portal.Bridge.DB.Reaction.GetAllToMessageBySender(ctx, portal.Receiver, reactionTarget.ID, preResp.SenderID) + if err != nil { + log.Err(err).Msg("Failed to get all reactions to message by sender") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get previous reactions: %w", ErrDatabaseError, err)) + } + if len(allReactions) < preResp.MaxReactions { + react.ExistingReactionsToKeep = allReactions + } else { + // Keep n-1 previous reactions and remove the rest + react.ExistingReactionsToKeep = allReactions[:preResp.MaxReactions-1] + for _, oldReaction := range allReactions[preResp.MaxReactions-1:] { + if existing != nil && oldReaction.EmojiID == existing.EmojiID { + // Don't double-delete on networks that only allow one emoji + continue + } + // Intentionally defer in a loop, there won't be that many items, + // and we want all of them to be done after this function completes successfully + //goland:noinspection GoDeferInLoop + defer removeOutdatedReaction(oldReaction, true) + } + } + } + dbReaction, err := reactingAPI.HandleMatrixReaction(ctx, react) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix reaction") + return EventHandlingResultFailed.WithMSSError(err) + } + if dbReaction == nil { + dbReaction = &database.Reaction{} + } + // Fill all fields that are known to allow omitting them in connector code + if dbReaction.Room.ID == "" { + dbReaction.Room = portal.PortalKey + } + if dbReaction.MessageID == "" { + dbReaction.MessageID = reactionTarget.ID + dbReaction.MessagePartID = reactionTarget.PartID + } + if deterministicID != "" { + dbReaction.MXID = deterministicID + } else if dbReaction.MXID == "" { + dbReaction.MXID = evt.ID + } + if dbReaction.Timestamp.IsZero() { + dbReaction.Timestamp = time.UnixMilli(evt.Timestamp) + } + if preResp.EmojiID == "" && dbReaction.EmojiID == "" { + if dbReaction.Emoji == "" { + dbReaction.Emoji = preResp.Emoji + } + } else if dbReaction.EmojiID == "" { + dbReaction.EmojiID = preResp.EmojiID + } + if dbReaction.SenderID == "" { + dbReaction.SenderID = preResp.SenderID + } + if dbReaction.SenderMXID == "" { + dbReaction.SenderMXID = evt.Sender + } + err = portal.Bridge.DB.Reaction.Upsert(ctx, dbReaction) + if err != nil { + log.Err(err).Msg("Failed to save reaction to database") + } + return EventHandlingResultSuccess.WithEventID(deterministicID) +} + +func handleMatrixRoomMeta[APIType any, ContentType any]( + portal *Portal, + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, + isStateRequest bool, + fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) (bool, error), +) EventHandlingResult { + if evt.StateKey == nil || *evt.StateKey != "" { + return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) + } + //caps := sender.Client.GetCapabilities(ctx, portal) + //if stateCap, ok := caps.State[evt.Type.Type]; !ok || stateCap.Level <= event.CapLevelUnsupported { + // return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%s %w", evt.Type.Type, ErrRoomMetadataNotAllowed)) + //} + api, ok := sender.Client.(APIType) + if !ok { + return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%w of type %s", ErrRoomMetadataNotSupported, evt.Type)) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(ContentType) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + switch typedContent := evt.Content.Parsed.(type) { + case *event.RoomNameEventContent: + if typedContent.Name == portal.Name { + portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultIgnored + } + case *event.TopicEventContent: + if typedContent.Topic == portal.Topic { + portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultIgnored + } + case *event.RoomAvatarEventContent: + if typedContent.URL == portal.AvatarMXC { + portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultIgnored + } + case *event.BeeperDisappearingTimer: + if typedContent.Type == event.DisappearingTypeNone || typedContent.Timer.Duration <= 0 { + typedContent.Type = event.DisappearingTypeNone + typedContent.Timer.Duration = 0 + } + if typedContent.Type == portal.Disappear.Type && typedContent.Timer.Duration == portal.Disappear.Timer { + portal.sendSuccessStatus(ctx, evt, 0, "") + return EventHandlingResultIgnored + } + if !sender.Client.GetCapabilities(ctx, portal).DisappearingTimer.Supports(typedContent) { + return EventHandlingResultFailed.WithMSSError(ErrDisappearingTimerUnsupported) + } + } + var prevContent ContentType + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent, _ = evt.Unsigned.PrevContent.Parsed.(ContentType) + } + + changed, err := fn(api, ctx, &MatrixRoomMeta[ContentType]{ + MatrixEventBase: MatrixEventBase[ContentType]{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + IsStateRequest: isStateRequest, + PrevContent: prevContent, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix room metadata") + return EventHandlingResultFailed.WithMSSError(err) + } + if changed { + if evt.Type != event.StateBeeperDisappearingTimer { + portal.UpdateBridgeInfo(ctx) + } + err = portal.Save(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal after updating room metadata") + } + } + return EventHandlingResultSuccess.WithMSS() +} + +func handleMatrixAccountData[APIType any, ContentType any]( + portal *Portal, ctx context.Context, sender *UserLogin, evt *event.Event, + fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) error, +) EventHandlingResult { + api, ok := sender.Client.(APIType) + if !ok { + return EventHandlingResultIgnored + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(ContentType) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + var prevContent ContentType + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent, _ = evt.Unsigned.PrevContent.Parsed.(ContentType) + } + + err := fn(api, ctx, &MatrixRoomMeta[ContentType]{ + MatrixEventBase: MatrixEventBase[ContentType]{ + Event: evt, + Content: content, + Portal: portal, + }, + PrevContent: prevContent, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix room account data") + return EventHandlingResultFailed.WithError(err) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) getTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) { + if targetGhost, err := portal.Bridge.GetGhostByMXID(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to get ghost: %w", err) + } else if targetGhost != nil { + return targetGhost, nil + } else if targetUser, err := portal.Bridge.GetUserByMXID(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } else if targetUserLogin, _, err := portal.FindPreferredLogin(ctx, targetUser, false); err != nil { + return nil, fmt.Errorf("failed to find preferred login: %w", err) + } else if targetUserLogin != nil { + return targetUserLogin, nil + } else { + // Return raw nil as a separate case to ensure a typed nil isn't returned + return nil, nil + } +} + +func (portal *Portal) handleMatrixAcceptMessageRequest( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, +) EventHandlingResult { + if origSender != nil { + return EventHandlingResultFailed.WithMSSError(ErrIgnoringAcceptRequestRelayedUser) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.BeeperAcceptMessageRequestEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + api, ok := sender.Client.(MessageRequestAcceptingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) + } + err := api.HandleMatrixAcceptMessageRequest(ctx, &MatrixAcceptMessageRequest{ + Event: evt, + Content: content, + Portal: portal, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix accept message request") + return EventHandlingResultFailed.WithMSSError(err) + } + if portal.MessageRequest { + portal.MessageRequest = false + portal.UpdateBridgeInfo(ctx) + err = portal.Save(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal after accepting message request") + } + } + return EventHandlingResultSuccess.WithMSS() +} + +func (portal *Portal) autoAcceptMessageRequest( + ctx context.Context, evt *event.Event, sender *UserLogin, origSender *OrigSender, caps *event.RoomFeatures, +) error { + if !portal.MessageRequest || caps.MessageRequest == nil || caps.MessageRequest.AcceptWithMessage == event.CapLevelFullySupported { + return nil + } + mran, ok := sender.Client.(MessageRequestAcceptingNetworkAPI) + if !ok { + return nil + } + err := mran.HandleMatrixAcceptMessageRequest(ctx, &MatrixAcceptMessageRequest{ + Event: evt, + Content: &event.BeeperAcceptMessageRequestEventContent{ + IsImplicit: true, + }, + Portal: portal, + OrigSender: origSender, + }) + if err != nil { + return err + } + if portal.MessageRequest { + portal.MessageRequest = false + portal.UpdateBridgeInfo(ctx) + err = portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after accepting message request") + } + } + return nil +} + +func (portal *Portal) handleMatrixDeleteChat( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, +) EventHandlingResult { + if origSender != nil { + return EventHandlingResultFailed.WithMSSError(ErrIgnoringDeleteChatRelayedUser) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.BeeperChatDeleteEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + api, ok := sender.Client.(DeleteChatHandlingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) + } + err := api.HandleMatrixDeleteChat(ctx, &MatrixDeleteChat{ + Event: evt, + Content: content, + Portal: portal, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix chat delete") + return EventHandlingResultFailed.WithMSSError(err) + } + if portal.Receiver == "" { + _, others, err := portal.findOtherLogins(ctx, sender) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return EventHandlingResultFailed.WithError(err) + } else if len(others) > 0 { + log.Debug().Msg("Not deleting portal after chat delete as other logins are present") + return EventHandlingResultSuccess + } + } + err = portal.Delete(ctx) + if err != nil { + log.Err(err).Msg("Failed to delete portal from database") + return EventHandlingResultFailed.WithMSSError(err) + } + // The event context has likely been canceled by delete, so use a background context for the delete call + noCancelCtx := log.WithContext(portal.Bridge.BackgroundCtx) + err = portal.Bridge.Bot.DeleteRoom(noCancelCtx, portal.MXID, false) + if err != nil { + log.Err(err).Msg("Failed to delete Matrix room") + return EventHandlingResultFailed.WithMSSError(err) + } + // No MSS here as the portal was deleted + return EventHandlingResultSuccess +} + +func (portal *Portal) handleMatrixMembership( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, + isStateRequest bool, +) EventHandlingResult { + if evt.StateKey == nil { + return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.MemberEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + prevContent := &event.MemberEventContent{Membership: event.MembershipLeave} + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.MemberEventContent) + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c. + Str("membership", string(content.Membership)). + Str("prev_membership", string(prevContent.Membership)). + Str("target_user_id", evt.GetStateKey()) + }) + api, ok := sender.Client.(MembershipHandlingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrMembershipNotSupported) + } + targetMXID := id.UserID(*evt.StateKey) + target, err := portal.getTargetUser(ctx, targetMXID) + if err != nil { + log.Err(err).Msg("Failed to get member event target") + return EventHandlingResultFailed.WithMSSError(err) + } + + membershipChangeType := MembershipChangeType{ + From: prevContent.Membership, + To: content.Membership, + IsSelf: evt.Sender == targetMXID, + } + if membershipChangeType.IsSelf && membershipChangeType.To == event.MembershipLeave { + sender.inPortalCache.Remove(portal.PortalKey) + if !portal.Bridge.Config.BridgeMatrixLeave { + log.Debug().Str("prev_membership", string(membershipChangeType.From)).Msg("Dropping leave event") + return EventHandlingResultIgnored + } + } + targetGhost, _ := target.(*Ghost) + membershipChange := &MatrixMembershipChange{ + MatrixRoomMeta: MatrixRoomMeta[*event.MemberEventContent]{ + MatrixEventBase: MatrixEventBase[*event.MemberEventContent]{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + IsStateRequest: isStateRequest, + PrevContent: prevContent, + }, + Target: target, + Type: membershipChangeType, + } + res, err := api.HandleMatrixMembership(ctx, membershipChange) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix membership change") + return EventHandlingResultFailed.WithMSSError(err) + } + didRedirectInvite := membershipChangeType == Invite && + targetGhost != nil && + res != nil && + res.RedirectTo != "" && + res.RedirectTo != targetGhost.ID + if didRedirectInvite { + log.Debug(). + Str("orig_id", string(targetGhost.ID)). + Str("redirect_id", string(res.RedirectTo)). + Msg("Invite was redirected to different ghost") + var redirectGhost *Ghost + redirectGhost, err = portal.Bridge.GetGhostByID(ctx, res.RedirectTo) + if err != nil { + log.Err(err).Msg("Failed to get redirect target ghost") + return EventHandlingResultFailed.WithError(err) + } + if !isStateRequest { + portal.sendRoomMeta( + ctx, + sender.User.DoublePuppet(ctx), + time.UnixMilli(evt.Timestamp), + event.StateMember, + evt.GetStateKey(), + &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: fmt.Sprintf("Invite redirected to %s", res.RedirectTo), + }, + true, + nil, + ) + } + portal.sendRoomMeta( + ctx, + sender.User.DoublePuppet(ctx), + time.UnixMilli(evt.Timestamp), + event.StateMember, + redirectGhost.Intent.GetMXID().String(), + content, + false, + nil, + ) + } + return EventHandlingResultSuccess.WithMSS().WithSkipStateEcho(didRedirectInvite) +} + +func makePLChange(old, new int, newIsSet bool) *SinglePowerLevelChange { + if old == new { + return nil + } + return &SinglePowerLevelChange{OrigLevel: old, NewLevel: new, NewIsSet: newIsSet} +} + +func getUniqueKeys[Key comparable, Value any](maps ...map[Key]Value) map[Key]struct{} { + unique := make(map[Key]struct{}) + for _, m := range maps { + for k := range m { + unique[k] = struct{}{} + } + } + return unique +} + +func (portal *Portal) handleMatrixPowerLevels( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, + isStateRequest bool, +) EventHandlingResult { + if evt.StateKey == nil || *evt.StateKey != "" { + return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + if content.CreateEvent == nil { + ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if ok { + var err error + content.CreateEvent, err = ars.GetStateEvent(ctx, portal.MXID, event.StateCreate, "") + if err != nil { + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("failed to get create event for power levels: %w", err)) + } + } + } + api, ok := sender.Client.(PowerLevelHandlingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrPowerLevelsNotSupported) + } + prevContent := &event.PowerLevelsEventContent{} + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.PowerLevelsEventContent) + prevContent.CreateEvent = content.CreateEvent + } + + plChange := &MatrixPowerLevelChange{ + MatrixRoomMeta: MatrixRoomMeta[*event.PowerLevelsEventContent]{ + MatrixEventBase: MatrixEventBase[*event.PowerLevelsEventContent]{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + IsStateRequest: isStateRequest, + PrevContent: prevContent, + }, + Users: make(map[id.UserID]*UserPowerLevelChange), + Events: make(map[string]*SinglePowerLevelChange), + UsersDefault: makePLChange(prevContent.UsersDefault, content.UsersDefault, true), + EventsDefault: makePLChange(prevContent.EventsDefault, content.EventsDefault, true), + StateDefault: makePLChange(prevContent.StateDefault(), content.StateDefault(), content.StateDefaultPtr != nil), + Invite: makePLChange(prevContent.Invite(), content.Invite(), content.InvitePtr != nil), + Kick: makePLChange(prevContent.Kick(), content.Kick(), content.KickPtr != nil), + Ban: makePLChange(prevContent.Ban(), content.Ban(), content.BanPtr != nil), + Redact: makePLChange(prevContent.Redact(), content.Redact(), content.RedactPtr != nil), + } + for eventType := range getUniqueKeys(content.Events, prevContent.Events) { + newLevel, hasNewLevel := content.Events[eventType] + if !hasNewLevel { + // TODO this doesn't handle state events properly + newLevel = content.EventsDefault + } + if change := makePLChange(prevContent.Events[eventType], newLevel, hasNewLevel); change != nil { + plChange.Events[eventType] = change + } + } + for user := range getUniqueKeys(content.Users, prevContent.Users) { + _, hasNewLevel := content.Users[user] + change := makePLChange(prevContent.GetUserLevel(user), content.GetUserLevel(user), hasNewLevel) + if change == nil { + continue + } + target, err := portal.getTargetUser(ctx, user) + if err != nil { + log.Err(err).Stringer("target_user_id", user).Msg("Failed to get user for power level change") + } else { + plChange.Users[user] = &UserPowerLevelChange{ + Target: target, + SinglePowerLevelChange: *change, + } + } + } + _, err := api.HandleMatrixPowerLevels(ctx, plChange) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix power level change") + return EventHandlingResultFailed.WithMSSError(err) + } + return EventHandlingResultSuccess.WithMSS() +} + +func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { + if evt.StateKey == nil || *evt.StateKey != "" || portal.MXID != evt.RoomID { + return EventHandlingResultIgnored + } + log := *zerolog.Ctx(ctx) + sentByBridge := evt.Sender == portal.Bridge.Bot.GetMXID() || portal.Bridge.IsGhostMXID(evt.Sender) + var senderUser *User + var err error + if !sentByBridge { + senderUser, err = portal.Bridge.GetUserByMXID(ctx, evt.Sender) + if err != nil { + log.Err(err).Msg("Failed to get tombstone sender user") + return EventHandlingResultFailed.WithError(err) + } + } + content, ok := evt.Content.Parsed.(*event.TombstoneEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + log = log.With(). + Stringer("replacement_room", content.ReplacementRoom). + Logger() + if content.ReplacementRoom == "" { + log.Info().Msg("Received tombstone with no replacement room, cleaning up portal") + err := portal.RemoveMXID(ctx) + if err != nil { + log.Err(err).Msg("Failed to remove portal MXID") + return EventHandlingResultFailed.WithMSSError(err) + } + err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true) + if err != nil { + log.Err(err).Msg("Failed to clean up Matrix room") + return EventHandlingResultFailed.WithError(err) + } + return EventHandlingResultSuccess + } + existingMemberEvt, err := portal.Bridge.Matrix.GetMemberInfo(ctx, content.ReplacementRoom, portal.Bridge.Bot.GetMXID()) + if err != nil { + log.Err(err).Msg("Failed to get member info of bot in replacement room") + return EventHandlingResultFailed.WithError(err) + } + leaveOnError := func() { + if existingMemberEvt != nil && existingMemberEvt.Membership == event.MembershipJoin { + return + } + log.Debug().Msg("Leaving replacement room with bot after tombstone validation failed") + _, err = portal.Bridge.Bot.SendState( + ctx, + content.ReplacementRoom, + event.StateMember, + portal.Bridge.Bot.GetMXID().String(), + &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: fmt.Sprintf("Failed to validate tombstone sent by %s from %s", evt.Sender, evt.RoomID), + }, + }, + time.Time{}, + ) + if err != nil { + log.Err(err).Msg("Failed to leave replacement room after tombstone validation failed") + } + } + var via []string + if senderHS := evt.Sender.Homeserver(); senderHS != "" { + via = []string{senderHS} + } + err = portal.Bridge.Bot.EnsureJoined(ctx, content.ReplacementRoom, EnsureJoinedParams{Via: via}) + if err != nil { + log.Err(err).Msg("Failed to join replacement room from tombstone") + return EventHandlingResultFailed.WithError(err) + } + if !sentByBridge && !senderUser.Permissions.Admin { + powers, err := portal.Bridge.Matrix.GetPowerLevels(ctx, content.ReplacementRoom) + if err != nil { + log.Err(err).Msg("Failed to get power levels in replacement room") + leaveOnError() + return EventHandlingResultFailed.WithError(err) + } + if powers.GetUserLevel(evt.Sender) < powers.Invite() { + log.Warn().Msg("Tombstone sender doesn't have enough power to invite the bot to the replacement room") + leaveOnError() + return EventHandlingResultIgnored + } + } + err = portal.UpdateMatrixRoomID(ctx, content.ReplacementRoom, UpdateMatrixRoomIDParams{ + DeleteOldRoom: true, + FetchInfoVia: senderUser, + }) + if errors.Is(err, ErrTargetRoomIsPortal) { + return EventHandlingResultIgnored + } else if err != nil { + return EventHandlingResultFailed.WithError(err) + } + return EventHandlingResultSuccess +} + +var ErrTargetRoomIsPortal = errors.New("target room is already a portal") +var ErrRoomAlreadyExists = errors.New("this portal already has a room") + +type UpdateMatrixRoomIDParams struct { + SyncDBMetadata func() + FailIfMXIDSet bool + OverwriteOldPortal bool + TombstoneOldRoom bool + DeleteOldRoom bool + + RoomCreateAlreadyLocked bool + + FetchInfoVia *User + ChatInfo *ChatInfo + ChatInfoSource *UserLogin +} + +func (portal *Portal) UpdateMatrixRoomID( + ctx context.Context, + newRoomID id.RoomID, + params UpdateMatrixRoomIDParams, +) error { + if !params.RoomCreateAlreadyLocked { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + } + oldRoom := portal.MXID + if oldRoom == newRoomID { + return nil + } else if oldRoom != "" && params.FailIfMXIDSet { + return ErrRoomAlreadyExists + } + err := portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to update in_space flag for user portals before updating portal MXID") + } + portal.removeInPortalCache(ctx) + log := zerolog.Ctx(ctx) + portal.Bridge.cacheLock.Lock() + // Wrap unlock in a sync.OnceFunc because we want to both defer it to catch early returns + // and unlock it before return if nothing goes wrong. + unlockCacheLock := sync.OnceFunc(portal.Bridge.cacheLock.Unlock) + defer unlockCacheLock() + if existingPortal, alreadyExists := portal.Bridge.portalsByMXID[newRoomID]; alreadyExists && !params.OverwriteOldPortal { + log.Warn().Msg("Replacement room is already a portal, ignoring") + return ErrTargetRoomIsPortal + } else if alreadyExists { + log.Debug().Msg("Replacement room is already a portal, overwriting") + err = existingPortal.removeMXID(ctx, true) + if err != nil { + return fmt.Errorf("failed to clear mxid of existing portal: %w", err) + } + } + portal.MXID = newRoomID + portal.RoomCreated.Set() + portal.Bridge.portalsByMXID[portal.MXID] = portal + if oldRoom != "" && portal.Bridge.portalsByMXID[oldRoom] == portal { + delete(portal.Bridge.portalsByMXID, oldRoom) + } + portal.NameSet = false + portal.AvatarSet = false + portal.TopicSet = false + portal.InSpace = false + portal.CapState = database.CapabilityState{} + portal.lastCapUpdate = time.Time{} + if params.SyncDBMetadata != nil { + params.SyncDBMetadata() + } + unlockCacheLock() + portal.updateLogger() + + err = portal.Save(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal in UpdateMatrixRoomID") + return err + } + log.Info().Msg("Successfully followed tombstone and updated portal MXID") + err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to update in_space flag for user portals after updating portal MXID") + } + go portal.addToUserSpaces(ctx) + if params.FetchInfoVia != nil { + go portal.updateInfoAfterTombstone(ctx, params.FetchInfoVia) + } else if params.ChatInfo != nil { + go portal.UpdateInfo(ctx, params.ChatInfo, params.ChatInfoSource, nil, time.Time{}) + } else if params.ChatInfoSource != nil { + portal.UpdateCapabilities(ctx, params.ChatInfoSource, true) + portal.UpdateBridgeInfo(ctx) + } + go func() { + // TODO this might become unnecessary if UpdateInfo starts taking care of it + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ + Parsed: &event.ElementFunctionalMembersContent{ + ServiceMembers: []id.UserID{portal.Bridge.Bot.GetMXID()}, + }, + }, time.Time{}) + if err != nil { + if err != nil { + log.Warn().Err(err).Msg("Failed to set service members in new room") + } + } + }() + if params.TombstoneOldRoom && oldRoom != "" { + _, err = portal.Bridge.Bot.SendState(ctx, oldRoom, event.StateTombstone, "", &event.Content{ + Parsed: &event.TombstoneEventContent{ + Body: "Room has been replaced.", + ReplacementRoom: newRoomID, + }, + }, time.Now()) + if err != nil { + log.Err(err).Msg("Failed to send tombstone event to old room") + } + } + if params.DeleteOldRoom && oldRoom != "" { + go func() { + err = portal.Bridge.Bot.DeleteRoom(ctx, oldRoom, true) + if err != nil { + log.Err(err).Msg("Failed to clean up old Matrix room after updating portal MXID") + } + }() + } + return nil +} + +func (portal *Portal) updateInfoAfterTombstone(ctx context.Context, senderUser *User) { + log := zerolog.Ctx(ctx) + logins, err := portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get user logins in portal to sync info") + return + } + var preferredLogin *UserLogin + for _, login := range logins { + if !login.Client.IsLoggedIn() { + continue + } else if preferredLogin == nil { + preferredLogin = login + } else if senderUser != nil && login.User == senderUser { + preferredLogin = login + } + } + if preferredLogin == nil { + log.Warn().Msg("No logins found to sync info") + return + } + info, err := preferredLogin.Client.GetChatInfo(ctx, portal) + if err != nil { + log.Err(err).Msg("Failed to get chat info") + return + } + log.Info(). + Str("info_source_login", string(preferredLogin.ID)). + Msg("Fetched info to update portal after tombstone") + portal.UpdateInfo(ctx, info, preferredLogin, nil, time.Time{}) +} + +func (portal *Portal) handleMatrixRedaction( + ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, +) EventHandlingResult { + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.RedactionEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + if evt.Redacts != "" && content.Redacts != evt.Redacts { + content.Redacts = evt.Redacts + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("redaction_target_mxid", content.Redacts) + }) + deletingAPI, deleteOK := sender.Client.(RedactionHandlingNetworkAPI) + reactingAPI, reactOK := sender.Client.(ReactionHandlingNetworkAPI) + if !deleteOK && !reactOK { + log.Debug().Msg("Ignoring redaction without checking target as network connector doesn't implement RedactionHandlingNetworkAPI nor ReactionHandlingNetworkAPI") + return EventHandlingResultIgnored.WithMSSError(ErrRedactionsNotSupported) + } + var redactionTargetReaction *database.Reaction + redactionTargetMsg, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.Redacts) + if err != nil { + log.Err(err).Msg("Failed to get redaction target message from database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get redaction target message: %w", ErrDatabaseError, err)) + } else if redactionTargetMsg != nil { + if !deleteOK { + log.Debug().Msg("Ignoring message redaction event as network connector doesn't implement RedactionHandlingNetworkAPI") + return EventHandlingResultIgnored.WithMSSError(ErrRedactionsNotSupported) + } + err = deletingAPI.HandleMatrixMessageRemove(ctx, &MatrixMessageRemove{ + MatrixEventBase: MatrixEventBase[*event.RedactionEventContent]{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + TargetMessage: redactionTargetMsg, + }) + } else if redactionTargetReaction, err = portal.Bridge.DB.Reaction.GetByMXID(ctx, content.Redacts); err != nil { + log.Err(err).Msg("Failed to get redaction target reaction from database") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get redaction target message reaction: %w", ErrDatabaseError, err)) + } else if redactionTargetReaction != nil { + if !reactOK { + log.Debug().Msg("Ignoring reaction redaction event as network connector doesn't implement ReactionHandlingNetworkAPI") + return EventHandlingResultIgnored.WithMSSError(ErrReactionsNotSupported) + } + // TODO ignore if sender doesn't match? + err = reactingAPI.HandleMatrixReactionRemove(ctx, &MatrixReactionRemove{ + MatrixEventBase: MatrixEventBase[*event.RedactionEventContent]{ + Event: evt, + Content: content, + Portal: portal, + OrigSender: origSender, + + InputTransactionID: portal.parseInputTransactionID(origSender, evt), + }, + TargetReaction: redactionTargetReaction, + }) + } else { + log.Debug().Msg("Redaction target message not found in database") + return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("redaction %w", ErrTargetMessageNotFound)) + } + if err != nil { + log.Err(err).Msg("Failed to handle Matrix redaction") + return EventHandlingResultFailed.WithMSSError(err) + } + if redactionTargetMsg != nil { + err = portal.Bridge.DB.Message.Delete(ctx, redactionTargetMsg.RowID) + } else if redactionTargetReaction != nil { + err = portal.Bridge.DB.Reaction.Delete(ctx, redactionTargetReaction) + } + if err != nil { + log.Err(err).Msg("Failed to delete redacted event from database") + } + return EventHandlingResultSuccess.WithMSS() +} + +func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { + log := zerolog.Ctx(ctx) + if portal.MXID == "" { + mcp, ok := evt.(RemoteEventThatMayCreatePortal) + if !ok || !mcp.ShouldCreatePortal() || !portal.Bridge.Config.PortalCreateFilter.ShouldAllow(source.ID, portal.PortalKey) { + log.Debug().Msg("Dropping event as portal doesn't exist") + return EventHandlingResultIgnored + } + infoProvider, ok := mcp.(RemoteChatResyncWithInfo) + var info *ChatInfo + var err error + if ok { + info, err = infoProvider.GetChatInfo(ctx, portal) + if err != nil { + log.Err(err).Msg("Failed to get chat info for portal creation from chat resync event") + } + } + bundleProvider, ok := evt.(RemoteChatResyncBackfillBundle) + var bundle any + if ok { + bundle = bundleProvider.GetBundledBackfillData() + } + err = portal.createMatrixRoomInLoop(ctx, source, info, bundle) + if err != nil { + log.Err(err).Msg("Failed to create portal to handle event") + return EventHandlingResultFailed.WithError(err) + } + if evtType == RemoteEventChatResync { + log.Debug().Msg("Not handling chat resync event further as portal was created by it") + postHandler, ok := evt.(RemotePostHandler) + if ok { + postHandler.PostHandle(ctx, portal) + } + return EventHandlingResultSuccess + } + } + preHandler, ok := evt.(RemotePreHandler) + if ok { + preHandler.PreHandle(ctx, portal) + } + log.Debug().Msg("Handling remote event") + switch evtType { + case RemoteEventUnknown: + log.Debug().Msg("Ignoring remote event with type unknown") + res = EventHandlingResultIgnored + case RemoteEventMessage, RemoteEventMessageUpsert: + res = portal.handleRemoteMessage(ctx, source, evt.(RemoteMessage)) + case RemoteEventEdit: + res = portal.handleRemoteEdit(ctx, source, evt.(RemoteEdit)) + case RemoteEventReaction: + res = portal.handleRemoteReaction(ctx, source, evt.(RemoteReaction)) + case RemoteEventReactionRemove: + res = portal.handleRemoteReactionRemove(ctx, source, evt.(RemoteReactionRemove)) + case RemoteEventReactionSync: + res = portal.handleRemoteReactionSync(ctx, source, evt.(RemoteReactionSync)) + case RemoteEventMessageRemove: + res = portal.handleRemoteMessageRemove(ctx, source, evt.(RemoteMessageRemove)) + case RemoteEventReadReceipt: + res = portal.handleRemoteReadReceipt(ctx, source, evt.(RemoteReadReceipt)) + case RemoteEventMarkUnread: + res = portal.handleRemoteMarkUnread(ctx, source, evt.(RemoteMarkUnread)) + case RemoteEventDeliveryReceipt: + res = portal.handleRemoteDeliveryReceipt(ctx, source, evt.(RemoteDeliveryReceipt)) + case RemoteEventTyping: + res = portal.handleRemoteTyping(ctx, source, evt.(RemoteTyping)) + case RemoteEventChatInfoChange: + res = portal.handleRemoteChatInfoChange(ctx, source, evt.(RemoteChatInfoChange)) + case RemoteEventChatResync: + res = portal.handleRemoteChatResync(ctx, source, evt.(RemoteChatResync)) + case RemoteEventChatDelete: + res = portal.handleRemoteChatDelete(ctx, source, evt.(RemoteChatDelete)) + case RemoteEventBackfill: + res = portal.HandleRemoteBackfill(ctx, source, evt.(RemoteBackfill)) + default: + log.Warn().Msg("Got remote event with unknown type") + } + postHandler, ok := evt.(RemotePostHandler) + if ok { + postHandler.PostHandle(ctx, portal) + } + return +} + +func (portal *Portal) ensureFunctionalMember(ctx context.Context, ghost *Ghost) { + if !ghost.IsBot || portal.RoomType != database.RoomTypeDM || portal.OtherUserID == ghost.ID || portal.MXID == "" { + return + } + ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if !ok { + return + } + portal.functionalMembersLock.Lock() + defer portal.functionalMembersLock.Unlock() + var functionalMembers *event.ElementFunctionalMembersContent + if portal.functionalMembersCache != nil { + functionalMembers = portal.functionalMembersCache + } else { + evt, err := ars.GetStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "") + if err != nil && !errors.Is(err, mautrix.MNotFound) { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get functional members state event") + return + } + functionalMembers = &event.ElementFunctionalMembersContent{} + if evt != nil { + evtContent, ok := evt.Content.Parsed.(*event.ElementFunctionalMembersContent) + if ok && evtContent != nil { + functionalMembers = evtContent + } + } + } + // TODO what about non-double-puppeted user ghosts? + functionalMembers.Add(portal.Bridge.Bot.GetMXID()) + if functionalMembers.Add(ghost.Intent.GetMXID()) { + _, err := portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ + Parsed: functionalMembers, + }, time.Time{}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to update functional members state event") + return + } + } +} + +func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { + var ghost *Ghost + if !sender.IsFromMe && sender.ForceDMUser && portal.OtherUserID != "" && sender.Sender != portal.OtherUserID { + zerolog.Ctx(ctx).Warn(). + Str("original_id", string(sender.Sender)). + Str("default_other_user", string(portal.OtherUserID)). + Msg("Overriding event sender with primary other user in DM portal") + // Ensure the ghost row exists anyway to prevent foreign key errors when saving messages + // TODO it'd probably be better to override the sender in the saved message, but that's more effort + _, err = portal.Bridge.GetGhostByID(ctx, sender.Sender) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get ghost with original user ID") + return + } + sender.Sender = portal.OtherUserID + } + if sender.Sender != "" { + ghost, err = portal.Bridge.GetGhostByID(ctx, sender.Sender) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for message sender") + return + } + ghost.UpdateInfoIfNecessary(ctx, source, evtType) + portal.ensureFunctionalMember(ctx, ghost) + } + if sender.IsFromMe { + intent = source.User.DoublePuppet(ctx) + if intent != nil { + return + } + extraUserID = source.UserMXID + } else if sender.SenderLogin != "" && portal.Receiver == "" { + senderLogin := portal.Bridge.GetCachedUserLoginByID(sender.SenderLogin) + if senderLogin != nil { + intent = senderLogin.User.DoublePuppet(ctx) + if intent != nil { + return + } + extraUserID = senderLogin.UserMXID + } + } + if sender.Sender != "" && portal.Receiver == "" && otherLogins != nil { + for _, login := range otherLogins { + if login.Client.IsThisUser(ctx, sender.Sender) { + intent = login.User.DoublePuppet(ctx) + if intent != nil { + return + } + extraUserID = login.UserMXID + } + } + } + if ghost != nil { + intent = ghost.Intent + } + return +} + +func (portal *Portal) GetIntentFor(ctx context.Context, sender EventSender, source *UserLogin, evtType RemoteEventType) (MatrixAPI, bool) { + intent, _, err := portal.getIntentAndUserMXIDFor(ctx, sender, source, nil, evtType) + if err != nil { + return nil, false + } + if intent == nil { + // TODO this is very hacky - we should either insert an empty ghost row automatically + // (and not fetch it at runtime) or make the message sender column nullable. + portal.Bridge.GetGhostByID(ctx, "") + intent = portal.Bridge.Bot + if intent == nil { + panic(fmt.Errorf("bridge bot is nil")) + } + } + return intent, true +} + +func (portal *Portal) getRelationMeta( + ctx context.Context, + currentMsgID networkid.MessageID, + currentMsg *ConvertedMessage, + isBatchSend bool, +) (replyTo, threadRoot, prevThreadEvent *database.Message) { + log := zerolog.Ctx(ctx) + var err error + if currentMsg.ReplyTo != nil { + replyTo, err = portal.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, portal.Receiver, *currentMsg.ReplyTo) + if err != nil { + log.Err(err).Msg("Failed to get reply target message from database") + } else if replyTo == nil { + if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { + // This is somewhat evil + replyTo = &database.Message{ + MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, currentMsg.ReplyTo.MessageID, ptr.Val(currentMsg.ReplyTo.PartID)), + Room: currentMsg.ReplyToRoom, + SenderID: currentMsg.ReplyToUser, + } + if currentMsg.ReplyToLogin != "" && (portal.Receiver == "" || portal.Receiver == currentMsg.ReplyToLogin) { + userLogin, err := portal.Bridge.GetExistingUserLoginByID(ctx, currentMsg.ReplyToLogin) + if err != nil { + log.Err(err). + Str("reply_to_login", string(currentMsg.ReplyToLogin)). + Msg("Failed to get reply target user login") + } else if userLogin != nil { + replyTo.SenderMXID = userLogin.UserMXID + } + } else { + ghost, err := portal.Bridge.GetGhostByID(ctx, currentMsg.ReplyToUser) + if err != nil { + log.Err(err). + Str("reply_to_user_id", string(currentMsg.ReplyToUser)). + Msg("Failed to get reply target ghost") + } else { + replyTo.SenderMXID = ghost.Intent.GetMXID() + } + } + } else { + log.Warn().Any("reply_to", *currentMsg.ReplyTo).Msg("Reply target message not found in database") + } + } + } + if currentMsg.ThreadRoot != nil && *currentMsg.ThreadRoot != currentMsgID { + threadRoot, err = portal.Bridge.DB.Message.GetFirstThreadMessage(ctx, portal.PortalKey, *currentMsg.ThreadRoot) + if err != nil { + log.Err(err).Msg("Failed to get thread root message from database") + } else if threadRoot == nil { + if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { + threadRoot = &database.Message{ + MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, *currentMsg.ThreadRoot, ""), + } + } else { + log.Warn().Str("thread_root", string(*currentMsg.ThreadRoot)).Msg("Thread root message not found in database") + } + } else if prevThreadEvent, err = portal.Bridge.DB.Message.GetLastThreadMessage(ctx, portal.PortalKey, *currentMsg.ThreadRoot); err != nil { + log.Err(err).Msg("Failed to get last thread message from database") + } + if prevThreadEvent == nil { + prevThreadEvent = ptr.Clone(threadRoot) + } + } + return +} + +func (portal *Portal) applyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { + if content.Mentions == nil { + content.Mentions = &event.Mentions{} + } + if threadRoot != nil && prevThreadEvent != nil { + content.GetRelatesTo().SetThread(threadRoot.MXID, prevThreadEvent.MXID) + } + if replyTo != nil { + crossRoom := !replyTo.Room.IsEmpty() && replyTo.Room != portal.PortalKey + if !crossRoom || portal.Bridge.Config.CrossRoomReplies { + content.GetRelatesTo().SetReplyTo(replyTo.MXID) + } + if crossRoom && portal.Bridge.Config.CrossRoomReplies { + targetPortal, err := portal.Bridge.GetExistingPortalByKey(ctx, replyTo.Room) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Object("target_portal_key", replyTo.Room). + Msg("Failed to get cross-room reply portal") + } else if targetPortal == nil || targetPortal.MXID == "" { + zerolog.Ctx(ctx).Warn(). + Object("target_portal_key", replyTo.Room). + Msg("Cross-room reply portal not found") + } else { + content.RelatesTo.InReplyTo.UnstableRoomID = targetPortal.MXID + } + } + content.Mentions.Add(replyTo.SenderMXID) + } +} + +func (portal *Portal) sendConvertedMessage( + ctx context.Context, + id networkid.MessageID, + intent MatrixAPI, + senderID networkid.UserID, + converted *ConvertedMessage, + ts time.Time, + streamOrder int64, + logContext func(*zerolog.Event) *zerolog.Event, +) ([]*database.Message, EventHandlingResult) { + if logContext == nil { + logContext = func(e *zerolog.Event) *zerolog.Event { + return e + } + } + log := zerolog.Ctx(ctx) + replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta( + ctx, id, converted, false, + ) + output := make([]*database.Message, 0, len(converted.Parts)) + var errorList []error + for i, part := range converted.Parts { + if ctx.Err() != nil { + errorList = append(errorList, ctx.Err()) + break + } + portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) + part.Content.BeeperDisappearingTimer = converted.Disappear.ToEventContent() + dbMessage := &database.Message{ + ID: id, + PartID: part.ID, + Room: portal.PortalKey, + SenderID: senderID, + SenderMXID: intent.GetMXID(), + Timestamp: ts, + ThreadRoot: ptr.Val(converted.ThreadRoot), + ReplyTo: ptr.Val(converted.ReplyTo), + Metadata: part.DBMetadata, + IsDoublePuppeted: intent.IsDoublePuppet(), + } + if part.DontBridge { + dbMessage.SetFakeMXID() + logContext(log.Debug()). + Stringer("event_id", dbMessage.MXID). + Str("part_id", string(part.ID)). + Msg("Not bridging message part with DontBridge flag to Matrix") + } else { + resp, err := intent.SendMessage(ctx, portal.MXID, part.Type, &event.Content{ + Parsed: part.Content, + Raw: part.Extra, + }, &MatrixSendExtra{ + Timestamp: ts, + MessageMeta: dbMessage, + StreamOrder: streamOrder, + PartIndex: i, + }) + if err != nil { + logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to send message part to Matrix") + errorList = append(errorList, fmt.Errorf("failed to send message part to Matrix: %w", err)) + continue + } + logContext(log.Debug()). + Stringer("event_id", resp.EventID). + Str("part_id", string(part.ID)). + Msg("Sent message part to Matrix") + dbMessage.MXID = resp.EventID + } + err := portal.Bridge.DB.Message.Insert(ctx, dbMessage) + if err != nil { + logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to save message part to database") + errorList = append(errorList, fmt.Errorf("%w: failed to save message part to database: %w", ErrDatabaseError, err)) + } + if converted.Disappear.Type != event.DisappearingTypeNone && !dbMessage.HasFakeMXID() { + if converted.Disappear.Type == event.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() { + converted.Disappear.DisappearAt = dbMessage.Timestamp.Add(converted.Disappear.Timer) + } + portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ + RoomID: portal.MXID, + EventID: dbMessage.MXID, + Timestamp: dbMessage.Timestamp, + DisappearingSetting: converted.Disappear, + }) + } + if prevThreadEvent != nil && !dbMessage.HasFakeMXID() { + prevThreadEvent = dbMessage + } + output = append(output, dbMessage) + } + if len(errorList) > 0 { + return output, EventHandlingResultFailed.WithError(errors.Join(errorList...)) + } + return output, EventHandlingResultSuccess +} + +func (portal *Portal) checkPendingMessage(ctx context.Context, evt RemoteMessage) (bool, *database.Message) { + evtWithTxn, ok := evt.(RemoteMessageWithTransactionID) + if !ok { + return false, nil + } + txnID := evtWithTxn.GetTransactionID() + if txnID == "" { + return false, nil + } + portal.outgoingMessagesLock.Lock() + defer portal.outgoingMessagesLock.Unlock() + pending, ok := portal.outgoingMessages[txnID] + if !ok { + return false, nil + } else if pending.ignore { + return true, nil + } + delete(portal.outgoingMessages, txnID) + pending.db.ID = evt.GetID() + if pending.db.SenderID == "" { + pending.db.SenderID = evt.GetSender().Sender + } + evtWithTimestamp, ok := evt.(RemoteEventWithTimestamp) + if ok { + ts := evtWithTimestamp.GetTimestamp() + if !ts.IsZero() { + pending.db.Timestamp = ts + } + } + var statusErr error + saveMessage := true + if pending.handle != nil { + saveMessage, statusErr = pending.handle(evt, pending.db) + } + if saveMessage { + if portal.Bridge.Config.OutgoingMessageReID { + pending.db.MXID = portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, pending.db.ID, pending.db.PartID) + } + // Hack to ensure the ghost row exists + // TODO move to better place (like login) + portal.Bridge.GetGhostByID(ctx, pending.db.SenderID) + err := portal.Bridge.DB.Message.Insert(ctx, pending.db) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save message to database after receiving remote echo") + } + } + if !errors.Is(statusErr, ErrNoStatus) { + if statusErr != nil { + portal.sendErrorStatus(ctx, pending.evt, statusErr) + } else { + portal.sendSuccessStatus(ctx, pending.evt, getStreamOrder(evt), pending.evt.ID) + } + } + zerolog.Ctx(ctx).Debug().Stringer("event_id", pending.evt.ID).Msg("Received remote echo for message") + return true, pending.db +} + +func (portal *Portal) handleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) { + log := zerolog.Ctx(ctx) + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageUpsert) + if !ok { + return + } + res, err := evt.HandleExisting(ctx, portal, intent, existing) + if err != nil { + log.Err(err).Msg("Failed to handle existing message in upsert event after receiving remote echo") + } else { + handleRes = EventHandlingResultSuccess + } + if res.SaveParts { + for _, part := range existing { + err = portal.Bridge.DB.Message.Update(ctx, part) + if err != nil { + log.Err(err).Str("part_id", string(part.PartID)).Msg("Failed to update message part in database") + handleRes = EventHandlingResultFailed.WithError(err) + } + } + } + if len(res.SubEvents) > 0 { + for _, subEvt := range res.SubEvents { + subType := subEvt.GetType() + log := portal.Log.With(). + Str("source_id", string(source.ID)). + Str("action", "handle remote subevent"). + Stringer("bridge_evt_type", subType). + Logger() + subRes := portal.handleRemoteEvent(log.WithContext(ctx), source, subType, subEvt) + if !subRes.Success { + handleRes.Success = false + } + } + } + continueHandling = res.ContinueMessageHandling + return +} + +func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) { + log := zerolog.Ctx(ctx) + upsertEvt, isUpsert := evt.(RemoteMessageUpsert) + isUpsert = isUpsert && evt.GetType() == RemoteEventMessageUpsert + if wasPending, dbMessage := portal.checkPendingMessage(ctx, evt); wasPending { + if isUpsert && dbMessage != nil { + res, _ = portal.handleRemoteUpsert(ctx, source, upsertEvt, []*database.Message{dbMessage}) + } else { + res = EventHandlingResultIgnored + } + return + } + existing, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, evt.GetID()) + if err != nil { + log.Err(err).Msg("Failed to check if message is a duplicate") + } else if len(existing) > 0 { + if isUpsert { + var continueHandling bool + res, continueHandling = portal.handleRemoteUpsert(ctx, source, upsertEvt, existing) + if continueHandling { + log.Debug().Msg("Upsert handler said to continue message handling normally") + } else { + return res + } + } else { + log.Debug().Stringer("existing_mxid", existing[0].MXID).Msg("Ignoring duplicate message") + return EventHandlingResultIgnored + } + } + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessage) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + ts := getEventTS(evt) + converted, err := evt.ConvertMessage(ctx, portal, intent) + if err != nil { + if errors.Is(err, ErrIgnoringRemoteEvent) { + log.Debug().Err(err).Msg("Remote message handling was cancelled by convert function") + return EventHandlingResultIgnored + } else { + log.Err(err).Msg("Failed to convert remote message") + portal.sendRemoteErrorNotice(ctx, intent, err, ts, "message") + return EventHandlingResultFailed.WithError(err) + } + } + _, res = portal.sendConvertedMessage(ctx, evt.GetID(), intent, evt.GetSender().Sender, converted, ts, getStreamOrder(evt), nil) + if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { + err = intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + if err != nil { + log.Warn().Err(err).Msg("Failed to send stop typing event after bridging message") + } + } + return +} + +func (portal *Portal) sendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { + resp, sendErr := intent.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: fmt.Sprintf("An error occurred while processing an incoming %s", evtTypeName), + Mentions: &event.Mentions{}, + }, + Raw: map[string]any{ + "fi.mau.bridge.internal_error": err.Error(), + }, + }, &MatrixSendExtra{ + Timestamp: ts, + }) + if sendErr != nil { + zerolog.Ctx(ctx).Err(sendErr).Msg("Failed to send error notice after remote event handling failed") + } else { + zerolog.Ctx(ctx).Debug().Stringer("event_id", resp.EventID).Msg("Sent error notice after remote event handling failed") + } +} + +func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult { + log := zerolog.Ctx(ctx) + var existing []*database.Message + if bundledEvt, ok := evt.(RemoteEventWithBundledParts); ok { + existing = bundledEvt.GetTargetDBMessage() + } + if existing == nil { + targetID := evt.GetTargetMessage() + var err error + existing, err = portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, targetID) + if err != nil { + log.Err(err).Msg("Failed to get edit target message") + return EventHandlingResultFailed.WithError(err) + } + } + if existing == nil { + log.Warn().Msg("Edit target message not found") + return EventHandlingResultIgnored + } + var intent MatrixAPI + if evt.GetSender().ForceEditOrigSender { + var err error + intent, err = portal.getIntentForMXID(ctx, existing[0].SenderMXID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get intent for edit message sender") + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } else if intent == nil { + zerolog.Ctx(ctx).Debug().Msg("Intent not found for edit message") + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + } else { + var ok bool + intent, ok = portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventEdit) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } else if intent.GetMXID() != existing[0].SenderMXID { + log.Warn(). + Stringer("edit_sender_mxid", intent.GetMXID()). + Stringer("original_sender_mxid", existing[0].SenderMXID). + Msg("Not bridging edit: sender doesn't match original message sender") + return EventHandlingResultIgnored + } + } + ts := getEventTS(evt) + converted, err := evt.ConvertEdit(ctx, portal, intent, existing) + if errors.Is(err, ErrIgnoringRemoteEvent) { + log.Debug().Err(err).Msg("Remote edit handling was cancelled by convert function") + return EventHandlingResultIgnored + } else if err != nil { + log.Err(err).Msg("Failed to convert remote edit") + portal.sendRemoteErrorNotice(ctx, intent, err, ts, "edit") + return EventHandlingResultFailed.WithError(err) + } + res := portal.sendConvertedEdit(ctx, existing[0].ID, evt.GetSender().Sender, converted, intent, ts, getStreamOrder(evt)) + if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { + err = intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) + if err != nil { + log.Warn().Err(err).Msg("Failed to send stop typing event after bridging edit") + } + } + return res +} + +func (portal *Portal) sendConvertedEdit( + ctx context.Context, + targetID networkid.MessageID, + senderID networkid.UserID, + converted *ConvertedEdit, + intent MatrixAPI, + ts time.Time, + streamOrder int64, +) EventHandlingResult { + log := zerolog.Ctx(ctx) + var errorList []error + for i, part := range converted.ModifiedParts { + if part.Content.Mentions == nil { + part.Content.Mentions = &event.Mentions{} + } + overrideMXID := true + if part.Part.Room != portal.PortalKey { + part.Part.Room = portal.PortalKey + } else if !part.Part.HasFakeMXID() { + part.Content.SetEdit(part.Part.MXID) + overrideMXID = false + if part.NewMentions != nil { + part.Content.Mentions = part.NewMentions + } else { + part.Content.Mentions = &event.Mentions{} + } + } + if part.TopLevelExtra == nil { + part.TopLevelExtra = make(map[string]any) + } + if part.Extra != nil { + part.TopLevelExtra["m.new_content"] = part.Extra + } + wrappedContent := &event.Content{ + Parsed: part.Content, + Raw: part.TopLevelExtra, + } + if !part.DontBridge { + resp, err := intent.SendMessage(ctx, portal.MXID, part.Type, wrappedContent, &MatrixSendExtra{ + Timestamp: ts, + MessageMeta: part.Part, + StreamOrder: streamOrder, + PartIndex: i, + }) + if err != nil { + log.Err(err).Stringer("part_mxid", part.Part.MXID).Msg("Failed to edit message part") + errorList = append(errorList, fmt.Errorf("failed to edit message part: %w", err)) + continue + } else { + log.Debug(). + Stringer("event_id", resp.EventID). + Str("part_id", string(part.Part.ID)). + Msg("Sent message part edit to Matrix") + if overrideMXID { + part.Part.MXID = resp.EventID + } + } + } + err := portal.Bridge.DB.Message.Update(ctx, part.Part) + if err != nil { + log.Err(err).Int64("part_rowid", part.Part.RowID).Msg("Failed to update message part in database") + errorList = append(errorList, fmt.Errorf("%w: failed to update message part in database: %w", ErrDatabaseError, err)) + } + } + for _, part := range converted.DeletedParts { + redactContent := &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: part.MXID, + }, + } + resp, err := intent.SendMessage(ctx, portal.MXID, event.EventRedaction, redactContent, &MatrixSendExtra{ + Timestamp: ts, + }) + if err != nil { + log.Err(err).Stringer("part_mxid", part.MXID).Msg("Failed to redact message part deleted in edit") + errorList = append(errorList, fmt.Errorf("failed to redact message part deleted in edit: %w", err)) + } else { + log.Debug(). + Stringer("redaction_event_id", resp.EventID). + Stringer("redacted_event_id", part.MXID). + Str("part_id", string(part.ID)). + Msg("Sent redaction of message part to Matrix") + } + err = portal.Bridge.DB.Message.Delete(ctx, part.RowID) + if err != nil { + log.Err(err).Int64("part_rowid", part.RowID).Msg("Failed to delete message part from database") + errorList = append(errorList, fmt.Errorf("%w: failed to delete message part from database: %w", ErrDatabaseError, err)) + } + } + if converted.AddedParts != nil { + _, res := portal.sendConvertedMessage(ctx, targetID, intent, senderID, converted.AddedParts, ts, streamOrder, nil) + if !res.Success { + errorList = append(errorList, res.Error) + } + } + if len(errorList) > 0 { + return EventHandlingResultFailed.WithError(errors.Join(errorList...)) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) getTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) { + if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { + return portal.Bridge.DB.Message.GetPartByID(ctx, portal.Receiver, evt.GetTargetMessage(), partTargeter.GetTargetMessagePart()) + } else { + return portal.Bridge.DB.Message.GetFirstPartByID(ctx, portal.Receiver, evt.GetTargetMessage()) + } +} + +func (portal *Portal) getTargetReaction(ctx context.Context, evt RemoteReactionRemove) (*database.Reaction, error) { + if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { + return portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, evt.GetTargetMessage(), partTargeter.GetTargetMessagePart(), evt.GetSender().Sender, evt.GetRemovedEmojiID()) + } else { + return portal.Bridge.DB.Reaction.GetByIDWithoutMessagePart(ctx, portal.Receiver, evt.GetTargetMessage(), evt.GetSender().Sender, evt.GetRemovedEmojiID()) + } +} + +func getEventTS(evt RemoteEvent) time.Time { + if tsProvider, ok := evt.(RemoteEventWithTimestamp); ok { + return tsProvider.GetTimestamp() + } + return time.Now() +} + +func getStreamOrder(evt RemoteEvent) int64 { + if streamProvider, ok := evt.(RemoteEventWithStreamOrder); ok { + return streamProvider.GetStreamOrder() + } + return 0 +} + +func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult { + log := zerolog.Ctx(ctx) + eventTS := getEventTS(evt) + targetMessage, err := portal.getTargetMessagePart(ctx, evt) + if err != nil { + log.Err(err).Msg("Failed to get target message for reaction") + return EventHandlingResultFailed.WithError(err) + } else if targetMessage == nil { + // TODO use deterministic event ID as target if applicable? + log.Warn().Msg("Target message for reaction not found") + return EventHandlingResultIgnored + } + var existingReactions []*database.Reaction + if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { + existingReactions, err = portal.Bridge.DB.Reaction.GetAllToMessagePart(ctx, portal.Receiver, evt.GetTargetMessage(), partTargeter.GetTargetMessagePart()) + } else { + existingReactions, err = portal.Bridge.DB.Reaction.GetAllToMessage(ctx, portal.Receiver, evt.GetTargetMessage()) + } + if err != nil { + log.Err(err).Msg("Failed to get existing reactions for reaction sync") + return EventHandlingResultFailed.WithError(err) + } + existing := make(map[networkid.UserID]map[networkid.EmojiID]*database.Reaction) + for _, existingReaction := range existingReactions { + if existing[existingReaction.SenderID] == nil { + existing[existingReaction.SenderID] = make(map[networkid.EmojiID]*database.Reaction) + } + existing[existingReaction.SenderID][existingReaction.EmojiID] = existingReaction + } + + doAddReaction := func(new *BackfillReaction, intent MatrixAPI) { + if intent == nil { + var ok bool + intent, ok = portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) + if !ok { + return + } + } + portal.sendConvertedReaction( + ctx, new.Sender.Sender, intent, targetMessage, new.EmojiID, new.Emoji, + new.Timestamp, new.DBMetadata, new.ExtraContent, + func(z *zerolog.Event) *zerolog.Event { + return z. + Any("reaction_sender_id", new.Sender). + Time("reaction_ts", new.Timestamp) + }, + ) + } + doRemoveReaction := func(old *database.Reaction, intent MatrixAPI, deleteRow bool) { + if intent == nil && old.SenderMXID != "" { + intent, err = portal.getIntentForMXID(ctx, old.SenderMXID) + if err != nil { + log.Err(err). + Stringer("reaction_sender_mxid", old.SenderMXID). + Msg("Failed to get intent for removing reaction") + } + } + if intent == nil { + log.Warn(). + Str("reaction_sender_id", string(old.SenderID)). + Stringer("reaction_sender_mxid", old.SenderMXID). + Msg("Didn't find intent for removing reaction, using bridge bot") + intent = portal.Bridge.Bot + } + _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: old.MXID, + }, + }, &MatrixSendExtra{Timestamp: eventTS}) + if err != nil { + log.Err(err).Msg("Failed to redact old reaction") + } + if deleteRow { + err = portal.Bridge.DB.Reaction.Delete(ctx, old) + if err != nil { + log.Err(err).Msg("Failed to delete old reaction row") + } + } + } + doOverwriteReaction := func(new *BackfillReaction, old *database.Reaction) { + intent, ok := portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) + if !ok { + return + } + doRemoveReaction(old, intent, false) + doAddReaction(new, intent) + } + + newData := evt.GetReactions() + for userID, reactions := range newData.Users { + existingUserReactions := existing[userID] + delete(existing, userID) + for _, reaction := range reactions.Reactions { + if reaction.Timestamp.IsZero() { + reaction.Timestamp = eventTS + } + existingReaction, ok := existingUserReactions[reaction.EmojiID] + if ok { + delete(existingUserReactions, reaction.EmojiID) + if reaction.EmojiID != "" || reaction.Emoji == existingReaction.Emoji { + continue + } + doOverwriteReaction(reaction, existingReaction) + } else { + doAddReaction(reaction, nil) + } + } + totalReactionCount := len(existingUserReactions) + len(reactions.Reactions) + if reactions.HasAllReactions { + for _, existingReaction := range existingUserReactions { + doRemoveReaction(existingReaction, nil, true) + } + } else if reactions.MaxCount > 0 && totalReactionCount > reactions.MaxCount { + remainingReactionList := maps.Values(existingUserReactions) + slices.SortFunc(remainingReactionList, func(a, b *database.Reaction) int { + diff := a.Timestamp.Compare(b.Timestamp) + if diff == 0 { + return cmp.Compare(a.EmojiID, b.EmojiID) + } + return diff + }) + numberToRemove := totalReactionCount - reactions.MaxCount + for i := 0; i < numberToRemove && i < len(remainingReactionList); i++ { + doRemoveReaction(remainingReactionList[i], nil, true) + } + } + } + if newData.HasAllUsers { + for _, userReactions := range existing { + for _, existingReaction := range userReactions { + doRemoveReaction(existingReaction, nil, true) + } + } + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult { + log := zerolog.Ctx(ctx) + targetMessage, err := portal.getTargetMessagePart(ctx, evt) + if err != nil { + log.Err(err).Msg("Failed to get target message for reaction") + return EventHandlingResultFailed.WithError(err) + } else if targetMessage == nil { + // TODO use deterministic event ID as target if applicable? + log.Warn().Msg("Target message for reaction not found") + return EventHandlingResultIgnored + } + emoji, emojiID := evt.GetReactionEmoji() + existingReaction, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, targetMessage.ID, targetMessage.PartID, evt.GetSender().Sender, emojiID) + if err != nil { + log.Err(err).Msg("Failed to check if reaction is a duplicate") + return EventHandlingResultFailed.WithError(err) + } else if existingReaction != nil && (emojiID != "" || existingReaction.Emoji == emoji) { + log.Debug().Msg("Ignoring duplicate reaction") + return EventHandlingResultIgnored + } + ts := getEventTS(evt) + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReaction) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + var extra map[string]any + if extraContentProvider, ok := evt.(RemoteReactionWithExtraContent); ok { + extra = extraContentProvider.GetReactionExtraContent() + } + var dbMetadata any + if metaProvider, ok := evt.(RemoteReactionWithMeta); ok { + dbMetadata = metaProvider.GetReactionDBMetadata() + } + if existingReaction != nil { + _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: existingReaction.MXID, + }, + }, &MatrixSendExtra{Timestamp: ts}) + if err != nil { + log.Err(err).Msg("Failed to redact old reaction") + } + } + return portal.sendConvertedReaction(ctx, evt.GetSender().Sender, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extra, nil) +} + +func (portal *Portal) sendConvertedReaction( + ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, + emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, + logContext func(*zerolog.Event) *zerolog.Event, +) EventHandlingResult { + if logContext == nil { + logContext = func(e *zerolog.Event) *zerolog.Event { + return e + } + } + log := zerolog.Ctx(ctx) + dbReaction := &database.Reaction{ + Room: portal.PortalKey, + MessageID: targetMessage.ID, + MessagePartID: targetMessage.PartID, + SenderID: senderID, + SenderMXID: intent.GetMXID(), + EmojiID: emojiID, + Timestamp: ts, + Metadata: dbMetadata, + } + if emojiID == "" { + dbReaction.Emoji = emoji + } + resp, err := intent.SendMessage(ctx, portal.MXID, event.EventReaction, &event.Content{ + Parsed: &event.ReactionEventContent{ + RelatesTo: event.RelatesTo{ + Type: event.RelAnnotation, + EventID: targetMessage.MXID, + Key: variationselector.Add(emoji), + }, + }, + Raw: extraContent, + }, &MatrixSendExtra{ + Timestamp: ts, + ReactionMeta: dbReaction, + }) + if err != nil { + logContext(log.Err(err)).Msg("Failed to send reaction to Matrix") + return EventHandlingResultFailed.WithError(err) + } + logContext(log.Debug()). + Stringer("event_id", resp.EventID). + Msg("Sent reaction to Matrix") + dbReaction.MXID = resp.EventID + err = portal.Bridge.DB.Reaction.Upsert(ctx, dbReaction) + if err != nil { + logContext(log.Err(err)).Msg("Failed to save reaction to database") + return EventHandlingResultFailed.WithError(err) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) getIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) { + if userID == "" { + return nil, nil + } else if userID == portal.Bridge.Bot.GetMXID() { + return portal.Bridge.Bot, nil + } else if ghost, err := portal.Bridge.GetGhostByMXID(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to get ghost: %w", err) + } else if ghost != nil { + return ghost.Intent, nil + } else if user, err := portal.Bridge.GetExistingUserByMXID(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } else if user != nil { + return user.DoublePuppet(ctx), nil + } else { + return nil, nil + } +} + +func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult { + log := zerolog.Ctx(ctx) + targetReaction, err := portal.getTargetReaction(ctx, evt) + if err != nil { + log.Err(err).Msg("Failed to get target reaction for removal") + return EventHandlingResultFailed.WithError(err) + } else if targetReaction == nil { + log.Warn().Msg("Target reaction not found") + return EventHandlingResultIgnored + } + intent, err := portal.getIntentForMXID(ctx, targetReaction.SenderMXID) + if err != nil { + log.Err(err).Stringer("sender_mxid", targetReaction.SenderMXID).Msg("Failed to get intent for removing reaction") + } + if intent == nil { + var ok bool + intent, ok = portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReactionRemove) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + } + ts := getEventTS(evt) + _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: targetReaction.MXID, + }, + }, &MatrixSendExtra{Timestamp: ts, ReactionMeta: targetReaction}) + if err != nil { + log.Err(err).Stringer("reaction_mxid", targetReaction.MXID).Msg("Failed to redact reaction") + return EventHandlingResultFailed.WithError(err) + } + err = portal.Bridge.DB.Reaction.Delete(ctx, targetReaction) + if err != nil { + log.Err(err).Msg("Failed to delete target reaction from database") + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult { + log := zerolog.Ctx(ctx) + targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, evt.GetTargetMessage()) + if err != nil { + log.Err(err).Msg("Failed to get target message for removal") + return EventHandlingResultFailed.WithError(err) + } else if len(targetParts) == 0 { + log.Debug().Msg("Target message not found") + return EventHandlingResultIgnored + } + onlyForMeProvider, ok := evt.(RemoteDeleteOnlyForMe) + onlyForMe := ok && onlyForMeProvider.DeleteOnlyForMe() + if onlyForMe && portal.Receiver == "" { + _, others, err := portal.findOtherLogins(ctx, source) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return EventHandlingResultFailed.WithError(err) + } else if len(others) > 0 { + log.Debug().Msg("Ignoring delete for me event in portal with multiple logins") + return EventHandlingResultIgnored + } + } + + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageRemove) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + if intent == portal.Bridge.Bot && len(targetParts) > 0 { + senderIntent, err := portal.getIntentForMXID(ctx, targetParts[0].SenderMXID) + if err != nil { + log.Err(err).Stringer("sender_mxid", targetParts[0].SenderMXID).Msg("Failed to get intent for removing message") + } else if senderIntent != nil { + intent = senderIntent + } + } + dontRenderPlaceholderProvider, ok := evt.(RemoteMessageRemoveWithoutPlaceholder) + dontRenderPlaceholder := ok && dontRenderPlaceholderProvider.DontRenderPlaceholder() + res := portal.redactMessageParts(ctx, targetParts, intent, getEventTS(evt), "", dontRenderPlaceholder) + err = portal.Bridge.DB.Message.DeleteAllParts(ctx, portal.Receiver, evt.GetTargetMessage()) + if err != nil { + log.Err(err).Msg("Failed to delete target message from database") + } + return res +} + +func (portal *Portal) redactMessageParts( + ctx context.Context, + parts []*database.Message, + intent MatrixAPI, + ts time.Time, + reason string, + dontRenderPlaceholder bool, +) EventHandlingResult { + log := zerolog.Ctx(ctx) + var errorList []error + for _, part := range parts { + if part.HasFakeMXID() { + continue + } + resp, err := intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ + Parsed: &event.RedactionEventContent{ + Redacts: part.MXID, + Reason: reason, + DontRenderPlaceholder: dontRenderPlaceholder, + }, + }, &MatrixSendExtra{Timestamp: ts, MessageMeta: part}) + if err != nil { + log.Err(err).Stringer("part_mxid", part.MXID).Msg("Failed to redact message part") + errorList = append(errorList, err) + } else { + log.Debug(). + Stringer("redaction_event_id", resp.EventID). + Stringer("redacted_event_id", part.MXID). + Str("part_id", string(part.ID)). + Msg("Sent redaction of message part to Matrix") + } + } + if len(errorList) > 0 { + return EventHandlingResultFailed.WithError(errors.Join(errorList...)) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult { + log := zerolog.Ctx(ctx) + var err error + var lastTarget *database.Message + readUpTo := evt.GetReadUpTo() + if lastTargetID := evt.GetLastReceiptTarget(); lastTargetID != "" { + lastTarget, err = portal.Bridge.DB.Message.GetLastPartByID(ctx, portal.Receiver, lastTargetID) + if err != nil { + log.Err(err).Str("last_target_id", string(lastTargetID)). + Msg("Failed to get last target message for read receipt") + return EventHandlingResultFailed.WithError(err) + } else if lastTarget == nil { + log.Debug().Str("last_target_id", string(lastTargetID)). + Msg("Last target message not found") + } else if lastTarget.HasFakeMXID() { + log.Debug().Str("last_target_id", string(lastTargetID)). + Msg("Last target message is fake") + if readUpTo.IsZero() { + readUpTo = lastTarget.Timestamp + } + lastTarget = nil + } + } + if lastTarget == nil { + for _, targetID := range evt.GetReceiptTargets() { + target, err := portal.Bridge.DB.Message.GetLastPartByID(ctx, portal.Receiver, targetID) + if err != nil { + log.Err(err).Str("target_id", string(targetID)). + Msg("Failed to get target message for read receipt") + return EventHandlingResultFailed.WithError(err) + } else if target != nil && !target.HasFakeMXID() && (lastTarget == nil || target.Timestamp.After(lastTarget.Timestamp)) { + lastTarget = target + } + } + } + if lastTarget == nil && !readUpTo.IsZero() { + lastTarget, err = portal.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime(ctx, portal.PortalKey, readUpTo) + if err != nil { + log.Err(err).Time("read_up_to", readUpTo).Msg("Failed to get target message for read receipt") + } + } + sender := evt.GetSender() + intent, ok := portal.GetIntentFor(ctx, sender, source, RemoteEventReadReceipt) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + var addTargetLog func(evt *zerolog.Event) *zerolog.Event + if lastTarget == nil { + sevt, evtOK := evt.(RemoteReadReceiptWithStreamOrder) + soIntent, soIntentOK := intent.(StreamOrderReadingMatrixAPI) + if !evtOK || !soIntentOK || sevt.GetReadUpToStreamOrder() == 0 { + log.Warn().Msg("No target message found for read receipt") + return EventHandlingResultIgnored + } + targetStreamOrder := sevt.GetReadUpToStreamOrder() + addTargetLog = func(evt *zerolog.Event) *zerolog.Event { + return evt.Int64("target_stream_order", targetStreamOrder) + } + err = soIntent.MarkStreamOrderRead(ctx, portal.MXID, targetStreamOrder, getEventTS(evt)) + if readUpTo.IsZero() { + readUpTo = getEventTS(evt) + } + } else { + addTargetLog = func(evt *zerolog.Event) *zerolog.Event { + return evt.Stringer("target_mxid", lastTarget.MXID) + } + err = intent.MarkRead(ctx, portal.MXID, lastTarget.MXID, getEventTS(evt)) + readUpTo = lastTarget.Timestamp + } + if err != nil { + addTargetLog(log.Err(err)).Msg("Failed to bridge read receipt") + return EventHandlingResultFailed.WithError(err) + } else { + addTargetLog(log.Debug()).Msg("Bridged read receipt") + } + if sender.IsFromMe { + portal.Bridge.DisappearLoop.StartAllBefore(ctx, portal.MXID, readUpTo) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult { + if !evt.GetSender().IsFromMe { + zerolog.Ctx(ctx).Warn().Msg("Ignoring mark unread event from non-self user") + return EventHandlingResultIgnored + } + dp := source.User.DoublePuppet(ctx) + if dp == nil { + return EventHandlingResultIgnored + } + err := dp.MarkUnread(ctx, portal.MXID, evt.GetUnread()) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge mark unread event") + return EventHandlingResultFailed.WithError(err) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { + if portal.RoomType != database.RoomTypeDM || (evt.GetSender().Sender != portal.OtherUserID && portal.OtherUserID != "") { + return EventHandlingResultIgnored + } + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventDeliveryReceipt) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + log := zerolog.Ctx(ctx) + for _, target := range evt.GetReceiptTargets() { + targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, target) + if err != nil { + log.Err(err).Str("target_id", string(target)).Msg("Failed to get target message for delivery receipt") + return EventHandlingResultFailed.WithError(err) + } else if len(targetParts) == 0 { + continue + } else if _, sentByGhost := portal.Bridge.Matrix.ParseGhostMXID(targetParts[0].SenderMXID); sentByGhost { + continue + } + for _, part := range targetParts { + portal.Bridge.Matrix.SendMessageStatus(ctx, &MessageStatus{ + Status: event.MessageStatusSuccess, + DeliveredTo: []id.UserID{intent.GetMXID()}, + }, &MessageStatusEventInfo{ + RoomID: portal.MXID, + SourceEventID: part.MXID, + Sender: part.SenderMXID, + + IsSourceEventDoublePuppeted: part.IsDoublePuppeted, + }) + } + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult { + var typingType TypingType + if typedEvt, ok := evt.(RemoteTypingWithType); ok { + typingType = typedEvt.GetTypingType() + } + intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventTyping) + if !ok { + return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) + } + timeout := evt.GetTimeout() + err := intent.MarkTyping(ctx, portal.MXID, typingType, timeout) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge typing event") + return EventHandlingResultFailed.WithError(err) + } + if timeout == 0 { + portal.currentlyTypingGhosts.Remove(intent.GetMXID()) + } else { + portal.currentlyTypingGhosts.Add(intent.GetMXID()) + } + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult { + info, err := evt.GetChatInfoChange(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get chat info change") + return EventHandlingResultFailed.WithError(err) + } + portal.ProcessChatInfoChange(ctx, evt.GetSender(), source, info, getEventTS(evt)) + return EventHandlingResultSuccess +} + +func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult { + log := zerolog.Ctx(ctx) + infoProvider, ok := evt.(RemoteChatResyncWithInfo) + if ok { + info, err := infoProvider.GetChatInfo(ctx, portal) + if err != nil { + log.Err(err).Msg("Failed to get chat info from resync event") + } else if info != nil { + portal.UpdateInfo(ctx, info, source, nil, time.Time{}) + } else { + log.Debug().Msg("No chat info provided in resync event") + } + } + backfillChecker, ok := evt.(RemoteChatResyncBackfill) + if portal.Bridge.Config.Backfill.Enabled && ok && portal.RoomType != database.RoomTypeSpace { + latestMessage, err := portal.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, portal.PortalKey, time.Now().Add(10*time.Second)) + if err != nil { + log.Err(err).Msg("Failed to get last message in portal to check if backfill is necessary") + } else if needsBackfill, err := backfillChecker.CheckNeedsBackfill(ctx, latestMessage); err != nil { + log.Err(err).Msg("Failed to check if backfill is needed") + } else if needsBackfill { + bundleProvider, ok := evt.(RemoteChatResyncBackfillBundle) + var bundle any + if ok { + bundle = bundleProvider.GetBundledBackfillData() + } + portal.doForwardBackfill(ctx, source, latestMessage, bundle) + } + } + return EventHandlingResultSuccess +} + +func (portal *Portal) findOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { + others, err = portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + return + } + others = slices.DeleteFunc(others, func(up *database.UserPortal) bool { + if up.LoginID == source.ID { + ownUP = up + return true + } + return false + }) + return +} + +type childDeleteProxy struct { + RemoteChatDeleteWithChildren + child networkid.PortalKey + done func() +} + +func (cdp *childDeleteProxy) AddLogContext(c zerolog.Context) zerolog.Context { + return cdp.RemoteChatDeleteWithChildren.AddLogContext(c).Str("subaction", "delete children") +} +func (cdp *childDeleteProxy) GetPortalKey() networkid.PortalKey { return cdp.child } +func (cdp *childDeleteProxy) ShouldCreatePortal() bool { return false } +func (cdp *childDeleteProxy) PreHandle(ctx context.Context, portal *Portal) {} +func (cdp *childDeleteProxy) PostHandle(ctx context.Context, portal *Portal) { cdp.done() } + +func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { + log := zerolog.Ctx(ctx) + if portal.Receiver == "" && evt.DeleteOnlyForMe() { + ownUP, logins, err := portal.findOtherLogins(ctx, source) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return EventHandlingResultFailed.WithError(err) + } + if len(logins) > 0 { + log.Debug().Msg("Not deleting portal with other logins in remote chat delete event") + if ownUP != nil { + err = portal.Bridge.DB.UserPortal.Delete(ctx, ownUP) + if err != nil { + log.Err(err).Msg("Failed to delete own user portal row from database") + } else { + log.Debug().Msg("Deleted own user portal row from database") + } + } + _, err = portal.sendStateWithIntentOrBot( + ctx, + source.User.DoublePuppet(ctx), + event.StateMember, + source.UserMXID.String(), + &event.Content{Parsed: &event.MemberEventContent{Membership: event.MembershipLeave}}, + getEventTS(evt), + ) + if err != nil { + log.Err(err).Msg("Failed to send leave state event for user after remote chat delete") + return EventHandlingResultFailed.WithError(err) + } else { + log.Debug().Msg("Sent leave state event for user after remote chat delete") + return EventHandlingResultSuccess + } + } + } + if childDeleter, ok := evt.(RemoteChatDeleteWithChildren); ok && childDeleter.DeleteChildren() && portal.RoomType == database.RoomTypeSpace { + children, err := portal.Bridge.GetChildPortals(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to fetch children to delete") + return EventHandlingResultFailed.WithError(err) + } + log.Debug(). + Int("portal_count", len(children)). + Msg("Deleting child portals before remote chat delete") + var wg sync.WaitGroup + wg.Add(len(children)) + for _, child := range children { + child.queueEvent(ctx, &portalRemoteEvent{ + evt: &childDeleteProxy{ + RemoteChatDeleteWithChildren: childDeleter, + child: child.PortalKey, + done: wg.Done, + }, + source: source, + evtType: RemoteEventChatDelete, + }) + } + wg.Wait() + log.Debug().Msg("Finished deleting child portals") + } + err := portal.Delete(ctx) + if err != nil { + log.Err(err).Msg("Failed to delete portal from database") + return EventHandlingResultFailed.WithError(err) + } + // The event context has likely been canceled by delete, so use a background context for the delete call + noCancelCtx := log.WithContext(portal.Bridge.BackgroundCtx) + err = portal.Bridge.Bot.DeleteRoom(noCancelCtx, portal.MXID, false) + if err != nil { + log.Err(err).Msg("Failed to delete Matrix room") + return EventHandlingResultFailed.WithError(err) + } else { + log.Info().Msg("Deleted room after remote chat delete event") + return EventHandlingResultSuccess + } +} + +func (portal *Portal) HandleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) EventHandlingResult { + if !portal.Bridge.Config.Backfill.Enabled { + zerolog.Ctx(ctx).Debug().Msg("Ignoring async backfill event because backfill is disabled") + return EventHandlingResultIgnored + } + data, err := backfill.GetBackfillData(ctx, portal) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get backfill data") + return EventHandlingResultFailed.WithError(err) + } + portal.Bridge.WakeupBackfillQueue(&ManualBackfill{Source: source, Portal: portal, Data: data}) + return EventHandlingResultSuccess +} + +type ChatInfoChange struct { + // The chat info that changed. Any fields that did not change can be left as nil. + ChatInfo *ChatInfo + // A list of member changes. + // This list should only include changes, not the whole member list. + // To resync the whole list, use the field inside ChatInfo. + MemberChanges *ChatMemberList +} + +func (portal *Portal) ProcessChatInfoChange(ctx context.Context, sender EventSender, source *UserLogin, change *ChatInfoChange, ts time.Time) { + intent, ok := portal.GetIntentFor(ctx, sender, source, RemoteEventChatInfoChange) + if !ok { + return + } + if change.ChatInfo != nil { + portal.UpdateInfo(ctx, change.ChatInfo, source, intent, ts) + } + if change.MemberChanges != nil { + err := portal.syncParticipants(ctx, change.MemberChanges, source, intent, ts) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to sync room members") + } + } +} + +// Deprecated: Renamed to ChatInfo +type PortalInfo = ChatInfo + +type ChatMember struct { + EventSender + Membership event.Membership + // Per-room nickname for the user. Not yet used. + Nickname *string + // The power level to set for the user when syncing power levels. + PowerLevel *int + // Optional user info to sync the ghost user while updating membership. + UserInfo *UserInfo + // The user who sent the membership change (user who invited/kicked/banned this user). + // Not yet used. Not applicable if Membership is join or knock. + MemberSender EventSender + // Extra fields to include in the member event. + MemberEventExtra map[string]any + // The expected previous membership. If this doesn't match, the change is ignored. + PrevMembership event.Membership +} + +type ChatMemberMap map[networkid.UserID]ChatMember + +// Set adds the given entry to this map, overwriting any existing entry with the same Sender field. +func (cmm ChatMemberMap) Set(member ChatMember) ChatMemberMap { + if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { + return cmm + } + cmm[member.Sender] = member + return cmm +} + +// Add adds the given entry to this map, but will ignore it if an entry with the same Sender field already exists. +// It returns true if the entry was added, false otherwise. +func (cmm ChatMemberMap) Add(member ChatMember) bool { + if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { + return false + } + if _, exists := cmm[member.Sender]; exists { + return false + } + cmm[member.Sender] = member + return true +} + +type ChatMemberList struct { + // Whether this is the full member list. + // If true, any extra members not listed here will be removed from the portal. + IsFull bool + // Should the bridge call IsThisUser for every member in the list? + // This should be used when SenderLogin can't be filled accurately. + CheckAllLogins bool + // Should any changes have the `com.beeper.exclude_from_timeline` flag set by default? + // This is recommended for syncs with non-real-time changes. + // Real-time changes (e.g. a user joining) should not set this flag set. + ExcludeChangesFromTimeline bool + + // The total number of members in the chat, regardless of how many of those members are included in MemberMap. + TotalMemberCount int + + // For DM portals, the ID of the recipient user. + // This field is optional and will be automatically filled from MemberMap if there are only 2 entries in the map. + OtherUserID networkid.UserID + + // Deprecated: Use MemberMap instead to avoid duplicate entries + Members []ChatMember + MemberMap ChatMemberMap + PowerLevels *PowerLevelOverrides +} + +func (cml *ChatMemberList) memberListToMap(ctx context.Context) { + if cml.Members == nil || cml.MemberMap != nil { + return + } + cml.MemberMap = make(map[networkid.UserID]ChatMember, len(cml.Members)) + for _, member := range cml.Members { + if _, alreadyExists := cml.MemberMap[member.Sender]; alreadyExists { + zerolog.Ctx(ctx).Warn().Str("member_id", string(member.Sender)).Msg("Duplicate member in list") + } + cml.MemberMap[member.Sender] = member + } +} + +type PowerLevelOverrides struct { + Events map[event.Type]int + UsersDefault *int + EventsDefault *int + StateDefault *int + Invite *int + Kick *int + Ban *int + Redact *int + + Custom func(*event.PowerLevelsEventContent) bool +} + +// Deprecated: renamed to PowerLevelOverrides +type PowerLevelChanges = PowerLevelOverrides + +func allowChange(newLevel *int, oldLevel, actorLevel int) bool { + return newLevel != nil && + *newLevel <= actorLevel && oldLevel <= actorLevel && + oldLevel != *newLevel +} + +func (plc *PowerLevelOverrides) Apply(actor id.UserID, content *event.PowerLevelsEventContent) (changed bool) { + if plc == nil || content == nil { + return + } + var actorLevel int + if actor != "" { + actorLevel = content.GetUserLevel(actor) + } else { + actorLevel = (1 << 31) - 1 + } + if allowChange(plc.UsersDefault, content.UsersDefault, actorLevel) { + changed = true + content.UsersDefault = *plc.UsersDefault + } + if allowChange(plc.EventsDefault, content.EventsDefault, actorLevel) { + changed = true + content.EventsDefault = *plc.EventsDefault + } + if allowChange(plc.StateDefault, content.StateDefault(), actorLevel) { + changed = true + content.StateDefaultPtr = plc.StateDefault + } + if allowChange(plc.Invite, content.Invite(), actorLevel) { + changed = true + content.InvitePtr = plc.Invite + } + if allowChange(plc.Kick, content.Kick(), actorLevel) { + changed = true + content.KickPtr = plc.Kick + } + if allowChange(plc.Ban, content.Ban(), actorLevel) { + changed = true + content.BanPtr = plc.Ban + } + if allowChange(plc.Redact, content.Redact(), actorLevel) { + changed = true + content.RedactPtr = plc.Redact + } + for evtType, level := range plc.Events { + changed = content.EnsureEventLevelAs(actor, evtType, level) || changed + } + if plc.Custom != nil { + changed = plc.Custom(content) || changed + } + return changed +} + +// DefaultChatName can be used to explicitly clear the name of a room +// and reset it to the default one based on members. +var DefaultChatName = ptr.Ptr("") + +type ChatInfo struct { + Name *string + Topic *string + Avatar *Avatar + + Members *ChatMemberList + JoinRule *event.JoinRulesEventContent + + Type *database.RoomType + Disappear *database.DisappearingSetting + ParentID *networkid.PortalID + + UserLocal *UserLocalPortalInfo + MessageRequest *bool + CanBackfill bool + + ExcludeChangesFromTimeline bool + + ExtraUpdates ExtraUpdater[*Portal] +} + +type ExtraUpdater[T any] func(context.Context, T) bool + +func MergeExtraUpdaters[T any](funcs ...ExtraUpdater[T]) ExtraUpdater[T] { + funcs = slices.DeleteFunc(funcs, func(f ExtraUpdater[T]) bool { + return f == nil + }) + if len(funcs) == 0 { + return nil + } else if len(funcs) == 1 { + return funcs[0] + } + return func(ctx context.Context, p T) bool { + changed := false + for _, f := range funcs { + changed = f(ctx, p) || changed + } + return changed + } +} + +var Unmuted = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + +type UserLocalPortalInfo struct { + // To signal an indefinite mute, use [event.MutedForever] as the value here. + // To unmute, set any time before now, e.g. [bridgev2.Unmuted]. + MutedUntil *time.Time + Tag *event.RoomTag +} + +func (portal *Portal) updateName( + ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, +) bool { + if portal.Name == name && (portal.NameSet || portal.MXID == "") { + return false + } + portal.Name = name + portal.NameSet = portal.sendRoomMeta( + ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, nil, + ) + return true +} + +func (portal *Portal) updateTopic( + ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, +) bool { + if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") { + return false + } + portal.Topic = topic + portal.TopicSet = portal.sendRoomMeta( + ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, nil, + ) + return true +} + +func (portal *Portal) updateAvatar( + ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, +) bool { + if portal.AvatarID == avatar.ID && (avatar.Remove || portal.AvatarMXC != "") && (portal.AvatarSet || portal.MXID == "") { + return false + } + portal.AvatarID = avatar.ID + if sender == nil { + sender = portal.Bridge.Bot + } + if avatar.Remove { + portal.AvatarMXC = "" + portal.AvatarHash = [32]byte{} + } else { + newMXC, newHash, err := avatar.Reupload(ctx, sender, portal.AvatarHash, portal.AvatarMXC) + if err != nil { + portal.AvatarSet = false + zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload room avatar") + return true + } else if newHash == portal.AvatarHash && portal.AvatarMXC != "" && portal.AvatarSet { + return true + } + portal.AvatarMXC = newMXC + portal.AvatarHash = newHash + } + portal.AvatarSet = portal.sendRoomMeta( + ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, nil, + ) + return true +} + +func (portal *Portal) GetTopLevelParent() *Portal { + if portal.Parent == nil { + if portal.RoomType != database.RoomTypeSpace { + return nil + } + return portal + } + return portal.Parent.GetTopLevelParent() +} + +func (portal *Portal) getBridgeInfoStateKey() string { + if portal.Bridge.Config.NoBridgeInfoStateKey { + return "" + } + idProvider, ok := portal.Bridge.Matrix.(MatrixConnectorWithBridgeIdentifier) + if ok { + return idProvider.GetUniqueBridgeID() + } + return string(portal.BridgeID) +} + +func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { + bridgeInfo := event.BridgeEventContent{ + BridgeBot: portal.Bridge.Bot.GetMXID(), + Creator: portal.Bridge.Bot.GetMXID(), + Protocol: portal.Bridge.Network.GetName().AsBridgeInfoSection(), + Channel: event.BridgeInfoSection{ + ID: string(portal.ID), + DisplayName: portal.Name, + AvatarURL: portal.AvatarMXC, + Receiver: string(portal.Receiver), + MessageRequest: portal.MessageRequest, + // TODO external URL? + }, + BeeperRoomTypeV2: string(portal.RoomType), + } + if portal.RoomType == database.RoomTypeDM || portal.RoomType == database.RoomTypeGroupDM { + bridgeInfo.BeeperRoomType = "dm" + } + if bridgeInfo.Protocol.ID == "slackgo" { + bridgeInfo.TempSlackRemoteIDMigratedFlag = true + bridgeInfo.TempSlackRemoteIDMigratedFlag2 = true + } + parent := portal.GetTopLevelParent() + if parent != nil { + bridgeInfo.Network = &event.BridgeInfoSection{ + ID: string(parent.ID), + DisplayName: parent.Name, + AvatarURL: parent.AvatarMXC, + // TODO external URL? + } + } + filler, ok := portal.Bridge.Network.(PortalBridgeInfoFillingNetwork) + if ok { + filler.FillPortalBridgeInfo(portal, &bridgeInfo) + } + return portal.getBridgeInfoStateKey(), bridgeInfo +} + +func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { + if portal.MXID == "" { + return + } + stateKey, bridgeInfo := portal.getBridgeInfo() + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false, nil) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false, nil) +} + +func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, implicit bool) bool { + if portal.MXID == "" { + return false + } else if !implicit && time.Since(portal.lastCapUpdate) < 24*time.Hour { + return false + } else if portal.CapState.ID != "" && source.ID != portal.CapState.Source && source.ID != portal.Receiver { + // TODO allow capability state source to change if the old user login is removed from the portal + return false + } + caps := source.Client.GetCapabilities(ctx, portal) + capID := caps.GetID() + if capID == portal.CapState.ID { + return false + } + zerolog.Ctx(ctx).Debug(). + Str("user_login_id", string(source.ID)). + Str("old_id", portal.CapState.ID). + Str("new_id", capID). + Msg("Sending new room capability event") + success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false, nil) + if !success { + return false + } + portal.CapState = database.CapabilityState{ + Source: source.ID, + ID: capID, + Flags: portal.CapState.Flags, + } + if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) { + zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event") + success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) + if !success { + return false + } + portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet + } + portal.lastCapUpdate = time.Now() + if implicit { + err := portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal capability state after sending state event") + } + } + return true +} + +func (portal *Portal) sendStateWithIntentOrBot(ctx context.Context, sender MatrixAPI, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { + if sender == nil { + sender = portal.Bridge.Bot + } + resp, err = sender.SendState(ctx, portal.MXID, eventType, stateKey, content, ts) + if errors.Is(err, mautrix.MForbidden) && sender != portal.Bridge.Bot { + if content.Raw == nil { + content.Raw = make(map[string]any) + } + content.Raw["fi.mau.bridge.set_by"] = sender.GetMXID() + resp, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, eventType, stateKey, content, ts) + } + return +} + +func (portal *Portal) sendRoomMeta( + ctx context.Context, + sender MatrixAPI, + ts time.Time, + eventType event.Type, + stateKey string, + content any, + excludeFromTimeline bool, + extra map[string]any, +) bool { + if portal.MXID == "" { + return false + } + if extra == nil { + extra = make(map[string]any) + } + if excludeFromTimeline { + extra["com.beeper.exclude_from_timeline"] = true + } + if !portal.NameIsCustom && (eventType == event.StateRoomName || eventType == event.StateRoomAvatar) { + extra["fi.mau.implicit_name"] = true + } + _, err := portal.sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, &event.Content{ + Parsed: content, + Raw: extra, + }, ts) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("event_type", eventType.Type). + Msg("Failed to set room metadata") + return false + } + if eventType == event.StateBeeperDisappearingTimer { + // TODO remove this debug log at some point + zerolog.Ctx(ctx).Debug(). + Any("content", content). + Msg("Sent new disappearing timer event") + } + return true +} + +func (portal *Portal) revertRoomMeta(ctx context.Context, evt *event.Event) { + if !portal.Bridge.Config.RevertFailedStateChanges { + return + } + if evt.GetStateKey() != "" && evt.Type != event.StateMember { + return + } + switch evt.Type { + case event.StateRoomName: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomName, "", &event.RoomNameEventContent{Name: portal.Name}, true, nil) + case event.StateRoomAvatar: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, true, nil) + case event.StateTopic: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateTopic, "", &event.TopicEventContent{Topic: portal.Topic}, true, nil) + case event.StateBeeperDisappearingTimer: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) + case event.StateMember: + var prevContent *event.MemberEventContent + var extra map[string]any + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent = evt.Unsigned.PrevContent.AsMember() + newContent := evt.Content.AsMember() + if prevContent.Membership == newContent.Membership { + return + } + extra = evt.Unsigned.PrevContent.Raw + } else { + prevContent = &event.MemberEventContent{Membership: event.MembershipLeave} + } + if portal.Bridge.Matrix.GetCapabilities().ArbitraryMemberChange { + if extra == nil { + extra = make(map[string]any) + } + extra["com.beeper.member_rollback"] = true + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateMember, evt.GetStateKey(), prevContent, true, extra) + } + } +} + +func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { + if members == nil { + invite = []id.UserID{source.UserMXID} + return + } + var loginsInPortal []*UserLogin + if members.CheckAllLogins && !portal.Bridge.Config.SplitPortals { + loginsInPortal, err = portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) + if err != nil { + err = fmt.Errorf("failed to get user logins in portal: %w", err) + return + } + } + members.PowerLevels.Apply("", pl) + members.memberListToMap(ctx) + for _, member := range members.MemberMap { + if ctx.Err() != nil { + err = ctx.Err() + return + } + if member.Membership != event.MembershipJoin && member.Membership != "" { + continue + } + if member.Sender != "" && member.UserInfo != nil { + ghost, err := portal.Bridge.GetGhostByID(ctx, member.Sender) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("ghost_id", string(member.Sender)).Msg("Failed to get ghost from member list to update info") + } else { + ghost.UpdateInfo(ctx, member.UserInfo) + } + } + intent, extraUserID, err := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) + if err != nil { + return nil, nil, err + } + if extraUserID != "" { + invite = append(invite, extraUserID) + if member.PowerLevel != nil { + pl.EnsureUserLevel(extraUserID, *member.PowerLevel) + } + if intent != nil { + // If intent is present along with a user ID, it's the ghost of a logged-in user, + // so add it to the functional members list + functional = append(functional, intent.GetMXID()) + } + } + if intent != nil { + invite = append(invite, intent.GetMXID()) + if member.PowerLevel != nil { + pl.EnsureUserLevel(intent.GetMXID(), *member.PowerLevel) + } + } + } + portal.updateOtherUser(ctx, members) + return +} + +func (portal *Portal) updateOtherUser(ctx context.Context, members *ChatMemberList) (changed bool) { + members.memberListToMap(ctx) + var expectedUserID networkid.UserID + if portal.RoomType != database.RoomTypeDM { + // expected user ID is empty + } else if members.OtherUserID != "" { + expectedUserID = members.OtherUserID + } else if len(members.MemberMap) == 2 && members.IsFull { + vals := maps.Values(members.MemberMap) + if vals[0].IsFromMe && !vals[1].IsFromMe { + expectedUserID = vals[1].Sender + } else if vals[1].IsFromMe && !vals[0].IsFromMe { + expectedUserID = vals[0].Sender + } + } + if portal.OtherUserID != expectedUserID { + zerolog.Ctx(ctx).Debug(). + Str("old_other_user_id", string(portal.OtherUserID)). + Str("new_other_user_id", string(expectedUserID)). + Msg("Updating other user ID in DM portal") + portal.OtherUserID = expectedUserID + return true + } + return false +} + +func looksDirectlyJoinable(rule *event.JoinRulesEventContent) bool { + switch rule.JoinRule { + case event.JoinRulePublic: + return true + case event.JoinRuleKnockRestricted, event.JoinRuleRestricted: + for _, allow := range rule.Allow { + if allow.Type == "fi.mau.spam_checker" { + return true + } + } + } + return false +} + +func (portal *Portal) roomIsPublic(ctx context.Context) bool { + mx, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) + if !ok { + return false + } + evt, err := mx.GetStateEvent(ctx, portal.MXID, event.StateJoinRules, "") + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get join rules to check if room is public") + return false + } else if evt == nil { + return false + } + content, ok := evt.Content.Parsed.(*event.JoinRulesEventContent) + if !ok { + return false + } + return looksDirectlyJoinable(content) +} + +func (portal *Portal) syncParticipants( + ctx context.Context, + members *ChatMemberList, + source *UserLogin, + sender MatrixAPI, + ts time.Time, +) error { + members.memberListToMap(ctx) + var loginsInPortal []*UserLogin + var err error + if members.CheckAllLogins && !portal.Bridge.Config.SplitPortals { + loginsInPortal, err = portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) + if err != nil { + return fmt.Errorf("failed to get user logins in portal: %w", err) + } + } + if sender == nil { + sender = portal.Bridge.Bot + } + log := zerolog.Ctx(ctx) + currentPower, err := portal.Bridge.Matrix.GetPowerLevels(ctx, portal.MXID) + if err != nil { + return fmt.Errorf("failed to get current power levels: %w", err) + } + currentMembers, err := portal.Bridge.Matrix.GetMembers(ctx, portal.MXID) + if err != nil { + return fmt.Errorf("failed to get current members: %w", err) + } + delete(currentMembers, portal.Bridge.Bot.GetMXID()) + powerChanged := members.PowerLevels.Apply(portal.Bridge.Bot.GetMXID(), currentPower) + addExcludeFromTimeline := func(raw map[string]any) { + _, hasKey := raw["com.beeper.exclude_from_timeline"] + if !hasKey && members.ExcludeChangesFromTimeline { + raw["com.beeper.exclude_from_timeline"] = true + } + } + syncUser := func(extraUserID id.UserID, member ChatMember, intent MatrixAPI) bool { + if member.Membership == "" { + member.Membership = event.MembershipJoin + } + if member.PowerLevel != nil { + powerChanged = currentPower.EnsureUserLevelAs(portal.Bridge.Bot.GetMXID(), extraUserID, *member.PowerLevel) || powerChanged + } + currentMember, ok := currentMembers[extraUserID] + delete(currentMembers, extraUserID) + if ok && currentMember.Membership == member.Membership { + return false + } + if currentMember == nil { + currentMember = &event.MemberEventContent{Membership: event.MembershipLeave} + } + if member.PrevMembership != "" && member.PrevMembership != currentMember.Membership { + log.Trace(). + Stringer("user_id", extraUserID). + Str("expected_prev_membership", string(member.PrevMembership)). + Str("actual_prev_membership", string(currentMember.Membership)). + Str("target_membership", string(member.Membership)). + Msg("Not updating membership: prev membership mismatch") + return false + } + content := &event.MemberEventContent{ + Membership: member.Membership, + Displayname: currentMember.Displayname, + AvatarURL: currentMember.AvatarURL, + } + wrappedContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} + addExcludeFromTimeline(wrappedContent.Raw) + thisEvtSender := sender + if member.Membership == event.MembershipJoin && (intent == nil || !portal.roomIsPublic(ctx)) { + content.Membership = event.MembershipInvite + if intent != nil { + wrappedContent.Raw["fi.mau.will_auto_accept"] = true + } + if thisEvtSender.GetMXID() == extraUserID { + thisEvtSender = portal.Bridge.Bot + } + } + addLogContext := func(e *zerolog.Event) *zerolog.Event { + return e.Stringer("target_user_id", extraUserID). + Stringer("sender_user_id", thisEvtSender.GetMXID()). + Str("prev_membership", string(currentMember.Membership)) + } + if currentMember != nil && currentMember.Membership == event.MembershipBan && member.Membership != event.MembershipLeave { + unbanContent := *content + unbanContent.Membership = event.MembershipLeave + wrappedUnbanContent := &event.Content{Parsed: &unbanContent} + _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedUnbanContent, ts) + if err != nil { + addLogContext(log.Err(err)). + Str("new_membership", string(unbanContent.Membership)). + Msg("Failed to unban user to update membership") + } else { + addLogContext(log.Trace()). + Str("new_membership", string(unbanContent.Membership)). + Msg("Unbanned user to update membership") + currentMember.Membership = event.MembershipLeave + } + } + if content.Membership == event.MembershipJoin && intent != nil && intent.GetMXID() == extraUserID { + _, err = intent.SendState(ctx, portal.MXID, event.StateMember, extraUserID.String(), wrappedContent, ts) + } else { + _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedContent, ts) + } + if err != nil { + addLogContext(log.Err(err)). + Str("new_membership", string(content.Membership)). + Msg("Failed to update user membership") + } else { + addLogContext(log.Trace()). + Str("new_membership", string(content.Membership)). + Msg("Updated membership in room") + currentMember.Membership = content.Membership + + if intent != nil && content.Membership == event.MembershipInvite && member.Membership == event.MembershipJoin { + content.Membership = event.MembershipJoin + wrappedJoinContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} + addExcludeFromTimeline(wrappedContent.Raw) + _, err = intent.SendState(ctx, portal.MXID, event.StateMember, intent.GetMXID().String(), wrappedJoinContent, ts) + if err != nil { + addLogContext(log.Err(err)). + Str("new_membership", string(content.Membership)). + Msg("Failed to join with intent") + } else { + addLogContext(log.Trace()). + Str("new_membership", string(content.Membership)). + Msg("Joined room with intent") + } + } + } + return true + } + syncIntent := func(intent MatrixAPI, member ChatMember) { + if !syncUser(intent.GetMXID(), member, intent) { + return + } + if member.Membership == event.MembershipJoin || member.Membership == "" { + err = intent.EnsureJoined(ctx, portal.MXID) + if err != nil { + log.Err(err). + Stringer("user_id", intent.GetMXID()). + Msg("Failed to ensure user is joined to room") + } + } + } + for _, member := range members.MemberMap { + if ctx.Err() != nil { + return ctx.Err() + } + if member.Sender != "" && member.UserInfo != nil { + ghost, err := portal.Bridge.GetGhostByID(ctx, member.Sender) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("ghost_id", string(member.Sender)).Msg("Failed to get ghost from member list to update info") + } else { + ghost.UpdateInfo(ctx, member.UserInfo) + } + } + intent, extraUserID, err := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) + if err != nil { + return err + } + if intent != nil { + syncIntent(intent, member) + } + if extraUserID != "" { + syncUser(extraUserID, member, nil) + } + } + if powerChanged { + _, err = portal.sendStateWithIntentOrBot(ctx, sender, event.StatePowerLevels, "", &event.Content{Parsed: currentPower}, ts) + if err != nil { + log.Err(err).Msg("Failed to update power levels") + } + } + portal.updateOtherUser(ctx, members) + if members.IsFull { + for extraMember, memberEvt := range currentMembers { + if memberEvt.Membership == event.MembershipLeave || memberEvt.Membership == event.MembershipBan { + continue + } + if !portal.Bridge.IsGhostMXID(extraMember) && (portal.Relay != nil || !portal.Bridge.Config.KickMatrixUsers) { + continue + } + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateMember, extraMember.String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + AvatarURL: memberEvt.AvatarURL, + Displayname: memberEvt.Displayname, + Reason: "User is not in remote chat", + }, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": members.ExcludeChangesFromTimeline, + }, + }, time.Now()) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("user_id", extraMember). + Msg("Failed to remove user from room") + } + } + } + return nil +} + +func (portal *Portal) updateUserLocalInfo(ctx context.Context, info *UserLocalPortalInfo, source *UserLogin, didJustCreate bool) { + if portal.MXID == "" { + return + } + dp := source.User.DoublePuppet(ctx) + if dp == nil { + return + } + dmMarkingMatrixAPI, canMarkDM := dp.(MarkAsDMMatrixAPI) + if canMarkDM && portal.OtherUserID != "" && portal.RoomType == database.RoomTypeDM { + dmGhost, err := portal.Bridge.GetGhostByID(ctx, portal.OtherUserID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM ghost to mark room as DM") + } else if err = dmMarkingMatrixAPI.MarkAsDM(ctx, portal.MXID, dmGhost.Intent.GetMXID()); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to mark room as DM") + } + } + if info == nil { + return + } + if info.MutedUntil != nil && (didJustCreate || !portal.Bridge.Config.MuteOnlyOnCreate) && (!didJustCreate || info.MutedUntil.After(time.Now())) { + err := dp.MuteRoom(ctx, portal.MXID, *info.MutedUntil) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to mute room") + } + } + if info.Tag != nil && + len(portal.Bridge.Config.OnlyBridgeTags) > 0 && + (*info.Tag == "" || slices.Contains(portal.Bridge.Config.OnlyBridgeTags, *info.Tag)) && + (didJustCreate || !portal.Bridge.Config.TagOnlyOnCreate) && + (!didJustCreate || *info.Tag != "") { + err := dp.TagRoom(ctx, portal.MXID, *info.Tag, *info.Tag != "") + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to tag room") + } + } +} + +func DisappearingMessageNotice(expiration time.Duration, implicit bool) *event.MessageEventContent { + formattedDuration := exfmt.DurationCustom(expiration, nil, exfmt.Day, time.Hour, time.Minute, time.Second) + content := &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: fmt.Sprintf("Set the disappearing message timer to %s", formattedDuration), + Mentions: &event.Mentions{}, + } + if expiration == 0 { + if implicit { + content.Body = "Automatically turned off disappearing messages because incoming message is not disappearing" + } else { + content.Body = "Turned off disappearing messages" + } + } else if implicit { + content.Body = fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", formattedDuration) + } + return content +} + +type UpdateDisappearingSettingOpts struct { + Sender MatrixAPI + Timestamp time.Time + Implicit bool + Save bool + SendNotice bool + + ExcludeFromTimeline bool +} + +func (portal *Portal) UpdateDisappearingSetting( + ctx context.Context, + setting database.DisappearingSetting, + opts UpdateDisappearingSettingOpts, +) bool { + setting = setting.Normalize() + if portal.Disappear.Timer == setting.Timer && portal.Disappear.Type == setting.Type { + return false + } + portal.Disappear.Type = setting.Type + portal.Disappear.Timer = setting.Timer + if opts.Save { + err := portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after updating disappearing setting") + } + } + if portal.MXID == "" { + return true + } + + if opts.Sender == nil { + opts.Sender = portal.Bridge.Bot + } + if opts.Timestamp.IsZero() { + opts.Timestamp = time.Now() + } + portal.sendRoomMeta( + ctx, + opts.Sender, + opts.Timestamp, + event.StateBeeperDisappearingTimer, + "", + setting.ToEventContent(), + opts.ExcludeFromTimeline, + nil, + ) + + if !opts.SendNotice { + return true + } + content := DisappearingMessageNotice(setting.Timer, opts.Implicit) + _, err := opts.Sender.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ + Parsed: content, + Raw: map[string]any{ + "com.beeper.action_message": map[string]any{ + "type": "disappearing_timer", + "timer": setting.Timer.Milliseconds(), + "timer_type": setting.Type, + "implicit": opts.Implicit, + }, + }, + }, &MatrixSendExtra{Timestamp: opts.Timestamp}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to send disappearing messages notice") + } else { + zerolog.Ctx(ctx).Debug(). + Dur("new_timer", portal.Disappear.Timer). + Bool("implicit", opts.Implicit). + Msg("Sent disappearing messages notice") + } + return true +} + +func (portal *Portal) updateParent(ctx context.Context, newParentID networkid.PortalID, source *UserLogin) bool { + newParent := networkid.PortalKey{ID: newParentID} + if portal.Bridge.Config.SplitPortals { + newParent.Receiver = portal.Receiver + } + if portal.ParentKey == newParent { + return false + } + var err error + if portal.MXID != "" && portal.InSpace && portal.Parent != nil && portal.Parent.MXID != "" { + err = portal.toggleSpace(ctx, portal.Parent.MXID, false, true) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("old_space_mxid", portal.Parent.MXID).Msg("Failed to remove portal from old space") + } + } + if newParent.ID != "" { + portal.Parent, err = portal.Bridge.GetPortalByKey(ctx, newParent) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get new parent portal") + return false + } + } + portal.ParentKey = newParent + portal.InSpace = false + if portal.MXID != "" && portal.Parent != nil && (source != nil || portal.Parent.MXID != "") { + if portal.Parent.MXID == "" { + zerolog.Ctx(ctx).Info().Msg("Parent portal doesn't exist, creating") + err = portal.Parent.CreateMatrixRoom(ctx, source, nil) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create parent portal") + } + } + if portal.Parent.MXID != "" { + portal.addToParentSpaceAndSave(ctx, false) + } + } + return true +} + +func (portal *Portal) lockedUpdateInfoFromGhost(ctx context.Context, ghost *Ghost) { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + portal.UpdateInfoFromGhost(ctx, ghost) +} + +func (portal *Portal) UpdateInfoFromGhost(ctx context.Context, ghost *Ghost) (changed bool) { + if portal.NameIsCustom || !portal.Bridge.Config.PrivateChatPortalMeta || (portal.OtherUserID == "" && ghost == nil) || portal.RoomType != database.RoomTypeDM { + return + } + var err error + if ghost == nil { + ghost, err = portal.Bridge.GetGhostByID(ctx, portal.OtherUserID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost to update info from") + return + } + } + changed = portal.updateName(ctx, ghost.Name, nil, time.Time{}, false) || changed + changed = portal.updateAvatar(ctx, &Avatar{ + ID: ghost.AvatarID, + MXC: ghost.AvatarMXC, + Hash: ghost.AvatarHash, + Remove: ghost.AvatarID == "", + }, nil, time.Time{}, false) || changed + return +} + +func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *UserLogin, sender MatrixAPI, ts time.Time) { + changed := false + if info.Name == DefaultChatName { + if portal.NameIsCustom { + portal.NameIsCustom = false + changed = portal.updateName(ctx, "", sender, ts, info.ExcludeChangesFromTimeline) || changed + } + } else if info.Name != nil { + portal.NameIsCustom = true + changed = portal.updateName(ctx, *info.Name, sender, ts, info.ExcludeChangesFromTimeline) || changed + } + if info.Avatar != nil { + portal.NameIsCustom = true + changed = portal.updateAvatar(ctx, info.Avatar, sender, ts, info.ExcludeChangesFromTimeline) || changed + } + if info.Type != nil && portal.RoomType != *info.Type { + if portal.MXID != "" && (*info.Type == database.RoomTypeSpace || portal.RoomType == database.RoomTypeSpace) { + zerolog.Ctx(ctx).Warn(). + Str("current_type", string(portal.RoomType)). + Str("target_type", string(*info.Type)). + Msg("Tried to change existing room type from/to space") + } else { + changed = true + portal.RoomType = *info.Type + } + } + if info.ParentID != nil { + changed = portal.updateParent(ctx, *info.ParentID, source) || changed + } + + // if the name, avatar, parent, or room type changes, we need to resend m.bridge events to each child portal as well + childInfoChanged := changed + + if info.Topic != nil { + changed = portal.updateTopic(ctx, *info.Topic, sender, ts, info.ExcludeChangesFromTimeline) || changed + } + if info.Disappear != nil { + changed = portal.UpdateDisappearingSetting(ctx, *info.Disappear, UpdateDisappearingSettingOpts{ + Sender: sender, + Timestamp: ts, + Implicit: false, + Save: false, + + SendNotice: !info.ExcludeChangesFromTimeline, + ExcludeFromTimeline: info.ExcludeChangesFromTimeline, + }) || changed + } + if info.JoinRule != nil { + // TODO change detection instead of spamming this every time? + portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline, nil) + } + if info.MessageRequest != nil && *info.MessageRequest != portal.MessageRequest { + changed = true + portal.MessageRequest = *info.MessageRequest + } + if info.Members != nil && portal.MXID != "" && source != nil { + err := portal.syncParticipants(ctx, info.Members, source, nil, time.Time{}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to sync room members") + } + // TODO detect changes to functional members list? + } else if info.Members != nil { + portal.updateOtherUser(ctx, info.Members) + } + changed = portal.UpdateInfoFromGhost(ctx, nil) || changed + if source != nil { + source.MarkInPortal(ctx, portal) + portal.updateUserLocalInfo(ctx, info.UserLocal, source, false) + changed = portal.UpdateCapabilities(ctx, source, false) || changed + } + if info.CanBackfill && source != nil && portal.MXID != "" { + err := portal.Bridge.DB.BackfillTask.EnsureExists(ctx, portal.PortalKey, source.ID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure backfill queue task exists") + } + // TODO wake up backfill queue if task was just created + } + if info.ExtraUpdates != nil { + changed = info.ExtraUpdates(ctx, portal) || changed + } + if changed { + portal.UpdateBridgeInfo(ctx) + err := portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after updating info") + } + } + if childInfoChanged { + portal.updateChildBridgeInfo(ctx) + } +} + +func (portal *Portal) updateChildBridgeInfo(ctx context.Context) { + if portal.RoomType != database.RoomTypeSpace { + return + } + portals, err := portal.Bridge.GetChildPortals(ctx, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get child portals to update bridge info") + return + } + zerolog.Ctx(ctx).Debug(). + Int("child_count", len(portals)). + Stringer("parent_key", portal.PortalKey). + Msg("Updating bridge info for child portals") + for _, child := range portals { + child.UpdateBridgeInfo(ctx) + child.updateChildBridgeInfo(ctx) + } +} + +func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserLogin, info *ChatInfo) (retErr error) { + if portal.MXID != "" { + if source != nil { + source.MarkInPortal(ctx, portal) + } + return nil + } + if portal.backgroundCtx.Err() != nil { + return ErrPortalIsDeleted + } + mergedCtx, cancel := context.WithCancel(ctx) + defer cancel() + waiter := make(chan struct{}) + closed := false + evt := &portalCreateEvent{ + ctx: mergedCtx, + source: source, + info: info, + cb: func(err error) { + retErr = err + if !closed { + closed = true + close(waiter) + } + }, + } + if PortalEventBuffer == 0 { + go portal.queueEvent(mergedCtx, evt) + } else { + select { + case portal.events <- evt: + case <-portal.deleted: + return ErrPortalIsDeleted + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-portal.deleted: + // This automatically cancels the merged context too + return ErrPortalIsDeleted + case <-waiter: + return + } +} + +func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLogin, info *ChatInfo, backfillBundle any) error { + cancellableCtx, cancel := context.WithCancel(ctx) + defer cancel() + portal.cancelRoomCreate.CompareAndSwap(nil, &cancel) + portal.roomCreateLock.Lock() + portal.cancelRoomCreate.Store(&cancel) + defer portal.roomCreateLock.Unlock() + if portal.MXID != "" { + if source != nil { + source.MarkInPortal(ctx, portal) + } + return nil + } + if cancellableCtx.Err() != nil { + return cancellableCtx.Err() + } + log := zerolog.Ctx(ctx).With(). + Str("action", "create matrix room"). + Logger() + cancellableCtx = log.WithContext(cancellableCtx) + ctx = log.WithContext(ctx) + log.Info().Msg("Creating Matrix room") + + var err error + if info == nil || info.Members == nil { + if info != nil { + log.Warn().Msg("CreateMatrixRoom got info without members. Refetching info") + } + info, err = source.Client.GetChatInfo(cancellableCtx, portal) + if err != nil { + log.Err(err).Msg("Failed to update portal info for creation") + return err + } + } + + portal.UpdateInfo(cancellableCtx, info, source, nil, time.Time{}) + if cancellableCtx.Err() != nil { + return cancellableCtx.Err() + } + + powerLevels := &event.PowerLevelsEventContent{ + Events: map[string]int{ + event.StateTombstone.Type: 100, + event.StateServerACL.Type: 100, + event.StateEncryption.Type: 100, + }, + Users: map[id.UserID]int{ + portal.Bridge.Bot.GetMXID(): 9001, + }, + } + initialMembers, extraFunctionalMembers, err := portal.getInitialMemberList(cancellableCtx, info.Members, source, powerLevels) + if err != nil { + log.Err(err).Msg("Failed to process participant list for portal creation") + return err + } + powerLevels.EnsureUserLevel(portal.Bridge.Bot.GetMXID(), 9001) + + req := mautrix.ReqCreateRoom{ + Visibility: "private", + CreationContent: make(map[string]any), + InitialState: make([]*event.Event, 0, 6), + Preset: "private_chat", + IsDirect: portal.RoomType == database.RoomTypeDM, + PowerLevelOverride: powerLevels, + BeeperLocalRoomID: portal.Bridge.Matrix.GenerateDeterministicRoomID(portal.PortalKey), + } + autoJoinInvites := portal.Bridge.Matrix.GetCapabilities().AutoJoinInvites + if autoJoinInvites { + req.BeeperInitialMembers = initialMembers + // TODO remove this after initial_members is supported in hungryserv + req.BeeperAutoJoinInvites = true + req.Invite = initialMembers + } + if portal.RoomType == database.RoomTypeSpace { + req.CreationContent["type"] = event.RoomTypeSpace + } + bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() + roomFeatures := source.Client.GetCapabilities(cancellableCtx, portal) + portal.CapState = database.CapabilityState{ + Source: source.ID, + ID: roomFeatures.GetID(), + } + + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateElementFunctionalMembers, + Content: event.Content{Parsed: &event.ElementFunctionalMembersContent{ + ServiceMembers: append(extraFunctionalMembers, portal.Bridge.Bot.GetMXID()), + }}, + }, &event.Event{ + StateKey: &bridgeInfoStateKey, + Type: event.StateHalfShotBridge, + Content: event.Content{Parsed: &bridgeInfo}, + }, &event.Event{ + StateKey: &bridgeInfoStateKey, + Type: event.StateBridge, + Content: event.Content{Parsed: &bridgeInfo}, + }, &event.Event{ + StateKey: &bridgeInfoStateKey, + Type: event.StateBeeperRoomFeatures, + Content: event.Content{Parsed: roomFeatures}, + }, &event.Event{ + Type: event.StateTopic, + Content: event.Content{ + Parsed: &event.TopicEventContent{Topic: portal.Topic}, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, + }) + if roomFeatures.DisappearingTimer != nil { + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateBeeperDisappearingTimer, + Content: event.Content{ + Parsed: portal.Disappear.ToEventContent(), + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, + }) + portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet + } + if portal.Name != "" { + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateRoomName, + Content: event.Content{ + Parsed: &event.RoomNameEventContent{Name: portal.Name}, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, + }) + } + if portal.AvatarMXC != "" { + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, + Raw: map[string]any{ + "com.beeper.exclude_from_timeline": true, + }, + }, + }) + } + if portal.Parent != nil && portal.Parent.MXID != "" { + req.InitialState = append(req.InitialState, &event.Event{ + StateKey: ptr.Ptr(portal.Parent.MXID.String()), + Type: event.StateSpaceParent, + Content: event.Content{Parsed: &event.SpaceParentEventContent{ + Via: []string{portal.Bridge.Matrix.ServerName()}, + Canonical: true, + }}, + }) + } + if info.JoinRule != nil { + req.InitialState = append(req.InitialState, &event.Event{ + Type: event.StateJoinRules, + Content: event.Content{Parsed: info.JoinRule}, + }) + } + if cancellableCtx.Err() != nil { + return cancellableCtx.Err() + } + roomID, err := portal.Bridge.Bot.CreateRoom(ctx, &req) + if err != nil { + log.Err(err).Msg("Failed to create Matrix room") + return err + } + log.Info().Stringer("room_id", roomID).Msg("Matrix room created") + portal.AvatarSet = true + portal.TopicSet = true + portal.NameSet = true + portal.MXID = roomID + portal.RoomCreated.Set() + portal.Bridge.cacheLock.Lock() + portal.Bridge.portalsByMXID[roomID] = portal + portal.Bridge.cacheLock.Unlock() + portal.updateLogger() + err = portal.Save(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal to database after creating Matrix room") + return err + } + if info.CanBackfill && portal.RoomType != database.RoomTypeSpace { + err = portal.Bridge.DB.BackfillTask.Upsert(ctx, &database.BackfillTask{ + PortalKey: portal.PortalKey, + UserLoginID: source.ID, + NextDispatchMinTS: time.Now().Add(BackfillMinBackoffAfterRoomCreate), + }) + if err != nil { + log.Err(err).Msg("Failed to create backfill queue task after creating room") + } + portal.Bridge.WakeupBackfillQueue() + } + withoutCancelCtx := zerolog.Ctx(ctx).WithContext(portal.backgroundCtx) + if portal.Parent != nil { + if portal.Parent.MXID != "" { + portal.addToParentSpaceAndSave(ctx, true) + } else { + log.Info().Msg("Parent portal doesn't exist, creating in background") + go portal.createParentAndAddToSpace(withoutCancelCtx, source) + } + } + portal.updateUserLocalInfo(ctx, info.UserLocal, source, true) + if !autoJoinInvites { + if info.Members == nil { + dp := source.User.DoublePuppet(ctx) + if dp != nil { + err = dp.EnsureJoined(ctx, portal.MXID) + if err != nil { + log.Err(err).Msg("Failed to ensure user is joined to room after creation") + } + } + } else { + err = portal.syncParticipants(ctx, info.Members, source, nil, time.Time{}) + if err != nil { + log.Err(err).Msg("Failed to sync participants after room creation") + } + } + } + portal.addToUserSpaces(ctx) + if info.CanBackfill && + portal.Bridge.Config.Backfill.Enabled && + portal.RoomType != database.RoomTypeSpace && + !portal.Bridge.Background { + portal.doForwardBackfill(ctx, source, nil, backfillBundle) + } + return nil +} + +func (portal *Portal) addToUserSpaces(ctx context.Context) { + if portal.Parent != nil { + return + } + log := zerolog.Ctx(ctx) + withoutCancelCtx := log.WithContext(portal.backgroundCtx) + if portal.Receiver != "" { + login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) + if login != nil { + up, err := portal.Bridge.DB.UserPortal.GetOrCreate(ctx, login.UserLogin, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get user portal to add portal to spaces") + } else { + login.inPortalCache.Remove(portal.PortalKey) + go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) + } + } + } else { + userPortals, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + log.Err(err).Msg("Failed to get user logins in portal to add portal to spaces") + } else { + for _, up := range userPortals { + login := portal.Bridge.GetCachedUserLoginByID(up.LoginID) + if login != nil { + login.inPortalCache.Remove(portal.PortalKey) + go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) + } + } + } + } +} + +func (portal *Portal) Delete(ctx context.Context) error { + if portal.backgroundCtx.Err() != nil { + return nil + } + portal.removeInPortalCache(ctx) + err := portal.safeDBDelete(ctx) + if err != nil { + return err + } + portal.Bridge.cacheLock.Lock() + defer portal.Bridge.cacheLock.Unlock() + portal.unlockedDeleteCache() + return nil +} + +func (portal *Portal) safeDBDelete(ctx context.Context) error { + err := portal.Bridge.DB.Message.DeleteInChunks(ctx, portal.PortalKey) + if err != nil { + return fmt.Errorf("failed to delete messages in portal: %w", err) + } + // TODO delete child portals? + return portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) +} + +func (portal *Portal) RemoveMXID(ctx context.Context) error { + return portal.removeMXID(ctx, false) +} + +func (portal *Portal) removeMXID(ctx context.Context, alreadyLocked bool) error { + if portal.MXID == "" { + return nil + } + oldMXID := portal.MXID + portal.MXID = "" + portal.Relay = nil + portal.RelayLoginID = "" + portal.RoomCreated.Clear() + err := portal.Save(ctx) + if err != nil { + return err + } + err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to update in_space flag for user portals after removing portal MXID") + } + portal.removeInPortalCache(ctx) + if !alreadyLocked { + portal.Bridge.cacheLock.Lock() + defer portal.Bridge.cacheLock.Unlock() + } + delete(portal.Bridge.portalsByMXID, oldMXID) + return nil +} + +func (portal *Portal) removeInPortalCache(ctx context.Context) { + if portal.Receiver != "" { + login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) + if login != nil { + login.inPortalCache.Remove(portal.PortalKey) + } + return + } + userPortals, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get user logins in portal to remove user portal cache") + } else { + for _, up := range userPortals { + login := portal.Bridge.GetCachedUserLoginByID(up.LoginID) + if login != nil { + login.inPortalCache.Remove(portal.PortalKey) + } + } + } +} + +func (portal *Portal) unlockedDelete(ctx context.Context) error { + if portal.backgroundCtx.Err() != nil { + return nil + } + err := portal.safeDBDelete(ctx) + if err != nil { + return err + } + portal.unlockedDeleteCache() + return nil +} + +func (portal *Portal) unlockedDeleteCache() { + if portal.backgroundCtx.Err() != nil { + return + } + delete(portal.Bridge.portalsByKey, portal.PortalKey) + if portal.MXID != "" { + delete(portal.Bridge.portalsByMXID, portal.MXID) + } + portal.cancelBackground() + if portal.events != nil { + // TODO there's a small risk of this racing with a queueEvent call + close(portal.events) + } +} + +func (portal *Portal) Save(ctx context.Context) error { + return portal.Bridge.DB.Portal.Update(ctx, portal.Portal) +} + +func (portal *Portal) SetRelay(ctx context.Context, relay *UserLogin) error { + if portal.Receiver != "" && relay.ID != portal.Receiver { + return fmt.Errorf("can't set non-receiver login as relay") + } + portal.Relay = relay + if relay == nil { + portal.RelayLoginID = "" + } else { + portal.RelayLoginID = relay.ID + } + err := portal.Save(ctx) + if err != nil { + return err + } + return nil +} diff --git a/mautrix-patched/bridgev2/portalbackfill.go b/mautrix-patched/bridgev2/portalbackfill.go new file mode 100644 index 00000000..c656ada1 --- /dev/null +++ b/mautrix-patched/bridgev2/portalbackfill.go @@ -0,0 +1,646 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + "go.mau.fi/util/variationselector" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (portal *Portal) doForwardBackfill(ctx context.Context, source *UserLogin, lastMessage *database.Message, bundledData any) { + log := zerolog.Ctx(ctx).With().Str("action", "forward backfill").Logger() + if !portal.forwardBackfillLock.TryLock() { + log.Warn().Msg("Another forward backfill is already running") + return + } + defer portal.forwardBackfillLock.Unlock() + ctx = log.WithContext(ctx) + api, ok := source.Client.(BackfillingNetworkAPI) + if !ok { + log.Debug().Msg("Network API does not support backfilling") + return + } + var limit int + var latestMessageID string + if lastMessage != nil { + latestMessageID = string(lastMessage.ID) + limit = portal.Bridge.Config.Backfill.MaxCatchupMessages + } else { + limit = portal.Bridge.Config.Backfill.MaxInitialMessages + } + if limit <= 0 { + return + } + log.Info().Str("latest_message_id", latestMessageID).Msg("Fetching messages for forward backfill") + resp, err := api.FetchMessages(ctx, FetchMessagesParams{ + Portal: portal, + ThreadRoot: "", + Forward: true, + AnchorMessage: lastMessage, + Count: limit, + BundledData: bundledData, + }) + if err != nil { + log.Err(err).Msg("Failed to fetch messages for forward backfill") + return + } else if resp == nil { + log.Debug().Msg("Didn't get backfill response") + return + } else if len(resp.Messages) == 0 { + log.Debug().Msg("No messages to backfill") + if resp.CompleteCallback != nil { + resp.CompleteCallback() + } + return + } + log.Debug(). + Int("message_count", len(resp.Messages)). + Bool("mark_read", resp.MarkRead). + Bool("aggressive_deduplication", resp.AggressiveDeduplication). + Str("queried_latest_message_id", latestMessageID). + Msg("Fetched messages for forward backfill, deduplicating before sending") + // TODO mark backfill queue task as done if last message is nil (-> room was empty) and HasMore is false? + resp.Messages = portal.cutoffMessages(ctx, resp.Messages, resp.AggressiveDeduplication, true, lastMessage) + if len(resp.Messages) == 0 { + log.Warn().Msg("No messages left to backfill after cutting off old messages") + if resp.CompleteCallback != nil { + resp.CompleteCallback() + } + return + } + err = portal.sendBackfill(ctx, source, resp.Messages, true, resp.MarkRead, false, resp.CompleteCallback) + if err != nil { + log.Err(err).Msg("Failed to send forward backfill") + } +} + +var errNoMessagesLeftAfterCutoff = errors.New("no messages left to backfill after cutting off too new messages") + +func (portal *Portal) doBackwardsBackfill(ctx context.Context, source *UserLogin, task *database.BackfillTask, resp *FetchMessagesResponse) (bool, error) { + log := zerolog.Ctx(ctx) + api, ok := source.Client.(BackfillingNetworkAPI) + if !ok { + return false, fmt.Errorf("network API does not support backfilling") + } + firstMessage, err := portal.Bridge.DB.Message.GetFirstPortalMessage(ctx, portal.PortalKey) + if err != nil { + return false, fmt.Errorf("failed to get first portal message: %w", err) + } + logEvt := log.Info(). + Str("cursor", string(task.Cursor)). + Str("task_oldest_message_id", string(task.OldestMessageID)). + Int("current_batch_count", task.BatchCount) + if firstMessage != nil { + logEvt = logEvt.Str("db_oldest_message_id", string(firstMessage.ID)) + } else { + logEvt = logEvt.Str("db_oldest_message_id", "") + } + if resp == nil { + logEvt.Msg("Fetching messages for backward backfill") + resp, err = api.FetchMessages(ctx, FetchMessagesParams{ + Portal: portal, + ThreadRoot: "", + Forward: false, + Cursor: task.Cursor, + AnchorMessage: firstMessage, + Count: portal.Bridge.Config.Backfill.Queue.BatchSize, + Task: task, + AllowSlowFetch: !task.FromQueue || !portal.Bridge.Config.Backfill.Queue.Manual, + }) + if err != nil { + return false, fmt.Errorf("failed to fetch messages for backward backfill: %w", err) + } else if resp == nil { + log.Debug().Msg("Didn't get backfill response, marking task as done") + task.IsDone = true + task.QueueDone = true + return false, nil + } else if resp.Pending { + log.Debug().Msg("Backfill response is pending") + // TODO make configurable by admin and/or network connector? + task.NextDispatchMinTS = time.Now().Add(24 * time.Hour) + return true, nil + } else if resp.MoreRequiresSlowFetch { + if !task.FromQueue { + log.Warn().Msg("Backfill response indicates more messages require slow fetching even though task is not from queue") + } else { + log.Debug().Msg("Backfill response indicates more messages require slow fetching, setting queue done flag") + } + task.QueueDone = true + return true, nil + } + } else { + log.Debug().Msg("Using data from event for backwards backfill") + } + log.Debug(). + Str("new_cursor", string(resp.Cursor)). + Bool("has_more", resp.HasMore). + Int("message_count", len(resp.Messages)). + Msg("Fetched messages for backward backfill") + task.Cursor = resp.Cursor + if !resp.HasMore { + task.IsDone = true + task.QueueDone = true + } + if len(resp.Messages) == 0 { + if !resp.HasMore { + log.Debug().Msg("No messages to backfill, marking backfill task as done") + } else { + log.Warn().Msg("No messages to backfill, but HasMore is true") + } + if resp.CompleteCallback != nil { + resp.CompleteCallback() + } + return false, nil + } + resp.Messages = portal.cutoffMessages(ctx, resp.Messages, resp.AggressiveDeduplication, false, firstMessage) + if len(resp.Messages) == 0 { + if resp.CompleteCallback != nil { + resp.CompleteCallback() + } + // Hack to handle some migrated portals where message timestamps are unknown. + // They can't be backfilled further, so just mark them as done. + if firstMessage != nil && firstMessage.Timestamp.Unix() == 0 { + task.IsDone = true + task.QueueDone = true + return false, nil + } + return false, errNoMessagesLeftAfterCutoff + } + err = portal.sendBackfill(ctx, source, resp.Messages, false, resp.MarkRead, false, resp.CompleteCallback) + if err != nil { + return false, fmt.Errorf("failed to send backward backfill: %w", err) + } + if len(resp.Messages) > 0 { + task.OldestMessageID = resp.Messages[0].ID + } + return false, nil +} + +func (portal *Portal) fetchThreadBackfill(ctx context.Context, source *UserLogin, anchor *database.Message) *FetchMessagesResponse { + log := zerolog.Ctx(ctx) + resp, err := source.Client.(BackfillingNetworkAPI).FetchMessages(ctx, FetchMessagesParams{ + Portal: portal, + ThreadRoot: anchor.ID, + Forward: true, + AnchorMessage: anchor, + Count: portal.Bridge.Config.Backfill.Threads.MaxInitialMessages, + }) + if err != nil { + log.Err(err).Msg("Failed to fetch messages for thread backfill") + return nil + } else if resp == nil { + log.Debug().Msg("Didn't get backfill response") + return nil + } else if len(resp.Messages) == 0 { + log.Debug().Msg("No messages to backfill") + return nil + } + resp.Messages = portal.cutoffMessages(ctx, resp.Messages, resp.AggressiveDeduplication, true, anchor) + if len(resp.Messages) == 0 { + log.Warn().Msg("No messages left to backfill after cutting off old messages") + if resp.CompleteCallback != nil { + resp.CompleteCallback() + } + return nil + } + return resp +} + +func (portal *Portal) doThreadBackfill(ctx context.Context, source *UserLogin, threadID networkid.MessageID) error { + log := zerolog.Ctx(ctx).With(). + Str("subaction", "thread backfill"). + Str("thread_id", string(threadID)). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Backfilling thread inside other backfill") + anchorMessage, err := portal.Bridge.DB.Message.GetLastThreadMessage(ctx, portal.PortalKey, threadID) + if err != nil { + return fmt.Errorf("failed to get last thread message: %w", err) + } else if anchorMessage == nil { + log.Warn().Msg("No messages found in thread?") + return nil + } + resp := portal.fetchThreadBackfill(ctx, source, anchorMessage) + if resp != nil { + return portal.sendBackfill(ctx, source, resp.Messages, true, resp.MarkRead, true, resp.CompleteCallback) + } + return nil +} + +func (portal *Portal) cutoffMessages(ctx context.Context, messages []*BackfillMessage, aggressiveDedup, forward bool, lastMessage *database.Message) []*BackfillMessage { + if lastMessage == nil { + return messages + } + if forward { + cutoff := -1 + for i, msg := range messages { + if msg.ID == lastMessage.ID || msg.Timestamp.Before(lastMessage.Timestamp) { + cutoff = i + } else { + break + } + } + if cutoff != -1 { + zerolog.Ctx(ctx).Debug(). + Int("cutoff_count", cutoff+1). + Int("total_count", len(messages)). + Time("last_bridged_ts", lastMessage.Timestamp). + Msg("Cutting off forward backfill messages older than latest bridged message") + messages = messages[cutoff+1:] + } + } else { + cutoff := -1 + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].ID == lastMessage.ID || messages[i].Timestamp.After(lastMessage.Timestamp) { + cutoff = i + } else { + break + } + } + if cutoff != -1 { + zerolog.Ctx(ctx).Debug(). + Int("cutoff_count", len(messages)-cutoff). + Int("total_count", len(messages)). + Time("oldest_bridged_ts", lastMessage.Timestamp). + Msg("Cutting off backward backfill messages newer than oldest bridged message") + messages = messages[:cutoff] + } + } + if aggressiveDedup { + filteredMessages := messages[:0] + for _, msg := range messages { + existingMsg, err := portal.Bridge.DB.Message.GetFirstPartByID(ctx, portal.Receiver, msg.ID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("message_id", string(msg.ID)).Msg("Failed to check for existing message") + } else if existingMsg != nil { + zerolog.Ctx(ctx).Err(err). + Str("message_id", string(msg.ID)). + Time("message_ts", msg.Timestamp). + Str("message_sender", string(msg.Sender.Sender)). + Msg("Ignoring duplicate message in backfill") + continue + } + if forward && msg.TxnID != "" { + wasPending, _ := portal.checkPendingMessage(ctx, msg) + if wasPending { + zerolog.Ctx(ctx).Err(err). + Str("transaction_id", string(msg.TxnID)). + Str("message_id", string(msg.ID)). + Time("message_ts", msg.Timestamp). + Msg("Found pending message in backfill") + continue + } + } + filteredMessages = append(filteredMessages, msg) + } + messages = filteredMessages + } + return messages +} + +func (portal *Portal) sendBackfill( + ctx context.Context, + source *UserLogin, + messages []*BackfillMessage, + forceForward, + markRead, + inThread bool, + done func(), +) error { + canBatchSend := portal.Bridge.Matrix.GetCapabilities().BatchSending + unreadThreshold := time.Duration(portal.Bridge.Config.Backfill.UnreadHoursThreshold) * time.Hour + forceMarkRead := unreadThreshold > 0 && time.Since(messages[len(messages)-1].Timestamp) > unreadThreshold + zerolog.Ctx(ctx).Info(). + Int("message_count", len(messages)). + Bool("batch_send", canBatchSend). + Bool("mark_read", markRead). + Bool("mark_read_past_threshold", forceMarkRead). + Msg("Sending backfill messages") + var err error + if canBatchSend { + err = portal.sendBatch(ctx, source, messages, forceForward, markRead || forceMarkRead, inThread) + } else { + err = portal.sendLegacyBackfill(ctx, source, messages, markRead || forceMarkRead) + } + if err != nil { + return err + } + if done != nil { + done() + } + zerolog.Ctx(ctx).Debug().Msg("Backfill finished") + if !canBatchSend && !inThread && portal.Bridge.Config.Backfill.Threads.MaxInitialMessages > 0 { + for _, msg := range messages { + if msg.ShouldBackfillThread { + err = portal.doThreadBackfill(ctx, source, msg.ID) + if err != nil { + zerolog.Ctx(ctx).Debug(). + Str("thread_id", string(msg.ID)). + Msg("Failed to backfill thread") + } + } + } + } + return nil +} + +type compileBatchOutput struct { + PrevThreadEvents map[networkid.MessageID]id.EventID + + Events []*event.Event + Extras []*MatrixSendExtra + + DBMessages []*database.Message + DBReactions []*database.Reaction + Disappear []*database.DisappearingMessage +} + +func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin, msg *BackfillMessage, out *compileBatchOutput, inThread bool) { + if len(msg.Parts) == 0 { + return + } + intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) + if !ok { + return + } + replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta( + ctx, msg.ID, msg.ConvertedMessage, true, + ) + if threadRoot != nil && out.PrevThreadEvents[*msg.ThreadRoot] != "" { + prevThreadEvent.MXID = out.PrevThreadEvents[*msg.ThreadRoot] + } + var partIDs []networkid.PartID + partMap := make(map[networkid.PartID]*database.Message, len(msg.Parts)) + var firstPart *database.Message + for i, part := range msg.Parts { + partIDs = append(partIDs, part.ID) + portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) + part.Content.BeeperDisappearingTimer = msg.Disappear.ToEventContent() + evtID := portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, part.ID) + dbMessage := &database.Message{ + ID: msg.ID, + PartID: part.ID, + MXID: evtID, + Room: portal.PortalKey, + SenderID: msg.Sender.Sender, + SenderMXID: intent.GetMXID(), + Timestamp: msg.Timestamp, + ThreadRoot: ptr.Val(msg.ThreadRoot), + ReplyTo: ptr.Val(msg.ReplyTo), + Metadata: part.DBMetadata, + IsDoublePuppeted: intent.IsDoublePuppet(), + } + if part.DontBridge { + dbMessage.SetFakeMXID() + out.DBMessages = append(out.DBMessages, dbMessage) + continue + } + out.Events = append(out.Events, &event.Event{ + Sender: intent.GetMXID(), + Type: part.Type, + Timestamp: msg.Timestamp.UnixMilli(), + ID: evtID, + RoomID: portal.MXID, + Content: event.Content{ + Parsed: part.Content, + Raw: part.Extra, + }, + }) + if firstPart == nil { + firstPart = dbMessage + } + partMap[part.ID] = dbMessage + out.Extras = append(out.Extras, &MatrixSendExtra{MessageMeta: dbMessage, StreamOrder: msg.StreamOrder, PartIndex: i}) + out.DBMessages = append(out.DBMessages, dbMessage) + if prevThreadEvent != nil { + prevThreadEvent.MXID = evtID + out.PrevThreadEvents[*msg.ThreadRoot] = evtID + } + if msg.Disappear.Type != event.DisappearingTypeNone { + if msg.Disappear.Type == event.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() { + msg.Disappear.DisappearAt = msg.Timestamp.Add(msg.Disappear.Timer) + } + out.Disappear = append(out.Disappear, &database.DisappearingMessage{ + RoomID: portal.MXID, + EventID: evtID, + Timestamp: msg.Timestamp, + DisappearingSetting: msg.Disappear, + }) + } + } + slices.Sort(partIDs) + for _, reaction := range msg.Reactions { + if reaction == nil { + continue + } + reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove) + if !ok { + continue + } + if reaction.TargetPart == nil { + reaction.TargetPart = &partIDs[0] + } + if reaction.Timestamp.IsZero() { + reaction.Timestamp = msg.Timestamp.Add(10 * time.Millisecond) + } + //lint:ignore SA4006 it's a todo + targetPart, ok := partMap[*reaction.TargetPart] + if !ok { + // TODO warning log and/or skip reaction? + } + reactionMXID := portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, targetPart, reaction.Sender.Sender, reaction.EmojiID) + dbReaction := &database.Reaction{ + Room: portal.PortalKey, + MessageID: msg.ID, + MessagePartID: *reaction.TargetPart, + SenderID: reaction.Sender.Sender, + EmojiID: reaction.EmojiID, + MXID: reactionMXID, + Timestamp: reaction.Timestamp, + Emoji: reaction.Emoji, + Metadata: reaction.DBMetadata, + } + out.Events = append(out.Events, &event.Event{ + Sender: reactionIntent.GetMXID(), + Type: event.EventReaction, + Timestamp: reaction.Timestamp.UnixMilli(), + ID: reactionMXID, + RoomID: portal.MXID, + Content: event.Content{ + Parsed: &event.ReactionEventContent{ + RelatesTo: event.RelatesTo{ + Type: event.RelAnnotation, + EventID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, *reaction.TargetPart), + Key: variationselector.Add(reaction.Emoji), + }, + }, + Raw: reaction.ExtraContent, + }, + }) + out.DBReactions = append(out.DBReactions, dbReaction) + out.Extras = append(out.Extras, &MatrixSendExtra{ReactionMeta: dbReaction}) + } + if firstPart != nil && !inThread && portal.Bridge.Config.Backfill.Threads.MaxInitialMessages > 0 && msg.ShouldBackfillThread { + portal.fetchThreadInsideBatch(ctx, source, firstPart, out) + } +} + +func (portal *Portal) fetchThreadInsideBatch(ctx context.Context, source *UserLogin, dbMsg *database.Message, out *compileBatchOutput) { + log := zerolog.Ctx(ctx).With(). + Str("subaction", "thread backfill in batch"). + Str("thread_id", string(dbMsg.ID)). + Logger() + ctx = log.WithContext(ctx) + resp := portal.fetchThreadBackfill(ctx, source, dbMsg) + if resp != nil { + for _, msg := range resp.Messages { + portal.compileBatchMessage(ctx, source, msg, out, true) + } + } +} + +func (portal *Portal) sendBatch(ctx context.Context, source *UserLogin, messages []*BackfillMessage, forceForward, markRead, inThread bool) error { + out := &compileBatchOutput{ + PrevThreadEvents: make(map[networkid.MessageID]id.EventID), + Events: make([]*event.Event, 0, len(messages)), + Extras: make([]*MatrixSendExtra, 0, len(messages)), + DBMessages: make([]*database.Message, 0, len(messages)), + DBReactions: make([]*database.Reaction, 0), + Disappear: make([]*database.DisappearingMessage, 0), + } + for _, msg := range messages { + portal.compileBatchMessage(ctx, source, msg, out, inThread) + } + req := &mautrix.ReqBeeperBatchSend{ + ForwardIfNoMessages: !forceForward, + Forward: forceForward, + SendNotification: !markRead && forceForward && !inThread, + Events: out.Events, + } + if markRead { + req.MarkReadBy = source.UserMXID + } + _, err := portal.Bridge.Matrix.BatchSend(ctx, portal.MXID, req, out.Extras) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to send backfill messages") + return err + } + if len(out.Disappear) > 0 { + // TODO mass insert disappearing messages + go func() { + for _, msg := range out.Disappear { + portal.Bridge.DisappearLoop.Add(ctx, msg) + } + }() + } + // TODO mass insert db messages + for _, msg := range out.DBMessages { + err = portal.Bridge.DB.Message.Insert(ctx, msg) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("message_id", string(msg.ID)). + Str("part_id", string(msg.PartID)). + Str("sender_id", string(msg.SenderID)). + Str("portal_id", string(msg.Room.ID)). + Str("portal_receiver", string(msg.Room.Receiver)). + Msg("Failed to insert backfilled message to database") + } + } + // TODO mass insert db reactions + for _, react := range out.DBReactions { + err = portal.Bridge.DB.Reaction.Upsert(ctx, react) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Str("message_id", string(react.MessageID)). + Str("part_id", string(react.MessagePartID)). + Str("sender_id", string(react.SenderID)). + Str("portal_id", string(react.Room.ID)). + Str("portal_receiver", string(react.Room.Receiver)). + Msg("Failed to insert backfilled reaction to database") + } + } + return nil +} + +func (portal *Portal) sendLegacyBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, markRead bool) error { + var lastPart id.EventID + for _, msg := range messages { + if ctx.Err() != nil { + return ctx.Err() + } + intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) + if !ok { + continue + } + dbMessages, res := portal.sendConvertedMessage(ctx, msg.ID, intent, msg.Sender.Sender, msg.ConvertedMessage, msg.Timestamp, msg.StreamOrder, func(z *zerolog.Event) *zerolog.Event { + return z. + Str("message_id", string(msg.ID)). + Any("sender_id", msg.Sender). + Time("message_ts", msg.Timestamp) + }) + if !res.Success { + return res.Error + } + if len(dbMessages) > 0 { + lastPart = dbMessages[len(dbMessages)-1].MXID + for _, reaction := range msg.Reactions { + if ctx.Err() != nil { + return ctx.Err() + } + reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReaction) + if !ok { + continue + } + targetPart := dbMessages[0] + if reaction.TargetPart != nil { + targetPartIdx := slices.IndexFunc(dbMessages, func(dbMsg *database.Message) bool { + return dbMsg.PartID == *reaction.TargetPart + }) + if targetPartIdx != -1 { + targetPart = dbMessages[targetPartIdx] + } else { + // TODO warning log and/or skip reaction? + } + } + portal.sendConvertedReaction( + ctx, reaction.Sender.Sender, reactionIntent, targetPart, reaction.EmojiID, reaction.Emoji, + reaction.Timestamp, reaction.DBMetadata, reaction.ExtraContent, + func(z *zerolog.Event) *zerolog.Event { + return z. + Str("target_message_id", string(msg.ID)). + Str("target_part_id", string(targetPart.PartID)). + Any("reaction_sender_id", reaction.Sender). + Time("reaction_ts", reaction.Timestamp) + }, + ) + } + } + } + if markRead { + dp := source.User.DoublePuppet(ctx) + if dp != nil { + err := dp.MarkRead(ctx, portal.MXID, lastPart, time.Now()) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to mark room as read after backfill") + } + } + } + return nil +} diff --git a/mautrix-patched/bridgev2/portalinternal.go b/mautrix-patched/bridgev2/portalinternal.go new file mode 100644 index 00000000..a9ec3a10 --- /dev/null +++ b/mautrix-patched/bridgev2/portalinternal.go @@ -0,0 +1,418 @@ +// GENERATED BY portalinternal_generate.go; DO NOT EDIT + +//go:generate go run portalinternal_generate.go +//go:generate goimports -local maunium.net/go/mautrix -w portalinternal.go + +package bridgev2 + +import ( + "context" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type PortalInternals Portal + +// Deprecated: portal internals should be used carefully and only when necessary. +func (portal *Portal) Internal() *PortalInternals { + return (*PortalInternals)(portal) +} + +func (portal *PortalInternals) UpdateLogger() { + (*Portal)(portal).updateLogger() +} + +func (portal *PortalInternals) QueueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { + return (*Portal)(portal).queueEvent(ctx, evt) +} + +func (portal *PortalInternals) EventLoop() { + (*Portal)(portal).eventLoop() +} + +func (portal *PortalInternals) HandleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) { + return (*Portal)(portal).handleSingleEventWithDelayLogging(idx, rawEvt) +} + +func (portal *PortalInternals) GetEventCtxWithLog(rawEvt any, idx int) context.Context { + return (*Portal)(portal).getEventCtxWithLog(rawEvt, idx) +} + +func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(EventHandlingResult)) { + (*Portal)(portal).handleSingleEvent(ctx, rawEvt, doneCallback) +} + +func (portal *PortalInternals) UnwrapBeeperSendState(ctx context.Context, evt *event.Event) error { + return (*Portal)(portal).unwrapBeeperSendState(ctx, evt) +} + +func (portal *PortalInternals) SendSuccessStatus(ctx context.Context, evt *event.Event, streamOrder int64, newEventID id.EventID) { + (*Portal)(portal).sendSuccessStatus(ctx, evt, streamOrder, newEventID) +} + +func (portal *PortalInternals) SendErrorStatus(ctx context.Context, evt *event.Event, err error) { + (*Portal)(portal).sendErrorStatus(ctx, evt, err) +} + +func (portal *PortalInternals) CheckConfusableName(ctx context.Context, userID id.UserID, name string) bool { + return (*Portal)(portal).checkConfusableName(ctx, userID, name) +} + +func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult { + return (*Portal)(portal).handleMatrixEvent(ctx, sender, evt, isStateRequest) +} + +func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixReceipts(ctx, evt) +} + +func (portal *PortalInternals) HandleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) { + (*Portal)(portal).handleMatrixReadReceipt(ctx, user, eventID, receipt) +} + +func (portal *PortalInternals) CallReadReceiptHandler(ctx context.Context, login *UserLogin, rrClient ReadReceiptHandlingNetworkAPI, evt *MatrixReadReceipt, userPortal *database.UserPortal) { + (*Portal)(portal).callReadReceiptHandler(ctx, login, rrClient, evt, userPortal) +} + +func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixTyping(ctx, evt) +} + +func (portal *PortalInternals) SendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { + (*Portal)(portal).sendTypings(ctx, userIDs, typing) +} + +func (portal *PortalInternals) PeriodicTypingUpdater() { + (*Portal)(portal).periodicTypingUpdater() +} + +func (portal *PortalInternals) CheckMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error { + return (*Portal)(portal).checkMessageContentCaps(caps, content) +} + +func (portal *PortalInternals) ParseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { + return (*Portal)(portal).parseInputTransactionID(origSender, evt) +} + +func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt) +} + +func (portal *PortalInternals) PendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { + (*Portal)(portal).pendingMessageTimeoutLoop(ctx, cfg) +} + +func (portal *PortalInternals) CheckPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) { + (*Portal)(portal).checkPendingMessages(ctx, cfg) +} + +func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) EventHandlingResult { + return (*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps) +} + +func (portal *PortalInternals) HandleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) (handleRes EventHandlingResult) { + return (*Portal)(portal).handleMatrixReaction(ctx, sender, nil, evt) +} + +func (portal *PortalInternals) GetTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) { + return (*Portal)(portal).getTargetUser(ctx, userID) +} + +func (portal *PortalInternals) HandleMatrixAcceptMessageRequest(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixAcceptMessageRequest(ctx, sender, origSender, evt) +} + +func (portal *PortalInternals) AutoAcceptMessageRequest(ctx context.Context, evt *event.Event, sender *UserLogin, origSender *OrigSender, caps *event.RoomFeatures) error { + return (*Portal)(portal).autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) +} + +func (portal *PortalInternals) HandleMatrixDeleteChat(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixDeleteChat(ctx, sender, origSender, evt) +} + +func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult { + return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt, isStateRequest) +} + +func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult { + return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt, isStateRequest) +} + +func (portal *PortalInternals) HandleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixTombstone(ctx, evt) +} + +func (portal *PortalInternals) UpdateInfoAfterTombstone(ctx context.Context, senderUser *User) { + (*Portal)(portal).updateInfoAfterTombstone(ctx, senderUser) +} + +func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) +} + +func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { + return (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt) +} + +func (portal *PortalInternals) EnsureFunctionalMember(ctx context.Context, ghost *Ghost) { + (*Portal)(portal).ensureFunctionalMember(ctx, ghost) +} + +func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { + return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType) +} + +func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsgID networkid.MessageID, currentMsg *ConvertedMessage, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) { + return (*Portal)(portal).getRelationMeta(ctx, currentMsgID, currentMsg, isBatchSend) +} + +func (portal *PortalInternals) ApplyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { + (*Portal)(portal).applyRelationMeta(ctx, content, replyTo, threadRoot, prevThreadEvent) +} + +func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) ([]*database.Message, EventHandlingResult) { + return (*Portal)(portal).sendConvertedMessage(ctx, id, intent, senderID, converted, ts, streamOrder, logContext) +} + +func (portal *PortalInternals) CheckPendingMessage(ctx context.Context, evt RemoteMessage) (bool, *database.Message) { + return (*Portal)(portal).checkPendingMessage(ctx, evt) +} + +func (portal *PortalInternals) HandleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) { + return (*Portal)(portal).handleRemoteUpsert(ctx, source, evt, existing) +} + +func (portal *PortalInternals) HandleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) { + return (*Portal)(portal).handleRemoteMessage(ctx, source, evt) +} + +func (portal *PortalInternals) SendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { + (*Portal)(portal).sendRemoteErrorNotice(ctx, intent, err, ts, evtTypeName) +} + +func (portal *PortalInternals) HandleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult { + return (*Portal)(portal).handleRemoteEdit(ctx, source, evt) +} + +func (portal *PortalInternals) SendConvertedEdit(ctx context.Context, targetID networkid.MessageID, senderID networkid.UserID, converted *ConvertedEdit, intent MatrixAPI, ts time.Time, streamOrder int64) EventHandlingResult { + return (*Portal)(portal).sendConvertedEdit(ctx, targetID, senderID, converted, intent, ts, streamOrder) +} + +func (portal *PortalInternals) GetTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) { + return (*Portal)(portal).getTargetMessagePart(ctx, evt) +} + +func (portal *PortalInternals) GetTargetReaction(ctx context.Context, evt RemoteReactionRemove) (*database.Reaction, error) { + return (*Portal)(portal).getTargetReaction(ctx, evt) +} + +func (portal *PortalInternals) HandleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult { + return (*Portal)(portal).handleRemoteReactionSync(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult { + return (*Portal)(portal).handleRemoteReaction(ctx, source, evt) +} + +func (portal *PortalInternals) SendConvertedReaction(ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event) EventHandlingResult { + return (*Portal)(portal).sendConvertedReaction(ctx, senderID, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extraContent, logContext) +} + +func (portal *PortalInternals) GetIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) { + return (*Portal)(portal).getIntentForMXID(ctx, userID) +} + +func (portal *PortalInternals) HandleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult { + return (*Portal)(portal).handleRemoteReactionRemove(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult { + return (*Portal)(portal).handleRemoteMessageRemove(ctx, source, evt) +} + +func (portal *PortalInternals) RedactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time, reason string, dontRenderPlaceholder bool) EventHandlingResult { + return (*Portal)(portal).redactMessageParts(ctx, parts, intent, ts, reason, dontRenderPlaceholder) +} + +func (portal *PortalInternals) HandleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult { + return (*Portal)(portal).handleRemoteReadReceipt(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult { + return (*Portal)(portal).handleRemoteMarkUnread(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { + return (*Portal)(portal).handleRemoteDeliveryReceipt(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult { + return (*Portal)(portal).handleRemoteTyping(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult { + return (*Portal)(portal).handleRemoteChatInfoChange(ctx, source, evt) +} + +func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult { + return (*Portal)(portal).handleRemoteChatResync(ctx, source, evt) +} + +func (portal *PortalInternals) FindOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { + return (*Portal)(portal).findOtherLogins(ctx, source) +} + +func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { + return (*Portal)(portal).handleRemoteChatDelete(ctx, source, evt) +} + +func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { + return (*Portal)(portal).updateName(ctx, name, sender, ts, excludeFromTimeline) +} + +func (portal *PortalInternals) UpdateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { + return (*Portal)(portal).updateTopic(ctx, topic, sender, ts, excludeFromTimeline) +} + +func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { + return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts, excludeFromTimeline) +} + +func (portal *PortalInternals) GetBridgeInfoStateKey() string { + return (*Portal)(portal).getBridgeInfoStateKey() +} + +func (portal *PortalInternals) GetBridgeInfo() (string, event.BridgeEventContent) { + return (*Portal)(portal).getBridgeInfo() +} + +func (portal *PortalInternals) SendStateWithIntentOrBot(ctx context.Context, sender MatrixAPI, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { + return (*Portal)(portal).sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, content, ts) +} + +func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool, extra map[string]any) bool { + return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline, extra) +} + +func (portal *PortalInternals) RevertRoomMeta(ctx context.Context, evt *event.Event) { + (*Portal)(portal).revertRoomMeta(ctx, evt) +} + +func (portal *PortalInternals) GetInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { + return (*Portal)(portal).getInitialMemberList(ctx, members, source, pl) +} + +func (portal *PortalInternals) UpdateOtherUser(ctx context.Context, members *ChatMemberList) (changed bool) { + return (*Portal)(portal).updateOtherUser(ctx, members) +} + +func (portal *PortalInternals) RoomIsPublic(ctx context.Context) bool { + return (*Portal)(portal).roomIsPublic(ctx) +} + +func (portal *PortalInternals) SyncParticipants(ctx context.Context, members *ChatMemberList, source *UserLogin, sender MatrixAPI, ts time.Time) error { + return (*Portal)(portal).syncParticipants(ctx, members, source, sender, ts) +} + +func (portal *PortalInternals) UpdateUserLocalInfo(ctx context.Context, info *UserLocalPortalInfo, source *UserLogin, didJustCreate bool) { + (*Portal)(portal).updateUserLocalInfo(ctx, info, source, didJustCreate) +} + +func (portal *PortalInternals) UpdateParent(ctx context.Context, newParentID networkid.PortalID, source *UserLogin) bool { + return (*Portal)(portal).updateParent(ctx, newParentID, source) +} + +func (portal *PortalInternals) LockedUpdateInfoFromGhost(ctx context.Context, ghost *Ghost) { + (*Portal)(portal).lockedUpdateInfoFromGhost(ctx, ghost) +} + +func (portal *PortalInternals) CreateMatrixRoomInLoop(ctx context.Context, source *UserLogin, info *ChatInfo, backfillBundle any) error { + return (*Portal)(portal).createMatrixRoomInLoop(ctx, source, info, backfillBundle) +} + +func (portal *PortalInternals) AddToUserSpaces(ctx context.Context) { + (*Portal)(portal).addToUserSpaces(ctx) +} + +func (portal *PortalInternals) SafeDBDelete(ctx context.Context) error { + return (*Portal)(portal).safeDBDelete(ctx) +} + +func (portal *PortalInternals) RemoveMXID(ctx context.Context, alreadyLocked bool) error { + return (*Portal)(portal).removeMXID(ctx, alreadyLocked) +} + +func (portal *PortalInternals) RemoveInPortalCache(ctx context.Context) { + (*Portal)(portal).removeInPortalCache(ctx) +} + +func (portal *PortalInternals) UnlockedDelete(ctx context.Context) error { + return (*Portal)(portal).unlockedDelete(ctx) +} + +func (portal *PortalInternals) UnlockedDeleteCache() { + (*Portal)(portal).unlockedDeleteCache() +} + +func (portal *PortalInternals) DoForwardBackfill(ctx context.Context, source *UserLogin, lastMessage *database.Message, bundledData any) { + (*Portal)(portal).doForwardBackfill(ctx, source, lastMessage, bundledData) +} + +func (portal *PortalInternals) DoBackwardsBackfill(ctx context.Context, source *UserLogin, task *database.BackfillTask, resp *FetchMessagesResponse) (bool, error) { + return (*Portal)(portal).doBackwardsBackfill(ctx, source, task, resp) +} + +func (portal *PortalInternals) FetchThreadBackfill(ctx context.Context, source *UserLogin, anchor *database.Message) *FetchMessagesResponse { + return (*Portal)(portal).fetchThreadBackfill(ctx, source, anchor) +} + +func (portal *PortalInternals) DoThreadBackfill(ctx context.Context, source *UserLogin, threadID networkid.MessageID) error { + return (*Portal)(portal).doThreadBackfill(ctx, source, threadID) +} + +func (portal *PortalInternals) CutoffMessages(ctx context.Context, messages []*BackfillMessage, aggressiveDedup, forward bool, lastMessage *database.Message) []*BackfillMessage { + return (*Portal)(portal).cutoffMessages(ctx, messages, aggressiveDedup, forward, lastMessage) +} + +func (portal *PortalInternals) SendBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, forceForward, markRead, inThread bool, done func()) error { + return (*Portal)(portal).sendBackfill(ctx, source, messages, forceForward, markRead, inThread, done) +} + +func (portal *PortalInternals) CompileBatchMessage(ctx context.Context, source *UserLogin, msg *BackfillMessage, out *compileBatchOutput, inThread bool) { + (*Portal)(portal).compileBatchMessage(ctx, source, msg, out, inThread) +} + +func (portal *PortalInternals) FetchThreadInsideBatch(ctx context.Context, source *UserLogin, dbMsg *database.Message, out *compileBatchOutput) { + (*Portal)(portal).fetchThreadInsideBatch(ctx, source, dbMsg, out) +} + +func (portal *PortalInternals) SendBatch(ctx context.Context, source *UserLogin, messages []*BackfillMessage, forceForward, markRead, inThread bool) error { + return (*Portal)(portal).sendBatch(ctx, source, messages, forceForward, markRead, inThread) +} + +func (portal *PortalInternals) SendLegacyBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, markRead bool) error { + return (*Portal)(portal).sendLegacyBackfill(ctx, source, messages, markRead) +} + +func (portal *PortalInternals) UnlockedReID(ctx context.Context, target networkid.PortalKey) error { + return (*Portal)(portal).unlockedReID(ctx, target) +} + +func (portal *PortalInternals) CreateParentAndAddToSpace(ctx context.Context, source *UserLogin) { + (*Portal)(portal).createParentAndAddToSpace(ctx, source) +} + +func (portal *PortalInternals) AddToParentSpaceAndSave(ctx context.Context, save bool) { + (*Portal)(portal).addToParentSpaceAndSave(ctx, save) +} + +func (portal *PortalInternals) ToggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error { + return (*Portal)(portal).toggleSpace(ctx, spaceID, canonical, remove) +} diff --git a/mautrix-patched/bridgev2/portalinternal_generate.go b/mautrix-patched/bridgev2/portalinternal_generate.go new file mode 100644 index 00000000..2ac6c898 --- /dev/null +++ b/mautrix-patched/bridgev2/portalinternal_generate.go @@ -0,0 +1,173 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build ignore + +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "strings" + + "go.mau.fi/util/exerrors" +) + +const header = `// GENERATED BY portalinternal_generate.go; DO NOT EDIT + +//go:generate go run portalinternal_generate.go +//go:generate goimports -local maunium.net/go/mautrix -w portalinternal.go + +package bridgev2 + +` +const postImportHeader = ` +type PortalInternals Portal + +// Deprecated: portal internals should be used carefully and only when necessary. +func (portal *Portal) Internal() *PortalInternals { + return (*PortalInternals)(portal) +} +` + +func getTypeName(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.StarExpr: + return "*" + getTypeName(e.X) + case *ast.ArrayType: + return "[]" + getTypeName(e.Elt) + case *ast.MapType: + return fmt.Sprintf("map[%s]%s", getTypeName(e.Key), getTypeName(e.Value)) + case *ast.ChanType: + return fmt.Sprintf("chan %s", getTypeName(e.Value)) + case *ast.FuncType: + var params []string + for _, param := range e.Params.List { + params = append(params, getTypeName(param.Type)) + } + var results []string + if e.Results != nil { + for _, result := range e.Results.List { + results = append(results, getTypeName(result.Type)) + } + } + return fmt.Sprintf("func(%s) %s", strings.Join(params, ", "), strings.Join(results, ", ")) + case *ast.SelectorExpr: + return fmt.Sprintf("%s.%s", getTypeName(e.X), e.Sel.Name) + default: + panic(fmt.Errorf("unknown type %T", e)) + } +} + +var write func(str string) +var writef func(format string, args ...any) + +func main() { + fset := token.NewFileSet() + fileNames := []string{"portal.go", "portalbackfill.go", "portalreid.go", "space.go", "matrixinvite.go"} + files := make([]*ast.File, len(fileNames)) + for i, name := range fileNames { + files[i] = exerrors.Must(parser.ParseFile(fset, name, nil, parser.SkipObjectResolution)) + } + file := exerrors.Must(os.OpenFile("portalinternal.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)) + write = func(str string) { + exerrors.Must(file.WriteString(str)) + } + writef = func(format string, args ...any) { + exerrors.Must(fmt.Fprintf(file, format, args...)) + } + write(header) + write("import (\n") + for _, i := range files[0].Imports { + write("\t") + if i.Name != nil { + writef("%s ", i.Name.Name) + } + writef("%s\n", i.Path.Value) + } + write(")\n") + write(postImportHeader) + for _, f := range files { + processFile(f) + } + exerrors.PanicIfNotNil(file.Close()) +} + +func processFile(f *ast.File) { + ast.Inspect(f, func(node ast.Node) (retVal bool) { + retVal = true + funcDecl, ok := node.(*ast.FuncDecl) + if !ok || funcDecl.Name.IsExported() { + return + } + if funcDecl.Recv == nil || len(funcDecl.Recv.List) == 0 || len(funcDecl.Recv.List[0].Names) == 0 || + funcDecl.Recv.List[0].Names[0].Name != "portal" { + return + } + writef("\nfunc (portal *PortalInternals) %s%s(", strings.ToUpper(funcDecl.Name.Name[0:1]), funcDecl.Name.Name[1:]) + for i, param := range funcDecl.Type.Params.List { + if i != 0 { + write(", ") + } + for j, name := range param.Names { + if j != 0 { + write(", ") + } + write(name.Name) + } + if len(param.Names) > 0 { + write(" ") + } + write(getTypeName(param.Type)) + } + write(") ") + if funcDecl.Type.Results != nil && len(funcDecl.Type.Results.List) > 0 { + needsParentheses := len(funcDecl.Type.Results.List) > 1 || len(funcDecl.Type.Results.List[0].Names) > 0 + if needsParentheses { + write("(") + } + for i, result := range funcDecl.Type.Results.List { + if i != 0 { + write(", ") + } + for j, name := range result.Names { + if j != 0 { + write(", ") + } + write(name.Name) + } + if len(result.Names) > 0 { + write(" ") + } + write(getTypeName(result.Type)) + } + if needsParentheses { + write(")") + } + write(" ") + } + write("{\n\t") + if funcDecl.Type.Results != nil { + write("return ") + } + writef("(*Portal)(portal).%s(", funcDecl.Name.Name) + for i, param := range funcDecl.Type.Params.List { + for j, name := range param.Names { + if i != 0 || j != 0 { + write(", ") + } + write(name.Name) + } + } + write(")\n}\n") + return + }) +} diff --git a/mautrix-patched/bridgev2/portalreid.go b/mautrix-patched/bridgev2/portalreid.go new file mode 100644 index 00000000..c976d97c --- /dev/null +++ b/mautrix-patched/bridgev2/portalreid.go @@ -0,0 +1,161 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +type ReIDResult int + +const ( + ReIDResultError ReIDResult = iota + ReIDResultNoOp + ReIDResultSourceDeleted + ReIDResultSourceReIDd + ReIDResultTargetDeletedAndSourceReIDd + ReIDResultSourceTombstonedIntoTarget +) + +func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.PortalKey) (ReIDResult, *Portal, error) { + if source == target { + return ReIDResultError, nil, fmt.Errorf("illegal re-ID call: source and target are the same") + } + log := zerolog.Ctx(ctx).With(). + Str("action", "re-id portal"). + Stringer("source_portal_key", source). + Stringer("target_portal_key", target). + Logger() + ctx = log.WithContext(ctx) + defer func() { + log.Debug().Msg("Finished handling portal re-ID") + }() + acquireCacheLock := func() { + if !br.cacheLock.TryLock() { + log.Debug().Msg("Waiting for global cache lock") + br.cacheLock.Lock() + log.Debug().Msg("Acquired global cache lock after waiting") + } else { + log.Trace().Msg("Acquired global cache lock without waiting") + } + } + log.Debug().Msg("Re-ID'ing portal") + sourcePortal, err := br.GetExistingPortalByKey(ctx, source) + if err != nil { + return ReIDResultError, nil, fmt.Errorf("failed to get source portal: %w", err) + } else if sourcePortal == nil { + log.Debug().Msg("Source portal not found, re-ID is no-op") + return ReIDResultNoOp, nil, nil + } + if !sourcePortal.roomCreateLock.TryLock() { + if cancelCreate := sourcePortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { + (*cancelCreate)() + } + log.Debug().Msg("Waiting for source portal room creation lock") + sourcePortal.roomCreateLock.Lock() + log.Debug().Msg("Acquired source portal room creation lock after waiting") + } + defer sourcePortal.roomCreateLock.Unlock() + if sourcePortal.MXID == "" { + log.Info().Msg("Source portal doesn't have Matrix room, deleting row") + err = sourcePortal.unlockedDelete(ctx) + if err != nil { + return ReIDResultError, nil, fmt.Errorf("failed to delete source portal: %w", err) + } + return ReIDResultSourceDeleted, nil, nil + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("source_portal_mxid", sourcePortal.MXID) + }) + + acquireCacheLock() + targetPortal, err := br.UnlockedGetPortalByKey(ctx, target, true) + if err != nil { + br.cacheLock.Unlock() + return ReIDResultError, nil, fmt.Errorf("failed to get target portal: %w", err) + } + if targetPortal == nil { + log.Info().Msg("Target portal doesn't exist, re-ID'ing source portal") + err = sourcePortal.unlockedReID(ctx, target) + br.cacheLock.Unlock() + if err != nil { + return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal: %w", err) + } + return ReIDResultSourceReIDd, sourcePortal, nil + } + br.cacheLock.Unlock() + + if !targetPortal.roomCreateLock.TryLock() { + if cancelCreate := targetPortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { + (*cancelCreate)() + } + log.Debug().Msg("Waiting for target portal room creation lock") + targetPortal.roomCreateLock.Lock() + log.Debug().Msg("Acquired target portal room creation lock after waiting") + } + defer targetPortal.roomCreateLock.Unlock() + if targetPortal.MXID == "" { + log.Info().Msg("Target portal row exists, but doesn't have a Matrix room. Deleting target portal row and re-ID'ing source portal") + acquireCacheLock() + defer br.cacheLock.Unlock() + err = targetPortal.unlockedDelete(ctx) + if err != nil { + return ReIDResultError, nil, fmt.Errorf("failed to delete target portal: %w", err) + } + err = sourcePortal.unlockedReID(ctx, target) + if err != nil { + return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal after deleting target: %w", err) + } + return ReIDResultTargetDeletedAndSourceReIDd, sourcePortal, nil + } else { + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("target_portal_mxid", targetPortal.MXID) + }) + log.Info().Msg("Both target and source portals have Matrix rooms, tombstoning source portal") + sourcePortal.removeInPortalCache(ctx) + acquireCacheLock() + defer br.cacheLock.Unlock() + err = sourcePortal.unlockedDelete(ctx) + if err != nil { + return ReIDResultError, nil, fmt.Errorf("failed to delete source portal row: %w", err) + } + go func() { + _, err := br.Bot.SendState(ctx, sourcePortal.MXID, event.StateTombstone, "", &event.Content{ + Parsed: &event.TombstoneEventContent{ + Body: "This room has been merged", + ReplacementRoom: targetPortal.MXID, + }, + }, time.Now()) + if err != nil { + log.Err(err).Msg("Failed to send tombstone to source portal room") + } + err = br.Bot.DeleteRoom(ctx, sourcePortal.MXID, err == nil) + if err != nil { + log.Err(err).Msg("Failed to delete source portal room") + } + }() + return ReIDResultSourceTombstonedIntoTarget, targetPortal, nil + } +} + +func (portal *Portal) unlockedReID(ctx context.Context, target networkid.PortalKey) error { + err := portal.Bridge.DB.Portal.ReID(ctx, portal.PortalKey, target) + if err != nil { + return err + } + delete(portal.Bridge.portalsByKey, portal.PortalKey) + portal.Bridge.portalsByKey[target] = portal + portal.PortalKey = target + return nil +} diff --git a/mautrix-patched/bridgev2/provisionutil/creategroup.go b/mautrix-patched/bridgev2/provisionutil/creategroup.go new file mode 100644 index 00000000..44b87289 --- /dev/null +++ b/mautrix-patched/bridgev2/provisionutil/creategroup.go @@ -0,0 +1,161 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + "slices" + + "github.com/rs/zerolog" + "go.mau.fi/util/exfmt" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type RespCreateGroup struct { + ID networkid.PortalID `json:"id"` + MXID id.RoomID `json:"mxid"` + Portal *bridgev2.Portal `json:"-"` + + FailedParticipants map[networkid.UserID]*bridgev2.CreateChatFailedParticipant `json:"failed_participants,omitempty"` +} + +func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) { + api, ok := login.Client.(bridgev2.GroupCreatingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups")) + } + zerolog.Ctx(ctx).Debug(). + Any("create_params", params). + Msg("Creating group chat on remote network") + caps := login.Bridge.Network.GetCapabilities() + typeSpec, validType := caps.Provisioning.GroupCreation[params.Type] + if !validType { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("Unrecognized group type %s", params.Type)) + } + if len(params.Participants) < typeSpec.Participants.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %s (in addition to yourself)", exfmt.Pluralizable("member")(typeSpec.Participants.MinLength))) + } else if typeSpec.Participants.MaxLength > 0 && len(params.Participants) > typeSpec.Participants.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at most %s", exfmt.Pluralizable("member")(typeSpec.Participants.MaxLength))) + } + userIDValidatingNetwork, uidValOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) + for i, participant := range params.Participants { + parsedParticipant, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(participant)) + if ok { + participant = parsedParticipant + params.Participants[i] = participant + } + if !typeSpec.Participants.SkipIdentifierValidation { + if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) + } + } + } + origParticipantCount := len(params.Participants) + params.Participants = slices.DeleteFunc(params.Participants, func(userID networkid.UserID) bool { + return api.IsThisUser(ctx, userID) + }) + if len(params.Participants) != origParticipantCount { + zerolog.Ctx(ctx).Debug(). + Int("original_count", origParticipantCount). + Int("filtered_count", len(params.Participants)). + Msg("Removed self from participants list") + } + if len(params.Participants) < typeSpec.Participants.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %s (in addition to yourself)", exfmt.Pluralizable("member")(typeSpec.Participants.MinLength))) + } + if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required")) + } else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at least %d characters", typeSpec.Name.MinLength)) + } else if typeSpec.Name.MaxLength > 0 && nameLen > typeSpec.Name.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at most %d characters", typeSpec.Name.MaxLength)) + } + if (params.Avatar == nil || params.Avatar.URL == "") && typeSpec.Avatar.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Avatar is required")) + } + if (params.Topic == nil || params.Topic.Topic == "") && typeSpec.Topic.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic is required")) + } else if topicLen := len(ptr.Val(params.Topic).Topic); topicLen > 0 && topicLen < typeSpec.Topic.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at least %d characters", typeSpec.Topic.MinLength)) + } else if typeSpec.Topic.MaxLength > 0 && topicLen > typeSpec.Topic.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at most %d characters", typeSpec.Topic.MaxLength)) + } + if (params.Disappear == nil || params.Disappear.Timer.Duration == 0) && typeSpec.Disappear.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Disappearing timer is required")) + } else if !typeSpec.Disappear.DisappearSettings.Supports(params.Disappear) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Unsupported value for disappearing timer")) + } + if params.Username == "" && typeSpec.Username.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username is required")) + } else if len(params.Username) > 0 && len(params.Username) < typeSpec.Username.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at least %d characters", typeSpec.Username.MinLength)) + } else if typeSpec.Username.MaxLength > 0 && len(params.Username) > typeSpec.Username.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at most %d characters", typeSpec.Username.MaxLength)) + } + if params.Parent == nil && typeSpec.Parent.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Parent is required")) + } + resp, err := api.CreateGroup(ctx, params) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create group") + return nil, err + } + if resp.PortalKey.IsEmpty() { + return nil, ErrNoPortalKey + } + zerolog.Ctx(ctx).Debug(). + Object("portal_key", resp.PortalKey). + Msg("Successfully created group on remote network") + if resp.Portal == nil { + resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) + } + } + if resp.Portal.MXID == "" { + err = resp.Portal.CreateMatrixRoom(ctx, login, resp.PortalInfo) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) + } + } + for key, fp := range resp.FailedParticipants { + if fp.InviteEventType == "" { + fp.InviteEventType = event.EventMessage.Type + } + if fp.UserMXID == "" { + ghost, err := login.Bridge.GetGhostByID(ctx, key) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for failed participant") + } else if ghost != nil { + fp.UserMXID = ghost.Intent.GetMXID() + } + } + if fp.DMRoomMXID == "" { + portal, err := login.Bridge.GetDMPortal(ctx, login.ID, key) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM portal for failed participant") + } else if portal != nil { + fp.DMRoomMXID = portal.MXID + } + } + } + return &RespCreateGroup{ + ID: resp.Portal.ID, + MXID: resp.Portal.MXID, + Portal: resp.Portal, + + FailedParticipants: resp.FailedParticipants, + }, nil +} diff --git a/mautrix-patched/bridgev2/provisionutil/imagepack.go b/mautrix-patched/bridgev2/provisionutil/imagepack.go new file mode 100644 index 00000000..3165f663 --- /dev/null +++ b/mautrix-patched/bridgev2/provisionutil/imagepack.go @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exmaps" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type RespImagePackSavedToRoom struct { + EventID id.EventID `json:"event_id"` + RoomID id.RoomID `json:"room_id"` + StateKey string `json:"state_key"` + EventIDs []id.EventID `json:"event_ids,omitempty"` + StateKeys []string `json:"state_keys,omitempty"` +} + +type spaceableNetworkAPI interface { + bridgev2.NetworkAPI + GetSpaceRoom() id.RoomID +} + +func actuallyImportImagePack(ctx context.Context, login *bridgev2.UserLogin, packURL string) (*bridgev2.ImportedImagePack, error) { + api, ok := login.Client.(bridgev2.StickerImportingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support importing image packs")) + } + resp, err := api.DownloadImagePack(ctx, packURL) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("pack_url", packURL).Msg("Failed to download image pack") + if !errors.Is(err, mautrix.MNotFound) { + login.TrackAnalytics("Image Pack Import Fail", map[string]any{}) + } + return nil, err + } + if resp.Shortcode == "" && resp.Content.Metadata.BridgedPack != nil { + resp.Shortcode = resp.Content.Metadata.BridgedPack.URL + } + return resp, nil +} + +func PreviewImagePack(ctx context.Context, login *bridgev2.UserLogin, packURL string) (*event.Content, error) { + resp, err := actuallyImportImagePack(ctx, login, packURL) + if err != nil { + return nil, err + } + shortcodeHash := sha256.Sum256([]byte(resp.Shortcode)) + login.TrackAnalytics("Image Pack Previewed", map[string]any{ + "shortcode_hash": hex.EncodeToString(shortcodeHash[:16]), + }) + return &event.Content{ + Parsed: resp.Content, + Raw: resp.Extra, + }, nil +} + +func ImportImagePack(ctx context.Context, login *bridgev2.UserLogin, packURL string) (*RespImagePackSavedToRoom, error) { + spaceRoom, err := login.GetSpaceRoom(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get space room for user") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get space room for user")) + } else if spaceRoom == "" { + // Small hack to allow importing emojis on Slack where there's a shared team space portal + // instead of individual personal filtering spaces. + spaceableAPI, ok := login.Client.(spaceableNetworkAPI) + if ok && spaceableAPI.GetSpaceRoom() != "" { + spaceRoom = spaceableAPI.GetSpaceRoom() + } else { + return nil, bridgev2.RespError(mautrix.MNotFound.WithMessage("Can't import image pack to space when personal filtering spaces are disabled")) + } + } + resp, err := actuallyImportImagePack(ctx, login, packURL) + if err != nil { + return nil, err + } + var eventIDs []id.EventID + var stateKeys []string + packs, err := SplitPack(resp) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to split image pack into multiple state events") + return nil, fmt.Errorf("failed to split image pack into multiple state events: %w", err) + } + if len(packs) > 1 { + zerolog.Ctx(ctx).Info(). + Int("pack_count", len(packs)). + Msg("Split pack into parts") + } + for i, content := range packs { + stateKey := resp.Shortcode + if i > 0 { + stateKey = fmt.Sprintf("%s.%03d", resp.Shortcode, i) + } + zerolog.Ctx(ctx).Trace().Str("state_key", stateKey).RawJSON("pack_data", content).Msg("Sending pack") + sendResp, err := login.Bridge.Bot.SendState(ctx, spaceRoom, event.StateImagePack, stateKey, &event.Content{VeryRaw: content}, time.Now()) + if err != nil { + zerolog.Ctx(ctx).Err(err).Int("pack_idx", i).Msg("Failed to send image pack state event to space") + return nil, fmt.Errorf("failed to send image pack state event #%d to space: %w", i+1, err) + } + eventIDs = append(eventIDs, sendResp.EventID) + stateKeys = append(stateKeys, stateKey) + } + shortcodeHash := sha256.Sum256([]byte(resp.Shortcode)) + login.TrackAnalytics("Image Pack Imported", map[string]any{ + "shortcode_hash": hex.EncodeToString(shortcodeHash[:16]), + }) + return &RespImagePackSavedToRoom{ + RoomID: spaceRoom, + EventID: eventIDs[0], + EventIDs: eventIDs, + StateKey: stateKeys[0], + StateKeys: stateKeys, + }, nil +} + +var MaxPackBytes = 62 * 1024 + +func SplitPack(resp *bridgev2.ImportedImagePack) ([]json.RawMessage, error) { + fullPack, err := json.Marshal(&event.Content{ + Parsed: resp.Content, + Raw: resp.Extra, + }) + if err != nil { + return nil, fmt.Errorf("failed to marshal base content: %w", err) + } + if len(fullPack) < MaxPackBytes { + return []json.RawMessage{fullPack}, nil + } + + baseContent := exmaps.NonNilClone(resp.Extra) + baseContent["pack"] = resp.Content.Metadata + baseContent["images"] = map[string]json.RawMessage{} + baseContent["fi.mau.combined_pack_key"] = resp.Shortcode + baseContent["fi.mau.combined_pack_index"] = 0 + baseContent["fi.mau.combined_pack_count"] = 0 + basePack, err := json.Marshal(baseContent) + if err != nil { + return nil, fmt.Errorf("failed to marshal base content: %w", err) + } + + imageKeys := make([]string, 0, len(resp.Content.Images)) + imageJSONs := make(map[string]json.RawMessage, len(resp.Content.Images)) + for key, image := range resp.Content.Images { + imageKeys = append(imageKeys, key) + imageJSONs[key], err = json.Marshal(image) + if err != nil { + return nil, fmt.Errorf("failed to marshal %s: %w", key, err) + } + } + + slices.Sort(imageKeys) + currentPackImages := make(map[string]json.RawMessage) + currentSize := len(basePack) + packs := make([]map[string]any, 0) + compilePack := func() { + if len(currentPackImages) == 0 { + return + } + newPack := maps.Clone(baseContent) + newPack["images"] = currentPackImages + newPack["fi.mau.combined_pack_index"] = len(packs) + meta := resp.Content.Metadata + meta.DisplayName = fmt.Sprintf("%s (part %d)", meta.DisplayName, len(packs)+1) + newPack["pack"] = meta + packs = append(packs, newPack) + currentPackImages = make(map[string]json.RawMessage) + currentSize = len(basePack) + } + if currentSize > MaxPackBytes/3 { + return nil, fmt.Errorf("pack metadata is too large: %d bytes", currentSize) + } + for _, key := range imageKeys { + nextImg := imageJSONs[key] + nextSize := len(nextImg) + len(key) + 4 + if currentSize+nextSize > MaxPackBytes { + compilePack() + } + currentPackImages[key] = nextImg + currentSize += nextSize + } + compilePack() + packJSONs := make([]json.RawMessage, len(packs)) + for i, pack := range packs { + pack["fi.mau.combined_pack_count"] = len(packs) + packJSONs[i], err = json.Marshal(pack) + if err != nil { + return nil, fmt.Errorf("failed to marshal pack %d: %w", i, err) + } + } + return packJSONs, err +} + +func ListImagePacks(ctx context.Context, login *bridgev2.UserLogin) ([]*event.ImagePackMetadata, error) { + api, ok := login.Client.(bridgev2.StickerImportingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support importing image packs")) + } + return api.ListImagePacks(ctx) +} diff --git a/mautrix-patched/bridgev2/provisionutil/listcontacts.go b/mautrix-patched/bridgev2/provisionutil/listcontacts.go new file mode 100644 index 00000000..ce163e67 --- /dev/null +++ b/mautrix-patched/bridgev2/provisionutil/listcontacts.go @@ -0,0 +1,98 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" +) + +type RespGetContactList struct { + Contacts []*RespResolveIdentifier `json:"contacts"` +} + +type RespSearchUsers struct { + Results []*RespResolveIdentifier `json:"results"` +} + +func GetContactList(ctx context.Context, login *bridgev2.UserLogin) (*RespGetContactList, error) { + api, ok := login.Client.(bridgev2.ContactListingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support listing contacts")) + } + resp, err := api.GetContactList(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list") + return nil, err + } + return &RespGetContactList{ + Contacts: processResolveIdentifiers(ctx, login.Bridge, resp, false), + }, nil +} + +func SearchUsers(ctx context.Context, login *bridgev2.UserLogin, query string) (*RespSearchUsers, error) { + api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support searching for users")) + } + resp, err := api.SearchUsers(ctx, query) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list") + return nil, err + } + return &RespSearchUsers{ + Results: processResolveIdentifiers(ctx, login.Bridge, resp, true), + }, nil +} + +func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp []*bridgev2.ResolveIdentifierResponse, syncInfo bool) (apiResp []*RespResolveIdentifier) { + apiResp = make([]*RespResolveIdentifier, len(resp)) + for i, contact := range resp { + apiContact := &RespResolveIdentifier{ + ID: contact.UserID, + } + apiResp[i] = apiContact + if contact.UserInfo != nil { + if contact.UserInfo.Name != nil { + apiContact.Name = *contact.UserInfo.Name + } + if contact.UserInfo.Identifiers != nil { + apiContact.Identifiers = contact.UserInfo.Identifiers + } + } + if contact.Ghost != nil { + if syncInfo && contact.UserInfo != nil { + contact.Ghost.UpdateInfo(ctx, contact.UserInfo) + } + if contact.Ghost.Name != "" { + apiContact.Name = contact.Ghost.Name + } + if len(contact.Ghost.Identifiers) >= len(apiContact.Identifiers) { + apiContact.Identifiers = contact.Ghost.Identifiers + } + apiContact.AvatarURL = contact.Ghost.AvatarMXC + apiContact.MXID = contact.Ghost.Intent.GetMXID() + } + if contact.Chat != nil { + if contact.Chat.Portal == nil { + var err error + contact.Chat.Portal, err = br.GetPortalByKey(ctx, contact.Chat.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + } + } + if contact.Chat.Portal != nil { + apiContact.DMRoomID = contact.Chat.Portal.MXID + } + } + } + return +} diff --git a/mautrix-patched/bridgev2/provisionutil/resolveidentifier.go b/mautrix-patched/bridgev2/provisionutil/resolveidentifier.go new file mode 100644 index 00000000..cfc388d0 --- /dev/null +++ b/mautrix-patched/bridgev2/provisionutil/resolveidentifier.go @@ -0,0 +1,125 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + "errors" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type RespResolveIdentifier struct { + ID networkid.UserID `json:"id"` + Name string `json:"name,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` + MXID id.UserID `json:"mxid,omitempty"` + DMRoomID id.RoomID `json:"dm_room_mxid,omitempty"` + + Portal *bridgev2.Portal `json:"-"` + Ghost *bridgev2.Ghost `json:"-"` + JustCreated bool `json:"-"` +} + +var ErrNoPortalKey = errors.New("network API didn't return portal key for createChat request") + +func ResolveIdentifier( + ctx context.Context, + login *bridgev2.UserLogin, + identifier string, + createChat bool, +) (*RespResolveIdentifier, error) { + api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers")) + } + var resp *bridgev2.ResolveIdentifierResponse + parsedUserID, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(identifier)) + validator, vOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) + if ok && (!vOK || validator.ValidateUserID(parsedUserID)) { + ghost, err := login.Bridge.GetGhostByID(ctx, parsedUserID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost by ID") + return nil, err + } + resp = &bridgev2.ResolveIdentifierResponse{ + Ghost: ghost, + UserID: parsedUserID, + } + gdcAPI, ok := api.(bridgev2.GhostDMCreatingNetworkAPI) + if ok && createChat { + resp.Chat, err = gdcAPI.CreateChatWithGhost(ctx, ghost) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create chat") + return nil, err + } + } else if createChat || ghost.Name == "" { + zerolog.Ctx(ctx).Debug(). + Bool("create_chat", createChat). + Bool("has_name", ghost.Name != ""). + Msg("Falling back to resolving identifier") + resp = nil + identifier = string(parsedUserID) + } + } + if resp == nil { + var err error + resp, err = api.ResolveIdentifier(ctx, identifier, createChat) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to resolve identifier") + return nil, err + } else if resp == nil { + return nil, nil + } + } + apiResp := &RespResolveIdentifier{ + ID: resp.UserID, + Ghost: resp.Ghost, + } + if resp.Ghost != nil { + if resp.UserInfo != nil { + resp.Ghost.UpdateInfo(ctx, resp.UserInfo) + } + apiResp.Name = resp.Ghost.Name + apiResp.AvatarURL = resp.Ghost.AvatarMXC + apiResp.Identifiers = resp.Ghost.Identifiers + apiResp.MXID = resp.Ghost.Intent.GetMXID() + } else if resp.UserInfo != nil && resp.UserInfo.Name != nil { + apiResp.Name = *resp.UserInfo.Name + } + if resp.Chat != nil { + if resp.Chat.PortalKey.IsEmpty() { + return nil, ErrNoPortalKey + } + if resp.Chat.Portal == nil { + var err error + resp.Chat.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.Chat.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) + } + } + resp.Chat.Portal.CleanupOrphanedDM(ctx, login.UserMXID) + if createChat && resp.Chat.Portal.MXID == "" { + apiResp.JustCreated = true + err := resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) + } + } + apiResp.Portal = resp.Chat.Portal + apiResp.DMRoomID = resp.Chat.Portal.MXID + } + return apiResp, nil +} diff --git a/mautrix-patched/bridgev2/queue.go b/mautrix-patched/bridgev2/queue.go new file mode 100644 index 00000000..f8e144fd --- /dev/null +++ b/mautrix-patched/bridgev2/queue.go @@ -0,0 +1,275 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func rejectInvite(ctx context.Context, evt *event.Event, intent MatrixAPI, reason string) { + resp, err := intent.SendState(ctx, evt.RoomID, event.StateMember, intent.GetMXID().String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: reason, + }, + }, time.Time{}) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("room_id", evt.RoomID). + Stringer("inviter_id", evt.Sender). + Stringer("invitee_id", intent.GetMXID()). + Str("reason", reason). + Msg("Failed to reject invite") + } else { + zerolog.Ctx(ctx).Debug(). + Stringer("leave_event_id", resp.EventID). + Stringer("room_id", evt.RoomID). + Stringer("inviter_id", evt.Sender). + Stringer("invitee_id", intent.GetMXID()). + Str("reason", reason). + Msg("Rejected invite") + } +} + +func (br *Bridge) rejectInviteOnNoPermission(ctx context.Context, evt *event.Event, permType string) bool { + if evt.Type != event.StateMember || evt.Content.AsMember().Membership != event.MembershipInvite { + return false + } + userID := id.UserID(evt.GetStateKey()) + parsed, isGhost := br.Matrix.ParseGhostMXID(userID) + if userID != br.Bot.GetMXID() && !isGhost { + return false + } + var intent MatrixAPI + if userID == br.Bot.GetMXID() { + intent = br.Bot + } else { + intent = br.Matrix.GhostIntent(parsed) + } + rejectInvite(ctx, evt, intent, "You don't have permission to "+permType+" this bridge") + return true +} + +var ( + ErrEventSenderUserNotFound = WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() + ErrNoPermissionToInteract = WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() + ErrNoPermissionForCommands = WrapErrorInStatus(WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()) + ErrCantRelayStateRequest = WrapErrorInStatus(errors.New("relayed users can't use beeper state requests")).WithIsCertain(true).WithErrorAsMessage() +) + +func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventHandlingResult { + // TODO maybe HandleMatrixEvent would be more appropriate as this also handles bot invites and commands + + log := zerolog.Ctx(ctx) + var sender *User + if evt.Sender != "" { + var err error + sender, err = br.GetUserByMXID(ctx, evt.Sender) + if err != nil { + log.Err(err).Msg("Failed to get sender user for incoming Matrix event") + err = fmt.Errorf("%w: failed to get sender user: %w", ErrDatabaseError, err) + status := WrapErrorInStatus(err) + br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + return EventHandlingResultFailed.WithError(err) + } else if sender == nil { + log.Error().Msg("Couldn't get sender for incoming non-ephemeral Matrix event") + br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt)) + return EventHandlingResultFailed.WithError(ErrEventSenderUserNotFound) + } else if !sender.Permissions.SendEvents { + if !br.rejectInviteOnNoPermission(ctx, evt, "interact with") { + br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionToInteract, StatusEventInfoFromEvent(evt)) + } + return EventHandlingResultIgnored + } else if !sender.Permissions.Commands && br.rejectInviteOnNoPermission(ctx, evt, "send commands to") { + return EventHandlingResultIgnored + } + } else if evt.Type.Class != event.EphemeralEventType { + log.Error().Msg("Missing sender for incoming non-ephemeral Matrix event") + br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt)) + return EventHandlingResultIgnored + } + if evt.Type == event.EventMessage && sender != nil { + msg := evt.Content.AsMessage() + msg.RemoveReplyFallback() + msg.RemovePerMessageProfileFallback() + if strings.HasPrefix(msg.Body, br.Config.CommandPrefix) || evt.RoomID == sender.ManagementRoom { + if !sender.Permissions.Commands { + br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionForCommands, StatusEventInfoFromEvent(evt)) + return EventHandlingResultIgnored + } + go br.Commands.Handle( + ctx, + evt.RoomID, + evt.ID, + sender, + strings.TrimPrefix(msg.Body, br.Config.CommandPrefix+" "), + msg.RelatesTo.GetReplyTo(), + ) + return EventHandlingResultQueued + } + } + if evt.Type == event.StateMember && evt.GetStateKey() == br.Bot.GetMXID().String() && evt.Content.AsMember().Membership == event.MembershipInvite && sender != nil { + return br.handleBotInvite(ctx, evt, sender) + } else if sender != nil && evt.RoomID == sender.ManagementRoom { + if evt.Type == event.StateMember && evt.Content.AsMember().Membership == event.MembershipLeave && (evt.GetStateKey() == br.Bot.GetMXID().String() || evt.GetStateKey() == sender.MXID.String()) { + sender.ManagementRoom = "" + err := br.DB.User.Update(ctx, sender.User) + if err != nil { + log.Err(err).Msg("Failed to clear user's management room in database") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to clear user's management room: %w", err)) + } + log.Debug().Msg("Cleared user's management room due to leave event") + } + return EventHandlingResultSuccess + } + portal, err := br.GetPortalByMXID(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to get portal for incoming Matrix event") + err = fmt.Errorf("%w: failed to get portal: %w", ErrDatabaseError, err) + status := WrapErrorInStatus(err) + br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + return EventHandlingResultFailed.WithError(err) + } else if portal != nil { + return portal.queueEvent(ctx, &portalMatrixEvent{ + evt: evt, + sender: sender, + }) + } else if evt.Type == event.StateMember && br.IsGhostMXID(id.UserID(evt.GetStateKey())) && evt.Content.AsMember().Membership == event.MembershipInvite && evt.Content.AsMember().IsDirect && sender != nil { + return br.handleGhostDMInvite(ctx, evt, sender) + } else if evt.Type == event.BeeperDeleteChat && sender != nil && sender.Permissions.Admin { + err = br.Bot.DeleteRoom(ctx, evt.RoomID, true) + if err != nil { + log.Err(err).Msg("Failed to delete non-portal room") + status := WrapErrorInStatus(err) + br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + return EventHandlingResultFailed.WithError(err) + } + log.Debug().Msg("Successfully deleted non-portal room after delete chat event") + return EventHandlingResultSuccess + } else { + status := WrapErrorInStatus(ErrNoPortal) + br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) + return EventHandlingResultIgnored + } +} + +type EventHandlingResult struct { + Success bool + Ignored bool + Queued bool + + SkipStateEcho bool + + // Error is an optional reason for failure. It is not required, Success may be false even without a specific error. + Error error + // Whether the Error should be sent as a MSS event. + SendMSS bool + + // EventID from the network + EventID id.EventID + // Stream order from the network + StreamOrder int64 +} + +func (ehr EventHandlingResult) WithEventID(id id.EventID) EventHandlingResult { + ehr.EventID = id + return ehr +} + +func (ehr EventHandlingResult) WithStreamOrder(order int64) EventHandlingResult { + ehr.StreamOrder = order + return ehr +} + +func (ehr EventHandlingResult) WithError(err error) EventHandlingResult { + if err == nil { + return ehr + } + ehr.Error = err + ehr.Success = false + return ehr +} + +func (ehr EventHandlingResult) WithMSS() EventHandlingResult { + ehr.SendMSS = true + return ehr +} + +func (ehr EventHandlingResult) WithSkipStateEcho(skip bool) EventHandlingResult { + ehr.SkipStateEcho = skip + return ehr +} + +func (ehr EventHandlingResult) WithMSSError(err error) EventHandlingResult { + if err == nil { + return ehr + } + return ehr.WithError(err).WithMSS() +} + +var ( + EventHandlingResultFailed = EventHandlingResult{} + EventHandlingResultQueued = EventHandlingResult{Success: true, Queued: true} + EventHandlingResultSuccess = EventHandlingResult{Success: true} + EventHandlingResultIgnored = EventHandlingResult{Success: true, Ignored: true} +) + +func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) EventHandlingResult { + return ul.Bridge.QueueRemoteEvent(ul, evt) +} + +func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) EventHandlingResult { + log := login.Log + ctx := log.WithContext(br.BackgroundCtx) + maybeUncertain, ok := evt.(RemoteEventWithUncertainPortalReceiver) + isUncertain := ok && maybeUncertain.PortalReceiverIsUncertain() + key := evt.GetPortalKey() + var portal *Portal + var err error + if isUncertain && !br.Config.SplitPortals { + portal, err = br.GetExistingPortalByKey(ctx, key) + if err == nil && portal == nil { + fetcher, ok := evt.(RemoteEventWithUncertainPortalReceiverFetcher) + if ok { + newKey := fetcher.FetchCertainPortalKey(ctx) + if !newKey.IsEmpty() { + key = newKey + portal, err = br.GetPortalByKey(ctx, newKey) + } + } + } + } else { + portal, err = br.GetPortalByKey(ctx, key) + } + if err != nil { + log.Err(err).Object("portal_key", key).Bool("uncertain_receiver", isUncertain). + Msg("Failed to get portal to handle remote event") + return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get portal: %w", err)) + } else if portal == nil { + log.Warn(). + Stringer("event_type", evt.GetType()). + Object("portal_key", key). + Bool("uncertain_receiver", isUncertain). + Msg("Portal not found to handle remote event") + return EventHandlingResultIgnored + } + // TODO put this in a better place, and maybe cache to avoid constant db queries + login.MarkInPortal(ctx, portal) + return portal.queueEvent(ctx, &portalRemoteEvent{ + evt: evt, + source: login, + }) +} diff --git a/mautrix-patched/bridgev2/simpleremoteevent.go b/mautrix-patched/bridgev2/simpleremoteevent.go new file mode 100644 index 00000000..66058e3e --- /dev/null +++ b/mautrix-patched/bridgev2/simpleremoteevent.go @@ -0,0 +1,133 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// SimpleRemoteEvent is a simple implementation of RemoteEvent that can be used with struct fields and some callbacks. +// +// Using this type is only recommended for simple bridges. More advanced ones should implement +// the remote event interfaces themselves by wrapping the remote network library event types. +// +// Deprecated: use the types in the simplevent package instead. +type SimpleRemoteEvent[T any] struct { + Type RemoteEventType + LogContext func(c zerolog.Context) zerolog.Context + PortalKey networkid.PortalKey + Data T + CreatePortal bool + + ID networkid.MessageID + Sender EventSender + TargetMessage networkid.MessageID + EmojiID networkid.EmojiID + Emoji string + ReactionDBMeta any + Timestamp time.Time + ChatInfoChange *ChatInfoChange + + ResyncChatInfo *ChatInfo + ResyncBackfillNeeded bool + + BackfillData *FetchMessagesResponse + + ConvertMessageFunc func(ctx context.Context, portal *Portal, intent MatrixAPI, data T) (*ConvertedMessage, error) + ConvertEditFunc func(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message, data T) (*ConvertedEdit, error) +} + +var ( + _ RemoteMessage = (*SimpleRemoteEvent[any])(nil) + _ RemoteEdit = (*SimpleRemoteEvent[any])(nil) + _ RemoteEventWithTimestamp = (*SimpleRemoteEvent[any])(nil) + _ RemoteReaction = (*SimpleRemoteEvent[any])(nil) + _ RemoteReactionWithMeta = (*SimpleRemoteEvent[any])(nil) + _ RemoteReactionRemove = (*SimpleRemoteEvent[any])(nil) + _ RemoteMessageRemove = (*SimpleRemoteEvent[any])(nil) + _ RemoteChatInfoChange = (*SimpleRemoteEvent[any])(nil) + _ RemoteChatResyncWithInfo = (*SimpleRemoteEvent[any])(nil) + _ RemoteChatResyncBackfill = (*SimpleRemoteEvent[any])(nil) + _ RemoteBackfill = (*SimpleRemoteEvent[any])(nil) +) + +func (sre *SimpleRemoteEvent[T]) AddLogContext(c zerolog.Context) zerolog.Context { + return sre.LogContext(c) +} + +func (sre *SimpleRemoteEvent[T]) GetPortalKey() networkid.PortalKey { + return sre.PortalKey +} + +func (sre *SimpleRemoteEvent[T]) GetTimestamp() time.Time { + if sre.Timestamp.IsZero() { + return time.Now() + } + return sre.Timestamp +} + +func (sre *SimpleRemoteEvent[T]) ConvertMessage(ctx context.Context, portal *Portal, intent MatrixAPI) (*ConvertedMessage, error) { + return sre.ConvertMessageFunc(ctx, portal, intent, sre.Data) +} + +func (sre *SimpleRemoteEvent[T]) ConvertEdit(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message) (*ConvertedEdit, error) { + return sre.ConvertEditFunc(ctx, portal, intent, existing, sre.Data) +} + +func (sre *SimpleRemoteEvent[T]) GetID() networkid.MessageID { + return sre.ID +} + +func (sre *SimpleRemoteEvent[T]) GetSender() EventSender { + return sre.Sender +} + +func (sre *SimpleRemoteEvent[T]) GetTargetMessage() networkid.MessageID { + return sre.TargetMessage +} + +func (sre *SimpleRemoteEvent[T]) GetReactionEmoji() (string, networkid.EmojiID) { + return sre.Emoji, sre.EmojiID +} + +func (sre *SimpleRemoteEvent[T]) GetRemovedEmojiID() networkid.EmojiID { + return sre.EmojiID +} + +func (sre *SimpleRemoteEvent[T]) GetReactionDBMetadata() any { + return sre.ReactionDBMeta +} + +func (sre *SimpleRemoteEvent[T]) GetChatInfoChange(ctx context.Context) (*ChatInfoChange, error) { + return sre.ChatInfoChange, nil +} + +func (sre *SimpleRemoteEvent[T]) GetType() RemoteEventType { + return sre.Type +} + +func (sre *SimpleRemoteEvent[T]) ShouldCreatePortal() bool { + return sre.CreatePortal +} + +func (sre *SimpleRemoteEvent[T]) GetBackfillData(ctx context.Context, portal *Portal) (*FetchMessagesResponse, error) { + return sre.BackfillData, nil +} + +func (sre *SimpleRemoteEvent[T]) CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) { + return sre.ResyncBackfillNeeded, nil +} + +func (sre *SimpleRemoteEvent[T]) GetChatInfo(ctx context.Context, portal *Portal) (*ChatInfo, error) { + return sre.ResyncChatInfo, nil +} diff --git a/mautrix-patched/bridgev2/simplevent/chat.go b/mautrix-patched/bridgev2/simplevent/chat.go new file mode 100644 index 00000000..e9dae59e --- /dev/null +++ b/mautrix-patched/bridgev2/simplevent/chat.go @@ -0,0 +1,108 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package simplevent + +import ( + "context" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" +) + +// ChatResync is a simple implementation of [bridgev2.RemoteChatResync]. +// +// If GetChatInfoFunc is set, it will be used to get the chat info. Otherwise, ChatInfo will be used. +// +// If CheckNeedsBackfillFunc is set, it will be used to determine if backfill is required. +// Otherwise, the latest database message timestamp is compared to LatestMessageTS. +// +// All four fields are optional. +type ChatResync struct { + EventMeta + + ChatInfo *bridgev2.ChatInfo + GetChatInfoFunc func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) + + LatestMessageTS time.Time + CheckNeedsBackfillFunc func(ctx context.Context, latestMessage *database.Message) (bool, error) + BundledBackfillData any +} + +var ( + _ bridgev2.RemoteChatResync = (*ChatResync)(nil) + _ bridgev2.RemoteChatResyncWithInfo = (*ChatResync)(nil) + _ bridgev2.RemoteChatResyncBackfill = (*ChatResync)(nil) + _ bridgev2.RemoteChatResyncBackfillBundle = (*ChatResync)(nil) +) + +func (evt *ChatResync) CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) { + if evt.CheckNeedsBackfillFunc != nil { + return evt.CheckNeedsBackfillFunc(ctx, latestMessage) + } else if latestMessage == nil { + return !evt.LatestMessageTS.IsZero(), nil + } else { + return evt.LatestMessageTS.After(latestMessage.Timestamp), nil + } +} + +func (evt *ChatResync) GetBundledBackfillData() any { + return evt.BundledBackfillData +} + +func (evt *ChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + if evt.GetChatInfoFunc != nil { + return evt.GetChatInfoFunc(ctx, portal) + } + return evt.ChatInfo, nil +} + +// ChatDelete is a simple implementation of [bridgev2.RemoteChatDelete]. +type ChatDelete struct { + EventMeta + OnlyForMe bool + Children bool +} + +var _ bridgev2.RemoteChatDeleteWithChildren = (*ChatDelete)(nil) + +func (evt *ChatDelete) DeleteOnlyForMe() bool { + return evt.OnlyForMe +} + +func (evt *ChatDelete) DeleteChildren() bool { + return evt.Children +} + +// ChatInfoChange is a simple implementation of [bridgev2.RemoteChatInfoChange]. +type ChatInfoChange struct { + EventMeta + + ChatInfoChange *bridgev2.ChatInfoChange +} + +var _ bridgev2.RemoteChatInfoChange = (*ChatInfoChange)(nil) + +func (evt *ChatInfoChange) GetChatInfoChange(ctx context.Context) (*bridgev2.ChatInfoChange, error) { + return evt.ChatInfoChange, nil +} + +// Backfill is a simple implementation of [bridgev2.RemoteBackfill]. +type Backfill struct { + EventMeta + Data *bridgev2.FetchMessagesResponse + GetDataFunc func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.FetchMessagesResponse, error) +} + +var _ bridgev2.RemoteBackfill = (*Backfill)(nil) + +func (evt *Backfill) GetBackfillData(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.FetchMessagesResponse, error) { + if evt.GetDataFunc != nil { + return evt.GetDataFunc(ctx, portal) + } + return evt.Data, nil +} diff --git a/mautrix-patched/bridgev2/simplevent/message.go b/mautrix-patched/bridgev2/simplevent/message.go new file mode 100644 index 00000000..8f3a43b1 --- /dev/null +++ b/mautrix-patched/bridgev2/simplevent/message.go @@ -0,0 +1,121 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package simplevent + +import ( + "context" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// Message is a simple implementation of [bridgev2.RemoteMessage], [bridgev2.RemoteEdit] and [bridgev2.RemoteMessageUpsert]. +type Message[T any] struct { + EventMeta + Data T + + ID networkid.MessageID + TransactionID networkid.TransactionID + TargetMessage networkid.MessageID + + ConvertMessageFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data T) (*bridgev2.ConvertedMessage, error) + ConvertEditFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data T) (*bridgev2.ConvertedEdit, error) + HandleExistingFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data T) (bridgev2.UpsertResult, error) +} + +var ( + _ bridgev2.RemoteMessage = (*Message[any])(nil) + _ bridgev2.RemoteEdit = (*Message[any])(nil) + _ bridgev2.RemoteMessageUpsert = (*Message[any])(nil) + _ bridgev2.RemoteMessageWithTransactionID = (*Message[any])(nil) +) + +func (evt *Message[T]) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { + return evt.ConvertMessageFunc(ctx, portal, intent, evt.Data) +} + +func (evt *Message[T]) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) { + return evt.ConvertEditFunc(ctx, portal, intent, existing, evt.Data) +} + +func (evt *Message[T]) HandleExisting(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) { + return evt.HandleExistingFunc(ctx, portal, intent, existing, evt.Data) +} + +func (evt *Message[T]) GetID() networkid.MessageID { + return evt.ID +} + +func (evt *Message[T]) GetTargetMessage() networkid.MessageID { + return evt.TargetMessage +} + +func (evt *Message[T]) GetTransactionID() networkid.TransactionID { + return evt.TransactionID +} + +// PreConvertedMessage is a simple implementation of [bridgev2.RemoteMessage] with pre-converted data. +type PreConvertedMessage struct { + EventMeta + Data *bridgev2.ConvertedMessage + ID networkid.MessageID + TransactionID networkid.TransactionID + + HandleExistingFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) +} + +var ( + _ bridgev2.RemoteMessage = (*PreConvertedMessage)(nil) + _ bridgev2.RemoteMessageUpsert = (*PreConvertedMessage)(nil) + _ bridgev2.RemoteMessageWithTransactionID = (*PreConvertedMessage)(nil) +) + +func (evt *PreConvertedMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { + return evt.Data, nil +} + +func (evt *PreConvertedMessage) GetID() networkid.MessageID { + return evt.ID +} + +func (evt *PreConvertedMessage) GetTransactionID() networkid.TransactionID { + return evt.TransactionID +} + +func (evt *PreConvertedMessage) HandleExisting(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) { + if evt.HandleExistingFunc == nil { + return bridgev2.UpsertResult{}, nil + } + return evt.HandleExistingFunc(ctx, portal, intent, existing) +} + +type MessageRemove struct { + EventMeta + + TargetMessage networkid.MessageID + OnlyForMe bool + HidePlaceholder bool +} + +var ( + _ bridgev2.RemoteMessageRemove = (*MessageRemove)(nil) + _ bridgev2.RemoteDeleteOnlyForMe = (*MessageRemove)(nil) + _ bridgev2.RemoteMessageRemoveWithoutPlaceholder = (*MessageRemove)(nil) +) + +func (evt *MessageRemove) GetTargetMessage() networkid.MessageID { + return evt.TargetMessage +} + +func (evt *MessageRemove) DeleteOnlyForMe() bool { + return evt.OnlyForMe +} + +func (evt *MessageRemove) DontRenderPlaceholder() bool { + return evt.HidePlaceholder +} diff --git a/mautrix-patched/bridgev2/simplevent/meta.go b/mautrix-patched/bridgev2/simplevent/meta.go new file mode 100644 index 00000000..34a65f6c --- /dev/null +++ b/mautrix-patched/bridgev2/simplevent/meta.go @@ -0,0 +1,163 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package simplevent + +import ( + "context" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// EventMeta is a struct containing metadata fields used by most event types. +type EventMeta struct { + Type bridgev2.RemoteEventType + LogContext func(c zerolog.Context) zerolog.Context + PortalKey networkid.PortalKey + UncertainReceiver bool + Sender bridgev2.EventSender + CreatePortal bool + Timestamp time.Time + StreamOrder int64 + + PreHandleFunc func(context.Context, *bridgev2.Portal) + PostHandleFunc func(context.Context, *bridgev2.Portal) + MutateContextFunc func(context.Context) context.Context + + FetchCertainPortalKeyFunc func(context.Context) networkid.PortalKey +} + +var ( + _ bridgev2.RemoteEvent = (*EventMeta)(nil) + _ bridgev2.RemoteEventWithUncertainPortalReceiver = (*EventMeta)(nil) + _ bridgev2.RemoteEventWithUncertainPortalReceiverFetcher = (*EventMeta)(nil) + _ bridgev2.RemoteEventThatMayCreatePortal = (*EventMeta)(nil) + _ bridgev2.RemoteEventWithTimestamp = (*EventMeta)(nil) + _ bridgev2.RemoteEventWithStreamOrder = (*EventMeta)(nil) + _ bridgev2.RemotePreHandler = (*EventMeta)(nil) + _ bridgev2.RemotePostHandler = (*EventMeta)(nil) + _ bridgev2.RemoteEventWithContextMutation = (*EventMeta)(nil) +) + +func (evt *EventMeta) AddLogContext(c zerolog.Context) zerolog.Context { + if evt.LogContext == nil { + return c + } + return evt.LogContext(c) +} + +func (evt *EventMeta) GetPortalKey() networkid.PortalKey { + return evt.PortalKey +} + +func (evt *EventMeta) PortalReceiverIsUncertain() bool { + return evt.UncertainReceiver +} + +func (evt *EventMeta) FetchCertainPortalKey(ctx context.Context) networkid.PortalKey { + if evt.FetchCertainPortalKeyFunc == nil { + return networkid.PortalKey{} + } + return evt.FetchCertainPortalKeyFunc(ctx) +} + +func (evt *EventMeta) GetTimestamp() time.Time { + if evt.Timestamp.IsZero() { + return time.Now() + } + return evt.Timestamp +} + +func (evt *EventMeta) GetStreamOrder() int64 { + return evt.StreamOrder +} + +func (evt *EventMeta) GetSender() bridgev2.EventSender { + return evt.Sender +} + +func (evt *EventMeta) GetType() bridgev2.RemoteEventType { + return evt.Type +} + +func (evt *EventMeta) ShouldCreatePortal() bool { + return evt.CreatePortal +} + +func (evt *EventMeta) PreHandle(ctx context.Context, portal *bridgev2.Portal) { + if evt.PreHandleFunc != nil { + evt.PreHandleFunc(ctx, portal) + } +} + +func (evt *EventMeta) PostHandle(ctx context.Context, portal *bridgev2.Portal) { + if evt.PostHandleFunc != nil { + evt.PostHandleFunc(ctx, portal) + } +} + +func (evt *EventMeta) MutateContext(ctx context.Context) context.Context { + if evt.MutateContextFunc == nil { + return ctx + } + return evt.MutateContextFunc(ctx) +} + +func (evt EventMeta) WithType(t bridgev2.RemoteEventType) EventMeta { + evt.Type = t + return evt +} + +func (evt EventMeta) WithLogContext(f func(c zerolog.Context) zerolog.Context) EventMeta { + evt.LogContext = f + return evt +} + +func (evt EventMeta) WithMoreLogContext(f func(c zerolog.Context) zerolog.Context) EventMeta { + origFunc := evt.LogContext + if origFunc == nil { + evt.LogContext = f + return evt + } + evt.LogContext = func(c zerolog.Context) zerolog.Context { + return f(origFunc(c)) + } + return evt +} + +func (evt EventMeta) WithPortalKey(p networkid.PortalKey) EventMeta { + evt.PortalKey = p + return evt +} + +func (evt EventMeta) WithUncertainReceiver(u bool) EventMeta { + evt.UncertainReceiver = u + return evt +} + +func (evt EventMeta) WithSender(s bridgev2.EventSender) EventMeta { + evt.Sender = s + return evt +} + +func (evt EventMeta) WithCreatePortal(c bool) EventMeta { + evt.CreatePortal = c + return evt +} + +func (evt EventMeta) WithTimestamp(t time.Time) EventMeta { + evt.Timestamp = t + return evt +} + +func (evt EventMeta) WithStreamOrder(s int64) EventMeta { + evt.StreamOrder = s + return evt +} diff --git a/mautrix-patched/bridgev2/simplevent/reaction.go b/mautrix-patched/bridgev2/simplevent/reaction.go new file mode 100644 index 00000000..34e0b025 --- /dev/null +++ b/mautrix-patched/bridgev2/simplevent/reaction.go @@ -0,0 +1,67 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package simplevent + +import ( + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// Reaction is a simple implementation of [bridgev2.RemoteReaction] and [bridgev2.RemoteReactionRemove]. +type Reaction struct { + EventMeta + TargetMessage networkid.MessageID + EmojiID networkid.EmojiID + Emoji string + ExtraContent map[string]any + ReactionDBMeta any +} + +var ( + _ bridgev2.RemoteReaction = (*Reaction)(nil) + _ bridgev2.RemoteReactionWithMeta = (*Reaction)(nil) + _ bridgev2.RemoteReactionWithExtraContent = (*Reaction)(nil) + _ bridgev2.RemoteReactionRemove = (*Reaction)(nil) +) + +func (evt *Reaction) GetTargetMessage() networkid.MessageID { + return evt.TargetMessage +} + +func (evt *Reaction) GetReactionEmoji() (string, networkid.EmojiID) { + return evt.Emoji, evt.EmojiID +} + +func (evt *Reaction) GetRemovedEmojiID() networkid.EmojiID { + return evt.EmojiID +} + +func (evt *Reaction) GetReactionDBMetadata() any { + return evt.ReactionDBMeta +} + +func (evt *Reaction) GetReactionExtraContent() map[string]any { + return evt.ExtraContent +} + +type ReactionSync struct { + EventMeta + TargetMessage networkid.MessageID + Reactions *bridgev2.ReactionSyncData +} + +var ( + _ bridgev2.RemoteReactionSync = (*ReactionSync)(nil) +) + +func (evt *ReactionSync) GetTargetMessage() networkid.MessageID { + return evt.TargetMessage +} + +func (evt *ReactionSync) GetReactions() *bridgev2.ReactionSyncData { + return evt.Reactions +} diff --git a/mautrix-patched/bridgev2/simplevent/receipt.go b/mautrix-patched/bridgev2/simplevent/receipt.go new file mode 100644 index 00000000..41614e40 --- /dev/null +++ b/mautrix-patched/bridgev2/simplevent/receipt.go @@ -0,0 +1,77 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package simplevent + +import ( + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type Receipt struct { + EventMeta + + LastTarget networkid.MessageID + Targets []networkid.MessageID + ReadUpTo time.Time + + ReadUpToStreamOrder int64 +} + +var ( + _ bridgev2.RemoteReadReceipt = (*Receipt)(nil) + _ bridgev2.RemoteDeliveryReceipt = (*Receipt)(nil) +) + +func (evt *Receipt) GetLastReceiptTarget() networkid.MessageID { + return evt.LastTarget +} + +func (evt *Receipt) GetReceiptTargets() []networkid.MessageID { + return evt.Targets +} + +func (evt *Receipt) GetReadUpTo() time.Time { + return evt.ReadUpTo +} + +func (evt *Receipt) GetReadUpToStreamOrder() int64 { + return evt.ReadUpToStreamOrder +} + +type MarkUnread struct { + EventMeta + Unread bool +} + +var ( + _ bridgev2.RemoteMarkUnread = (*MarkUnread)(nil) +) + +func (evt *MarkUnread) GetUnread() bool { + return evt.Unread +} + +type Typing struct { + EventMeta + Timeout time.Duration + Type bridgev2.TypingType +} + +var ( + _ bridgev2.RemoteTyping = (*Typing)(nil) + _ bridgev2.RemoteTypingWithType = (*Typing)(nil) +) + +func (evt *Typing) GetTimeout() time.Duration { + return evt.Timeout +} + +func (evt *Typing) GetTypingType() bridgev2.TypingType { + return evt.Type +} diff --git a/mautrix-patched/bridgev2/space.go b/mautrix-patched/bridgev2/space.go new file mode 100644 index 00000000..d9c656f4 --- /dev/null +++ b/mautrix-patched/bridgev2/space.go @@ -0,0 +1,211 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (ul *UserLogin) MarkInPortal(ctx context.Context, portal *Portal) { + if ul.inPortalCache.Has(portal.PortalKey) { + return + } + userPortal, err := ul.Bridge.DB.UserPortal.GetOrCreate(ctx, ul.UserLogin, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure user portal row exists") + return + } + ul.inPortalCache.Add(portal.PortalKey) + if portal.MXID != "" { + dp := ul.User.DoublePuppet(ctx) + if dp != nil { + err = dp.EnsureJoined(ctx, portal.MXID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure double puppet is joined to portal") + } + } else { + err = ul.Bridge.Bot.EnsureInvited(ctx, portal.MXID, ul.UserMXID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure user is invited to portal") + } + } + if ul.Bridge.Config.PersonalFilteringSpaces && (userPortal.InSpace == nil || !*userPortal.InSpace) { + go ul.tryAddPortalToSpace(context.WithoutCancel(ctx), portal, userPortal.CopyWithoutValues()) + } + } +} + +func (ul *UserLogin) tryAddPortalToSpace(ctx context.Context, portal *Portal, userPortal *database.UserPortal) { + err := ul.AddPortalToSpace(ctx, portal, userPortal) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to add portal to space") + } +} + +func (ul *UserLogin) AddPortalToSpace(ctx context.Context, portal *Portal, userPortal *database.UserPortal) error { + if portal.MXID == "" || portal.Parent != nil { + return nil + } + spaceRoom, err := ul.GetSpaceRoom(ctx) + if err != nil { + return fmt.Errorf("failed to get space room: %w", err) + } else if spaceRoom == "" { + return nil + } + err = portal.toggleSpace(ctx, spaceRoom, false, false) + if err != nil { + return fmt.Errorf("failed to add portal to space: %w", err) + } + inSpace := true + userPortal.InSpace = &inSpace + err = ul.Bridge.DB.UserPortal.Put(ctx, userPortal) + if err != nil { + return fmt.Errorf("failed to save user portal row: %w", err) + } + zerolog.Ctx(ctx).Debug().Stringer("space_room_id", spaceRoom).Msg("Added portal to space") + return nil +} + +func (portal *Portal) createParentAndAddToSpace(ctx context.Context, source *UserLogin) { + err := portal.Parent.CreateMatrixRoom(ctx, source, nil) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create parent portal") + } else { + portal.addToParentSpaceAndSave(ctx, true) + } +} + +func (portal *Portal) addToParentSpaceAndSave(ctx context.Context, save bool) { + err := portal.toggleSpace(ctx, portal.Parent.MXID, true, false) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("space_mxid", portal.Parent.MXID).Msg("Failed to add portal to space") + } else { + portal.InSpace = true + if save { + err = portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after adding to space") + } + } + } +} + +func (portal *Portal) toggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error { + via := []string{portal.Bridge.Matrix.ServerName()} + if remove { + via = nil + } + _, err := portal.Bridge.Bot.SendState(ctx, spaceID, event.StateSpaceChild, portal.MXID.String(), &event.Content{ + Parsed: &event.SpaceChildEventContent{ + Via: via, + }, + }, time.Now()) + if err != nil { + return err + } + if canonical { + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateSpaceParent, spaceID.String(), &event.Content{ + Parsed: &event.SpaceParentEventContent{ + Via: via, + Canonical: !remove, + }, + }, time.Now()) + if err != nil { + return err + } + } + return nil +} + +func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { + if !ul.Bridge.Config.PersonalFilteringSpaces { + return ul.SpaceRoom, nil + } + ul.spaceCreateLock.Lock() + defer ul.spaceCreateLock.Unlock() + if ul.SpaceRoom != "" { + return ul.SpaceRoom, nil + } + netName := ul.Bridge.Network.GetName() + var err error + autoJoin := ul.Bridge.Matrix.GetCapabilities().AutoJoinInvites + doublePuppet := ul.User.DoublePuppet(ctx) + req := &mautrix.ReqCreateRoom{ + Visibility: "private", + Name: fmt.Sprintf("%s (%s)", netName.DisplayName, ul.RemoteName), + Topic: fmt.Sprintf("Your %s bridged chats - %s", netName.DisplayName, ul.RemoteName), + InitialState: []*event.Event{{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: &event.RoomAvatarEventContent{ + URL: netName.NetworkIcon, + }, + }, + }, { + Type: event.StateBridge, + Content: event.Content{ + Parsed: &event.BridgeEventContent{ + BridgeBot: ul.Bridge.Bot.GetMXID(), + Protocol: netName.AsBridgeInfoSection(), + Channel: event.BridgeInfoSection{ + ID: "__personal_filtering_space__", + Receiver: string(ul.ID), + }, + BeeperRoomTypeV2: "personal_filtering_space", + }, + }, + }}, + CreationContent: map[string]any{ + "type": event.RoomTypeSpace, + }, + PowerLevelOverride: &event.PowerLevelsEventContent{ + Users: map[id.UserID]int{ + ul.Bridge.Bot.GetMXID(): 9001, + ul.UserMXID: 50, + }, + }, + Invite: []id.UserID{ul.UserMXID}, + BeeperLocalRoomID: ul.Bridge.Matrix.GenerateDeterministicRoomID(networkid.PortalKey{ + ID: "__personal_filtering_space__", + Receiver: ul.ID, + }), + } + if autoJoin { + req.BeeperInitialMembers = []id.UserID{ul.UserMXID} + // TODO remove this after initial_members is supported in hungryserv + req.BeeperAutoJoinInvites = true + } + pfc, ok := ul.Client.(PersonalFilteringCustomizingNetworkAPI) + if ok { + pfc.CustomizePersonalFilteringSpace(req) + } + ul.SpaceRoom, err = ul.Bridge.Bot.CreateRoom(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to create space room: %w", err) + } + if !autoJoin && doublePuppet != nil { + err = doublePuppet.EnsureJoined(ctx, ul.SpaceRoom) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to auto-join created space room with double puppet") + } + } + err = ul.Save(ctx) + if err != nil { + return "", fmt.Errorf("failed to save space room ID: %w", err) + } + return ul.SpaceRoom, nil +} diff --git a/mautrix-patched/bridgev2/status/bridgestate.go b/mautrix-patched/bridgev2/status/bridgestate.go new file mode 100644 index 00000000..5925dd4f --- /dev/null +++ b/mautrix-patched/bridgev2/status/bridgestate.go @@ -0,0 +1,216 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package status + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "reflect" + "time" + + "github.com/tidwall/sjson" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type BridgeStateEvent string +type BridgeStateErrorCode string + +type BridgeStateErrorMap map[BridgeStateErrorCode]string + +func (bem BridgeStateErrorMap) Update(data BridgeStateErrorMap) { + for key, value := range data { + bem[key] = value + } +} + +var BridgeStateHumanErrors = make(BridgeStateErrorMap) + +const ( + StateStarting BridgeStateEvent = "STARTING" + StateUnconfigured BridgeStateEvent = "UNCONFIGURED" + StateRunning BridgeStateEvent = "RUNNING" + StateBridgeUnreachable BridgeStateEvent = "BRIDGE_UNREACHABLE" + + StateConnecting BridgeStateEvent = "CONNECTING" + StateBackfilling BridgeStateEvent = "BACKFILLING" + StateConnected BridgeStateEvent = "CONNECTED" + StateTransientDisconnect BridgeStateEvent = "TRANSIENT_DISCONNECT" + StateBadCredentials BridgeStateEvent = "BAD_CREDENTIALS" + StateUnknownError BridgeStateEvent = "UNKNOWN_ERROR" + StateLoggedOut BridgeStateEvent = "LOGGED_OUT" +) + +func (e BridgeStateEvent) IsValid() bool { + switch e { + case + StateStarting, + StateUnconfigured, + StateRunning, + StateBridgeUnreachable, + StateConnecting, + StateBackfilling, + StateConnected, + StateTransientDisconnect, + StateBadCredentials, + StateUnknownError, + StateLoggedOut: + return true + default: + return false + } +} + +type BridgeStateUserAction string + +const ( + UserActionOpenNative BridgeStateUserAction = "OPEN_NATIVE" + UserActionRelogin BridgeStateUserAction = "RELOGIN" + UserActionRestart BridgeStateUserAction = "RESTART" +) + +type RemoteProfile struct { + Phone string `json:"phone,omitempty"` + Email string `json:"email,omitempty"` + Username string `json:"username,omitempty"` + Name string `json:"name,omitempty"` + Avatar id.ContentURIString `json:"avatar,omitempty"` + + AvatarFile *event.EncryptedFileInfo `json:"avatar_file,omitempty"` +} + +func coalesce[T ~string](a, b T) T { + if a != "" { + return a + } + return b +} + +func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile { + other.Phone = coalesce(rp.Phone, other.Phone) + other.Email = coalesce(rp.Email, other.Email) + other.Username = coalesce(rp.Username, other.Username) + other.Name = coalesce(rp.Name, other.Name) + other.Avatar = coalesce(rp.Avatar, other.Avatar) + if rp.AvatarFile != nil { + other.AvatarFile = rp.AvatarFile + } + return other +} + +func (rp *RemoteProfile) IsZero() bool { + return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil) +} + +type BridgeState struct { + StateEvent BridgeStateEvent `json:"state_event"` + Timestamp jsontime.Unix `json:"timestamp"` + TTL int `json:"ttl"` + + Source string `json:"source,omitempty"` + Error BridgeStateErrorCode `json:"error,omitempty"` + Message string `json:"message,omitempty"` + + UserAction BridgeStateUserAction `json:"user_action,omitempty"` + + UserID id.UserID `json:"user_id,omitempty"` + RemoteID networkid.UserLoginID `json:"remote_id,omitempty"` + RemoteName string `json:"remote_name,omitempty"` + RemoteProfile RemoteProfile `json:"remote_profile,omitzero"` + + Reason string `json:"reason,omitempty"` + Info map[string]interface{} `json:"info,omitempty"` +} + +type GlobalBridgeState struct { + RemoteStates map[string]BridgeState `json:"remoteState"` + BridgeState BridgeState `json:"bridgeState"` +} + +type BridgeStateFiller interface { + FillBridgeState(BridgeState) BridgeState +} + +// Deprecated: use BridgeStateFiller instead +type StandaloneCustomBridgeStateFiller = BridgeStateFiller + +func (pong BridgeState) Fill(user BridgeStateFiller) BridgeState { + if user != nil { + pong = user.FillBridgeState(pong) + } + + pong.Timestamp = jsontime.UnixNow() + pong.Source = "bridge" + if len(pong.Error) > 0 { + pong.TTL = 3600 + msg, ok := BridgeStateHumanErrors[pong.Error] + if ok { + pong.Message = msg + } + } else { + pong.TTL = 21600 + } + return pong +} + +func (pong *BridgeState) SendHTTP(ctx context.Context, url, token string) error { + var body []byte + var err error + if body, err = json.Marshal(&pong); err != nil { + return fmt.Errorf("failed to encode bridge state JSON: %w", err) + } + + if pong.StateEvent == StateBridgeUnreachable { + body, err = sjson.SetBytes(body, "stateEvent", pong.StateEvent) + if err != nil { + return fmt.Errorf("failed to add stateEvent field to bridge_unreachable state") + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to prepare request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (bridge state sender)") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + respBody, _ := io.ReadAll(resp.Body) + if respBody != nil { + respBody = bytes.ReplaceAll(respBody, []byte("\n"), []byte("\\n")) + } + return fmt.Errorf("unexpected status code %d sending bridge state update: %s", resp.StatusCode, respBody) + } + return nil +} + +func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool { + return pong != nil && + pong.StateEvent == newPong.StateEvent && + pong.RemoteName == newPong.RemoteName && + pong.UserAction == newPong.UserAction && + pong.RemoteProfile == newPong.RemoteProfile && + pong.Error == newPong.Error && + maps.EqualFunc(pong.Info, newPong.Info, reflect.DeepEqual) && + pong.Timestamp.Add(time.Duration(pong.TTL)*time.Second).After(time.Now()) +} diff --git a/mautrix-patched/bridgev2/status/localbridgestate.go b/mautrix-patched/bridgev2/status/localbridgestate.go new file mode 100644 index 00000000..3ad66538 --- /dev/null +++ b/mautrix-patched/bridgev2/status/localbridgestate.go @@ -0,0 +1,23 @@ +package status + +type LocalBridgeAccountState string + +const ( + // LocalBridgeAccountStateSetup means the user wants this account to be setup and connected + LocalBridgeAccountStateSetup LocalBridgeAccountState = "SETUP" + // LocalBridgeAccountStateDeleted means the user wants this account to be deleted + LocalBridgeAccountStateDeleted LocalBridgeAccountState = "DELETED" +) + +type LocalBridgeDeviceState string + +const ( + // LocalBridgeDeviceStateSetup means this device is setup to be connected to this account + LocalBridgeDeviceStateSetup LocalBridgeDeviceState = "SETUP" + // LocalBridgeDeviceStateLoggedOut means the user has logged this particular device out while wanting their other devices to remain setup + LocalBridgeDeviceStateLoggedOut LocalBridgeDeviceState = "LOGGED_OUT" + // LocalBridgeDeviceStateError means this particular device has fallen into a persistent error state that may need user intervention to fix + LocalBridgeDeviceStateError LocalBridgeDeviceState = "ERROR" + // LocalBridgeDeviceStateDeleted means this particular device has cleaned up after the account as a whole was requested to be deleted + LocalBridgeDeviceStateDeleted LocalBridgeDeviceState = "DELETED" +) diff --git a/mautrix-patched/bridgev2/status/messagecheckpoint.go b/mautrix-patched/bridgev2/status/messagecheckpoint.go new file mode 100644 index 00000000..b3c05f4f --- /dev/null +++ b/mautrix-patched/bridgev2/status/messagecheckpoint.go @@ -0,0 +1,212 @@ +// Copyright (c) 2021 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package status + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type MessageCheckpointStep string + +const ( + MsgStepClient MessageCheckpointStep = "CLIENT" + MsgStepHomeserver MessageCheckpointStep = "HOMESERVER" + MsgStepBridge MessageCheckpointStep = "BRIDGE" + MsgStepDecrypted MessageCheckpointStep = "DECRYPTED" + MsgStepRemote MessageCheckpointStep = "REMOTE" + MsgStepCommand MessageCheckpointStep = "COMMAND" +) + +func (mcs MessageCheckpointStep) order() int { + checkpointOrder := map[MessageCheckpointStep]int{ + MsgStepClient: 0, + MsgStepHomeserver: 1, + MsgStepBridge: 2, + MsgStepDecrypted: 3, + MsgStepRemote: 4, + MsgStepCommand: 4, + } + if order, ok := checkpointOrder[mcs]; !ok { + panic(fmt.Sprintf("Unknown checkpoint step %s", mcs)) + } else { + return order + } +} + +func (mcs MessageCheckpointStep) Before(other MessageCheckpointStep) bool { + return mcs.order() < other.order() +} + +func (mcs MessageCheckpointStep) IsValid() bool { + switch mcs { + case MsgStepClient, MsgStepHomeserver, MsgStepBridge, MsgStepDecrypted, MsgStepRemote, MsgStepCommand: + return true + } + return false +} + +type MessageCheckpointStatus string + +const ( + MsgStatusSuccess MessageCheckpointStatus = "SUCCESS" + MsgStatusWillRetry MessageCheckpointStatus = "WILL_RETRY" + MsgStatusPermFailure MessageCheckpointStatus = "PERM_FAILURE" + MsgStatusUnsupported MessageCheckpointStatus = "UNSUPPORTED" + MsgStatusTimeout MessageCheckpointStatus = "TIMEOUT" + MsgStatusDelivered MessageCheckpointStatus = "DELIVERED" + MsgStatusDeliveryFailed MessageCheckpointStatus = "DELIVERY_FAILED" +) + +func (mcs MessageCheckpointStatus) IsValid() bool { + switch mcs { + case MsgStatusSuccess, MsgStatusWillRetry, MsgStatusPermFailure, MsgStatusUnsupported, MsgStatusTimeout, MsgStatusDelivered, MsgStatusDeliveryFailed: + return true + } + return false +} + +func ReasonToCheckpointStatus(reason event.MessageStatusReason, status event.MessageStatus) MessageCheckpointStatus { + if status == event.MessageStatusPending { + return MsgStatusWillRetry + } + switch reason { + case event.MessageStatusUnsupported: + return MsgStatusUnsupported + case event.MessageStatusTooOld: + return MsgStatusTimeout + default: + return MsgStatusPermFailure + } +} + +type MessageCheckpointReportedBy string + +const ( + MsgReportedByAsmux MessageCheckpointReportedBy = "ASMUX" + MsgReportedByBridge MessageCheckpointReportedBy = "BRIDGE" + MsgReportedByHungry MessageCheckpointReportedBy = "HUNGRYSERV" +) + +func (mcrb MessageCheckpointReportedBy) IsValid() bool { + switch mcrb { + case MsgReportedByAsmux, MsgReportedByBridge, MsgReportedByHungry: + return true + } + return false +} + +type MessageCheckpoint struct { + EventID id.EventID `json:"event_id"` + RoomID id.RoomID `json:"room_id"` + Step MessageCheckpointStep `json:"step"` + Timestamp jsontime.UnixMilli `json:"timestamp"` + Status MessageCheckpointStatus `json:"status"` + EventType event.Type `json:"event_type"` + ReportedBy MessageCheckpointReportedBy `json:"reported_by"` + RetryNum int `json:"retry_num"` + MessageType event.MessageType `json:"message_type,omitempty"` + Info string `json:"info,omitempty"` + + ClientType string `json:"client_type,omitempty"` + ClientVersion string `json:"client_version,omitempty"` + + OriginalEventID id.EventID `json:"original_event_id,omitempty"` + ManualRetryCount int `json:"manual_retry_count,omitempty"` +} + +var CheckpointTypes = map[event.Type]struct{}{ + event.EventRedaction: {}, + event.EventMessage: {}, + event.EventEncrypted: {}, + event.EventSticker: {}, + event.EventReaction: {}, + //event.CallInvite: {}, + //event.CallCandidates: {}, + //event.CallSelectAnswer: {}, + //event.CallAnswer: {}, + //event.CallHangup: {}, + //event.CallReject: {}, + //event.CallNegotiate: {}, +} + +func NewMessageCheckpoint(evt *event.Event, step MessageCheckpointStep, status MessageCheckpointStatus, retryNum int) *MessageCheckpoint { + checkpoint := MessageCheckpoint{ + EventID: evt.ID, + RoomID: evt.RoomID, + Step: step, + Timestamp: jsontime.UnixMilliNow(), + Status: status, + EventType: evt.Type, + ReportedBy: MsgReportedByBridge, + RetryNum: retryNum, + } + if evt.Type == event.EventMessage { + checkpoint.MessageType = evt.Content.AsMessage().MsgType + } + if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil { + checkpoint.OriginalEventID = retryMeta.OriginalEventID + checkpoint.ManualRetryCount = retryMeta.RetryCount + } + return &checkpoint +} + +type CheckpointsJSON struct { + Checkpoints []*MessageCheckpoint `json:"checkpoints"` +} + +func (cj *CheckpointsJSON) SendHTTP(ctx context.Context, cli *http.Client, endpoint string, token string) error { + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(cj); err != nil { + return fmt.Errorf("failed to encode message checkpoint JSON: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (checkpoint sender)") + req.Header.Set("Content-Type", "application/json") + + if cli == nil { + cli = http.DefaultClient + } + resp, err := cli.Do(req) + if err != nil { + return mautrix.HTTPError{ + Request: req, + Response: resp, + + WrappedError: err, + Message: "failed to send message checkpoint", + } + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "failed to send message checkpoint", + } + } + return nil +} diff --git a/mautrix-patched/bridgev2/unorganized-docs/FEATURES.md b/mautrix-patched/bridgev2/unorganized-docs/FEATURES.md new file mode 100644 index 00000000..73da364d --- /dev/null +++ b/mautrix-patched/bridgev2/unorganized-docs/FEATURES.md @@ -0,0 +1,49 @@ +# Megabridge features + +* [ ] Messages + * [x] Text (incl. formatting and mentions) + * [x] Attachments + * [ ] Polls + * [x] Replies + * [x] Threads + * [x] Edits + * [x] Reactions + * [x] Reaction mass-syncing + * [x] Deletions + * [x] Message status events and error notices + * [x] Backfilling history +* [x] Login +* [x] Logout +* [x] Re-login after credential expiry +* [x] Disappearing messages +* [x] Read receipts +* [ ] Presence +* [x] Typing notifications +* [x] Spaces +* [x] Relay mode +* [x] Chat metadata + * [x] Archive/low priority + * [x] Pin/favorite + * [x] Mark unread + * [x] Mute status + * [x] Temporary mutes ("snooze") +* [x] User metadata (name/avatar) +* [x] Group metadata + * [x] Initial meta and full resyncs + * [x] Name, avatar, topic + * [x] Members + * [x] Permissions + * [x] Change events + * [x] Name, avatar, topic + * [x] Members (join, leave, invite, kick, ban, knock) + * [x] Permissions (promote, demote) +* [ ] Misc actions + * [ ] Invites / accepting message requests + * [x] Create group + * [x] Create DM + * [x] Get contact list + * [x] Check if identifier is on remote network + * [x] Search users on remote network + * [ ] Delete chat + * [ ] Report spam +* [ ] Custom emojis diff --git a/mautrix-patched/bridgev2/unorganized-docs/README.md b/mautrix-patched/bridgev2/unorganized-docs/README.md new file mode 100644 index 00000000..62cc731a --- /dev/null +++ b/mautrix-patched/bridgev2/unorganized-docs/README.md @@ -0,0 +1,66 @@ +# Megabridge +Megabridge, also known as bridgev2 (final naming is subject to change), is a +new high-level framework for writing puppeting Matrix bridges with hopefully +minimal boilerplate code. + +## General architecture +Megabridge is split into three components: network connectors, the central +bridge module, and Matrix connectors. + +* Network connectors are responsible for connecting to the remote (non-Matrix) + network and handling all the protocol-specific details. +* The central bridge module has most of the generic bridge logic, such as + keeping track of portal mappings and handling messages. +* Matrix connectors are responsible for connecting to Matrix. Initially there + will be two Matrix connectors: one for the standard setup that connects to + a Matrix homeserver as an application service, and another for Beeper's local + bridge system. However, in the future there could be a third connector which + uses a single bot account and [MSC4144] instead of an appservice with ghost + users. + + [MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144 + +The central bridge module defines interfaces that it uses to interact with the +connectors on both sides. Additionally, the connectors are allowed to directly +call interface methods on other side. + +## Getting started with a new network connector +To create a new network connector, you need to implement the +`NetworkConnector`, `LoginProcess`, `NetworkAPI` and `RemoteEvent` interfaces. + +* `NetworkConnector` is the main entry point to the remote network. It is + responsible for general non-user-specific things, as well as creating + `NetworkAPI`s and starting login flows. +* `LoginProcess` is a state machine for logging into the remote network. +* `NetworkAPI` is the remote network client for a single login. It is + responsible for maintaining the connection to the remote network, receiving + incoming events, sending outgoing events, and fetching information like + chat/user metadata. +* `RemoteEvent` represents a single event from the remote network, such as a + message or a reaction. When the NetworkAPI receives an event, it should create + a `RemoteEvent` object and pass it to the bridge using `Bridge.QueueRemoteEvent`. + +### Login +Logins are implemented by combining three types of steps: + +* `user_input` asks the user to enter some information, such as a phone number, + username, email, password, or 2FA code. +* `cookies` either asks the user to extract cookies from their browser, or opens + a webview to do it automatically (depending on whether the login is being done + via bridge commands or a more advanced client). +* `display_and_wait` displays a QR code or other data to the user and waits until + the remote network accepts the login. + +The general flow is: + +1. Login handler (bridge command or client) calls `NetworkConnector.GetLoginFlows` + to get available login flows, and asks the user to pick one (or alternatively + automatically picks the first one if there's only one option). +2. Login handler calls `NetworkConnector.CreateLogin` with the chosen flow ID and + the network connector returns a `LoginProcess` object that remembers the user + and flow. +3. Login handler calls `LoginProcess.Start` to get the first step. +4. Login handler calls the appropriate functions (`Wait`, `SubmitUserInput` or + `SubmitCookies`) based on the step data as many times as needed. +5. When the login is done, the login process creates the `UserLogin` object and + returns a `complete` step. diff --git a/mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml b/mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml new file mode 100644 index 00000000..ae13ee74 --- /dev/null +++ b/mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml @@ -0,0 +1,23 @@ +title Bridge v2 incoming Matrix message + +participant Network Library +participant Network Connector +participant Bridge +participant Portal +participant Database +participant Matrix + +Matrix->Bridge: QueueMatrixEvent(evt) +note over Bridge: GetPortalByID(evt.GetPortalID()) +Bridge->Portal: portal.events <- evt +loop event queue consumer + Portal->+Portal: \n evt := <-portal.events + note over Portal: Check for edit, reply/thread, etc + Portal->+Network Connector: HandleMatrixMessage(evt, replyTo) + Network Connector->Network Connector: msg := ConvertMatrixMessage(evt) + Network Connector->+Network Library: SendMessage(msg) + Network Library->-Network Connector: OK + Network Connector->-Portal: *database.Message{msg.ID} + Portal->-Database: Message.Insert() + Portal->Matrix: Success checkpoint +end diff --git a/mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml b/mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml new file mode 100644 index 00000000..f86d6e65 --- /dev/null +++ b/mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml @@ -0,0 +1,22 @@ +title Bridge v2 incoming remote message + +participant Network Library +participant Network Connector +participant Bridge +participant Portal +participant Database +participant Matrix + +Network Library->Network Connector: New event +Network Connector->Bridge: QueueRemoteEvent(evt) +note over Bridge: GetPortalByID(evt.GetPortalID()) +Bridge->Portal: portal.events <- evt +loop event queue consumer + Portal->+Portal: \n evt := <-portal.events + note over Portal: CreateMatrixRoom() if applicable + Portal->+Network Connector: ConvertRemoteMessage(evt) + Network Connector->-Portal: *ConvertedMessage + Portal->+Matrix: SendMessage(convertedMsg) + Matrix->-Portal: event ID + Portal->-Database: Message.Insert() +end diff --git a/mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json b/mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json new file mode 100644 index 00000000..b039354f --- /dev/null +++ b/mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go.mau.fi/mautrix/bridgev2/login-step.json", + "title": "Login step data", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["user_input", "cookies", "display_and_wait", "complete"] + }, + "step_id": { + "type": "string", + "description": "An unique ID identifying this step. This can be used to implement special behavior in clients." + }, + "instructions": { + "type": "string", + "description": "Human-readable instructions for completing this login step." + }, + "user_input": { + "type": "object", + "title": "User input params", + "description": "Parameters for the `user_input` login type", + "properties": { + "fields": { + "type": "array", + "description": "The list of fields that the user must fill", + "items": { + "title": "Field", + "description": "A field that the user must fill", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["username", "phone_number", "email", "password", "2fa_code", "token"] + }, + "id": { + "type": "string", + "description": "The ID of the field. This should be used when submitting the form.", + "examples": ["uid", "email", "2fa_password", "meow"] + }, + "name": { + "type": "string", + "description": "The name of the field shown to the user", + "examples": ["Username", "Password", "Phone number", "2FA code", "Meow"] + }, + "description": { + "type": "string", + "description": "The description of the field shown to the user", + "examples": ["Include the country code with a +"] + }, + "pattern": { + "type": "string", + "description": "A regular expression that the field value must match" + } + }, + "required": ["type", "id", "name"] + } + } + }, + "required": ["fields"] + }, + "cookies": { + "type": "object", + "title": "Cookie params", + "description": "Parameters for the `cookies` login type", + "properties": { + "url": { + "type": "string", + "description": "The URL to open when using a webview to extract cookies" + }, + "user_agent": { + "type": "string", + "description": "The user agent to use when opening the URL" + }, + "fields": { + "type": "array", + "description": "The list of cookies (or other stored data) that must be extracted", + "items": { + "title": "Cookie Field", + "description": "A cookie (or other stored data) that must be extracted", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of data to extract", + "enum": ["cookie", "local_storage", "request_header", "request_body", "special"] + }, + "name": { + "type": "string", + "description": "The name of the cookie or key in the storage" + }, + "request_url_regex": { + "type": "string", + "description": "For the `request_header` and `request_body` types, a regex that matches the URLs from which the values can be extracted." + }, + "cookie_domain": { + "type": "string", + "description": "For the `cookie` type, the domain of the cookie" + } + }, + "required": ["type", "name"] + } + }, + "extract_js": { + "type": "string", + "description": "JavaScript code that can be evaluated inside the webview to extract the special keys" + } + }, + "required": ["url"] + }, + "display_and_wait": { + "type": "object", + "title": "Display and wait params", + "description": "Parameters for the `display_and_wait` login type", + "properties": { + "type": { + "type": "string", + "description": "The type of thing to display", + "enum": ["qr", "emoji", "code", "nothing"] + }, + "data": { + "type": "string", + "description": "The thing to display (raw data for QR, unicode emoji for emoji, plain string for code)" + }, + "image_url": { + "type": "string", + "description": "An image containing the thing to display. If present, this is recommended over using data directly. For emojis, the URL to the canonical image representation of the emoji" + } + }, + "required": ["type"] + }, + "complete": { + "type": "object", + "title": "Login complete information", + "description": "Information about a successful login", + "properties": { + "user_login_id": { + "type": "string", + "description": "The ID of the user login entry" + } + } + } + }, + "required": [ + "type", + "step_id", + "instructions" + ], + "oneOf": [ + {"title":"User input type","properties":{"type": {"type":"string","const": "user_input"}}, "required": ["user_input"]}, + {"title":"Cookies type","properties":{"type": {"type":"string","const": "cookies"}}, "required": ["cookies"]}, + {"title":"Display and wait type","properties":{"type": {"type":"string","const": "display_and_wait"}}, "required": ["display_and_wait"]}, + {"title":"Login complete","properties":{"type": {"type":"string","const": "complete"}}} + ] +} diff --git a/mautrix-patched/bridgev2/unorganized-docs/login-steps.uml b/mautrix-patched/bridgev2/unorganized-docs/login-steps.uml new file mode 100644 index 00000000..5af9c88e --- /dev/null +++ b/mautrix-patched/bridgev2/unorganized-docs/login-steps.uml @@ -0,0 +1,43 @@ +title Login flows + +participant User +participant Client +participant Bridge +participant User's device + +alt Username+Password/Phone number/2FA code + Client->+Bridge: /login + Bridge->-Client: step=user_input, fields=[...] + Client->User: input box(es) + User->Client: submit input + Client->+Bridge: /login/user_input + Bridge->-Client: success=true, step=next step +end + +alt Cookies + Client->+Bridge: /login + Bridge->-Client: step=cookies, url=..., cookies=[...] + Client->User: webview + User->Client: login in webview + Client->Bridge: /login/cookies + Bridge->-Client: success=true, step=next step +end + +alt QR/Emoji/Code + Client->+Bridge: /login + Bridge->-Client: step=display_and_wait, data=... + Client->+Bridge: /login/wait + Client->User: display QR/emoji/code + loop Refresh QR + Bridge->-Client: step=display_and_wait, data=new QR + Client->User: display new QR + Client->+Bridge: /login/wait + end +else Successful case + User->User's device: Scan QR/tap emoji/enter code + User's device->Bridge: Login successful + Bridge->-Client: success=true, step=next step +else Error + Bridge->Client: error=timeout + Client->User: error +end diff --git a/mautrix-patched/bridgev2/user.go b/mautrix-patched/bridgev2/user.go new file mode 100644 index 00000000..9a7896d6 --- /dev/null +++ b/mautrix-patched/bridgev2/user.go @@ -0,0 +1,275 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "context" + "fmt" + "strings" + "sync" + "unsafe" + + "github.com/rs/zerolog" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type User struct { + *database.User + Bridge *Bridge + Log zerolog.Logger + + CommandState unsafe.Pointer + Permissions bridgeconfig.Permissions + + doublePuppetIntent MatrixAPI + doublePuppetInitialized bool + doublePuppetLock sync.Mutex + + managementCreateLock sync.Mutex + + logins map[networkid.UserLoginID]*UserLogin +} + +func (br *Bridge) loadUser(ctx context.Context, dbUser *database.User, queryErr error, userID *id.UserID) (*User, error) { + if queryErr != nil { + return nil, fmt.Errorf("failed to query db: %w", queryErr) + } + if dbUser == nil { + if userID == nil { + return nil, nil + } + dbUser = &database.User{ + BridgeID: br.ID, + MXID: *userID, + } + err := br.DB.User.Insert(ctx, dbUser) + if err != nil { + return nil, fmt.Errorf("failed to insert new user: %w", err) + } + } + user := &User{ + User: dbUser, + Bridge: br, + Log: br.Log.With().Stringer("user_mxid", dbUser.MXID).Logger(), + logins: make(map[networkid.UserLoginID]*UserLogin), + Permissions: br.Config.Permissions.Get(dbUser.MXID), + } + br.usersByMXID[user.MXID] = user + err := br.unlockedLoadUserLoginsByMXID(ctx, user) + if err != nil { + return nil, fmt.Errorf("failed to load user logins: %w", err) + } + return user, nil +} + +func (br *Bridge) unlockedGetUserByMXID(ctx context.Context, userID id.UserID, onlyIfExists bool) (*User, error) { + cached, ok := br.usersByMXID[userID] + if ok { + return cached, nil + } + idPtr := &userID + if onlyIfExists { + idPtr = nil + } + db, err := br.DB.User.GetByMXID(ctx, userID) + return br.loadUser(ctx, db, err, idPtr) +} + +func (br *Bridge) GetUserByMXID(ctx context.Context, userID id.UserID) (*User, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.unlockedGetUserByMXID(ctx, userID, false) +} + +func (br *Bridge) GetExistingUserByMXID(ctx context.Context, userID id.UserID) (*User, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.unlockedGetUserByMXID(ctx, userID, true) +} + +func (user *User) LogoutDoublePuppet(ctx context.Context) { + user.doublePuppetLock.Lock() + defer user.doublePuppetLock.Unlock() + user.AccessToken = "" + err := user.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save removed access token") + } + user.doublePuppetIntent = nil + user.doublePuppetInitialized = false +} + +func (user *User) LoginDoublePuppet(ctx context.Context, token string) error { + if token == "" { + return fmt.Errorf("no token provided") + } + user.doublePuppetLock.Lock() + defer user.doublePuppetLock.Unlock() + intent, newToken, err := user.Bridge.Matrix.NewUserIntent(ctx, user.MXID, token) + if err != nil { + return err + } + user.AccessToken = newToken + user.doublePuppetIntent = intent + user.doublePuppetInitialized = true + err = user.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save new access token") + } + if newToken != token { + return fmt.Errorf("logging in manually is not supported when automatic double puppeting is enabled") + } + return nil +} + +func (user *User) DoublePuppet(ctx context.Context) MatrixAPI { + user.doublePuppetLock.Lock() + defer user.doublePuppetLock.Unlock() + if user.doublePuppetInitialized { + return user.doublePuppetIntent + } + user.doublePuppetInitialized = true + log := user.Log.With().Str("action", "setup double puppet").Logger() + ctx = log.WithContext(ctx) + intent, newToken, err := user.Bridge.Matrix.NewUserIntent(ctx, user.MXID, user.AccessToken) + if err != nil { + log.Err(err).Msg("Failed to create new user intent") + return nil + } + user.doublePuppetIntent = intent + if newToken != user.AccessToken { + user.AccessToken = newToken + err = user.Save(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to save new access token") + } + } + return intent +} + +func (user *User) GetUserLoginIDs() []networkid.UserLoginID { + user.Bridge.cacheLock.Lock() + defer user.Bridge.cacheLock.Unlock() + return maps.Keys(user.logins) +} + +// Deprecated: renamed to GetUserLogins +func (user *User) GetCachedUserLogins() []*UserLogin { + return user.GetUserLogins() +} + +func (user *User) GetUserLogins() []*UserLogin { + user.Bridge.cacheLock.Lock() + defer user.Bridge.cacheLock.Unlock() + return maps.Values(user.logins) +} + +func (user *User) HasTooManyLogins() bool { + return user.Permissions.MaxLogins > 0 && len(user.GetUserLoginIDs()) >= user.Permissions.MaxLogins +} + +func (user *User) GetFormattedUserLogins() string { + user.Bridge.cacheLock.Lock() + logins := make([]string, len(user.logins)) + for key, val := range user.logins { + logins = append(logins, fmt.Sprintf("* `%s` (%s) - `%s`", key, val.RemoteName, val.BridgeState.GetPrev().StateEvent)) + } + user.Bridge.cacheLock.Unlock() + return strings.Join(logins, "\n") +} + +func (user *User) GetDefaultLogin() *UserLogin { + user.Bridge.cacheLock.Lock() + defer user.Bridge.cacheLock.Unlock() + if len(user.logins) == 0 { + return nil + } + loginKeys := maps.Keys(user.logins) + slices.Sort(loginKeys) + return user.logins[loginKeys[0]] +} + +func (user *User) GetManagementRoom(ctx context.Context) (id.RoomID, error) { + user.managementCreateLock.Lock() + defer user.managementCreateLock.Unlock() + if user.ManagementRoom != "" { + return user.ManagementRoom, nil + } + netName := user.Bridge.Network.GetName() + var err error + autoJoin := user.Bridge.Matrix.GetCapabilities().AutoJoinInvites + doublePuppet := user.DoublePuppet(ctx) + req := &mautrix.ReqCreateRoom{ + Visibility: "private", + Name: netName.DisplayName, + Topic: fmt.Sprintf("%s bridge management room", netName.DisplayName), + InitialState: []*event.Event{{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: &event.RoomAvatarEventContent{ + URL: netName.NetworkIcon, + }, + }, + }}, + PowerLevelOverride: &event.PowerLevelsEventContent{ + Users: map[id.UserID]int{ + user.Bridge.Bot.GetMXID(): 9001, + user.MXID: 50, + }, + }, + Invite: []id.UserID{user.MXID}, + IsDirect: true, + } + if autoJoin { + req.BeeperInitialMembers = []id.UserID{user.MXID} + // TODO remove this after initial_members is supported in hungryserv + req.BeeperAutoJoinInvites = true + } + user.ManagementRoom, err = user.Bridge.Bot.CreateRoom(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to create management room: %w", err) + } + if !autoJoin && doublePuppet != nil { + err = doublePuppet.EnsureJoined(ctx, user.ManagementRoom) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to auto-join created management room with double puppet") + } + } + err = user.Save(ctx) + if err != nil { + return "", fmt.Errorf("failed to save management room ID: %w", err) + } + return user.ManagementRoom, nil +} + +func (user *User) Save(ctx context.Context) error { + return user.Bridge.DB.User.Update(ctx, user.User) +} + +func (br *Bridge) TrackAnalytics(userID id.UserID, event string, props map[string]any) { + analyticSender, ok := br.Matrix.(MatrixConnectorWithAnalytics) + if ok { + analyticSender.TrackAnalytics(userID, event, props) + } +} + +func (user *User) TrackAnalytics(event string, props map[string]any) { + user.Bridge.TrackAnalytics(user.MXID, event, props) +} + +func (ul *UserLogin) TrackAnalytics(event string, props map[string]any) { + // TODO include user login ID? + ul.Bridge.TrackAnalytics(ul.UserMXID, event, props) +} diff --git a/mautrix-patched/bridgev2/userlogin.go b/mautrix-patched/bridgev2/userlogin.go new file mode 100644 index 00000000..9d4a5f1f --- /dev/null +++ b/mautrix-patched/bridgev2/userlogin.go @@ -0,0 +1,577 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "sync" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exsync" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" +) + +type UserLogin struct { + *database.UserLogin + Bridge *Bridge + User *User + Log zerolog.Logger + + Client NetworkAPI + BridgeState *BridgeStateQueue + + inPortalCache *exsync.Set[networkid.PortalKey] + + spaceCreateLock sync.Mutex + deleteLock sync.Mutex + disconnectOnce sync.Once +} + +func (br *Bridge) loadUserLogin(ctx context.Context, user *User, dbUserLogin *database.UserLogin) (*UserLogin, error) { + if dbUserLogin == nil { + return nil, nil + } + if user == nil { + var err error + user, err = br.unlockedGetUserByMXID(ctx, dbUserLogin.UserMXID, true) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + // TODO if loading the user caused the provided userlogin to be loaded, cancel here? + // Currently this will double-load it + } + userLogin := &UserLogin{ + UserLogin: dbUserLogin, + Bridge: br, + User: user, + Log: user.Log.With().Str("login_id", string(dbUserLogin.ID)).Logger(), + + inPortalCache: exsync.NewSet[networkid.PortalKey](), + } + err := br.Network.LoadUserLogin(ctx, userLogin) + if err != nil { + userLogin.Log.Err(err).Msg("Failed to load user login") + return nil, nil + } else if userLogin.Client == nil { + userLogin.Log.Error().Msg("LoadUserLogin didn't fill Client") + return nil, nil + } + userLogin.BridgeState = br.NewBridgeStateQueue(userLogin) + user.logins[userLogin.ID] = userLogin + br.userLoginsByID[userLogin.ID] = userLogin + return userLogin, nil +} + +func (br *Bridge) loadManyUserLogins(ctx context.Context, user *User, logins []*database.UserLogin) ([]*UserLogin, error) { + output := make([]*UserLogin, 0, len(logins)) + for _, dbLogin := range logins { + if cached, ok := br.userLoginsByID[dbLogin.ID]; ok { + output = append(output, cached) + } else { + loaded, err := br.loadUserLogin(ctx, user, dbLogin) + if err != nil { + return nil, err + } else if loaded != nil { + output = append(output, loaded) + } + } + } + return output, nil +} + +func (br *Bridge) unlockedLoadUserLoginsByMXID(ctx context.Context, user *User) error { + logins, err := br.DB.UserLogin.GetAllForUser(ctx, user.MXID) + if err != nil { + return err + } + _, err = br.loadManyUserLogins(ctx, user, logins) + return err +} + +func (br *Bridge) GetUserLoginsInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserLogin, error) { + if portal.Receiver != "" { + ul := br.GetCachedUserLoginByID(portal.Receiver) + if ul == nil { + return nil, nil + } + return []*UserLogin{ul}, nil + } + logins, err := br.DB.UserLogin.GetAllInPortal(ctx, portal) + if err != nil { + return nil, err + } + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.loadManyUserLogins(ctx, nil, logins) +} + +func (br *Bridge) GetExistingUserLoginByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.unlockedGetExistingUserLoginByID(ctx, id) +} + +func (br *Bridge) unlockedGetExistingUserLoginByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) { + cached, ok := br.userLoginsByID[id] + if ok { + return cached, nil + } + login, err := br.DB.UserLogin.GetByID(ctx, id) + if err != nil { + return nil, err + } + return br.loadUserLogin(ctx, nil, login) +} + +func (br *Bridge) GetCachedUserLoginByID(id networkid.UserLoginID) *UserLogin { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return br.userLoginsByID[id] +} + +func (br *Bridge) GetAllCachedUserLogins() (logins []*UserLogin) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + return slices.Collect(maps.Values(br.userLoginsByID)) +} + +func (br *Bridge) GetCurrentBridgeStates() (states []status.BridgeState) { + br.cacheLock.Lock() + defer br.cacheLock.Unlock() + if len(br.userLoginsByID) == 0 { + return []status.BridgeState{{ + StateEvent: status.StateUnconfigured, + }} + } + states = make([]status.BridgeState, len(br.userLoginsByID)) + i := 0 + for _, login := range br.userLoginsByID { + states[i] = login.BridgeState.GetPrev() + i++ + } + return +} + +type NewLoginParams struct { + LoadUserLogin func(context.Context, *UserLogin) error + DeleteOnConflict bool + DontReuseExisting bool +} + +// NewLogin creates a UserLogin object for this user with the given parameters. +// +// If a login already exists with the same ID, it is reused after updating the remote name +// and metadata from the provided data, unless DontReuseExisting is set in params. +// +// If the existing login belongs to another user, this returns an error, +// unless DeleteOnConflict is set in the params, in which case the existing login is deleted. +// +// This will automatically call LoadUserLogin after creating the UserLogin object. +// The load method defaults to the network connector's LoadUserLogin method, but it can be overridden in params. +func (user *User) NewLogin(ctx context.Context, data *database.UserLogin, params *NewLoginParams) (*UserLogin, error) { + user.Bridge.cacheLock.Lock() + defer user.Bridge.cacheLock.Unlock() + data.BridgeID = user.BridgeID + data.UserMXID = user.MXID + if data.Metadata == nil { + metaTypes := user.Bridge.Network.GetDBMetaTypes() + if metaTypes.UserLogin != nil { + data.Metadata = metaTypes.UserLogin() + } + } + if params == nil { + params = &NewLoginParams{} + } + if params.LoadUserLogin == nil { + params.LoadUserLogin = user.Bridge.Network.LoadUserLogin + } + ul, err := user.Bridge.unlockedGetExistingUserLoginByID(ctx, data.ID) + if err != nil { + return nil, fmt.Errorf("failed to check if login already exists: %w", err) + } + var doInsert bool + if ul != nil && ul.UserMXID != user.MXID { + if params.DeleteOnConflict { + ul.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN_ANOTHER_USER"}, DeleteOpts{ + LogoutRemote: false, + unlocked: true, + }) + ul = nil + } else { + return nil, fmt.Errorf("%s is already logged in with that account", ul.UserMXID) + } + } + if ul != nil { + if params.DontReuseExisting { + return nil, fmt.Errorf("login already exists") + } + doInsert = false + ul.RemoteName = data.RemoteName + ul.RemoteProfile = ul.RemoteProfile.Merge(data.RemoteProfile) + if merger, ok := ul.Metadata.(database.MetaMerger); ok { + merger.CopyFrom(data.Metadata) + } else { + ul.Metadata = data.Metadata + } + } else { + doInsert = true + ul = &UserLogin{ + UserLogin: data, + Bridge: user.Bridge, + User: user, + Log: user.Log.With().Str("login_id", string(data.ID)).Logger(), + } + ul.BridgeState = user.Bridge.NewBridgeStateQueue(ul) + } + noCancelCtx := ul.Log.WithContext(user.Bridge.BackgroundCtx) + err = params.LoadUserLogin(noCancelCtx, ul) + if err != nil { + return nil, err + } else if ul.Client == nil { + ul.Log.Error().Msg("LoadUserLogin didn't fill Client in NewLogin") + return nil, fmt.Errorf("client not filled by LoadUserLogin") + } + if doInsert { + err = user.Bridge.DB.UserLogin.Insert(noCancelCtx, ul.UserLogin) + if err != nil { + return nil, err + } + user.Bridge.userLoginsByID[ul.ID] = ul + user.logins[ul.ID] = ul + } else { + err = ul.Save(noCancelCtx) + if err != nil { + return nil, err + } + } + return ul, nil +} + +func (ul *UserLogin) Save(ctx context.Context) error { + return ul.Bridge.DB.UserLogin.Update(ctx, ul.UserLogin) +} + +func (ul *UserLogin) Logout(ctx context.Context) { + ul.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut}, DeleteOpts{LogoutRemote: true}) +} + +type DeleteOpts struct { + LogoutRemote bool + DontCleanupRooms bool + BlockingCleanup bool + unlocked bool +} + +func (ul *UserLogin) Delete(ctx context.Context, state status.BridgeState, opts DeleteOpts) { + cleanupRooms := !opts.DontCleanupRooms && ul.Bridge.Config.CleanupOnLogout.Enabled + zerolog.Ctx(ctx).Info().Str("user_login_id", string(ul.ID)). + Bool("logout_remote", opts.LogoutRemote). + Bool("cleanup_rooms", cleanupRooms). + Msg("Deleting user login") + ul.deleteLock.Lock() + defer ul.deleteLock.Unlock() + if ul.BridgeState == nil { + return + } + if opts.LogoutRemote { + ul.Client.LogoutRemote(ctx) + } else { + // we probably shouldn't delete the login if disconnect isn't finished + ul.Disconnect() + } + var portals []*database.UserPortal + var err error + if cleanupRooms { + portals, err = ul.Bridge.DB.UserPortal.GetAllForLogin(ctx, ul.UserLogin) + if err != nil { + ul.Log.Err(err).Msg("Failed to get user portals") + } + } + err = ul.Bridge.DB.UserLogin.Delete(ctx, ul.ID) + if err != nil { + ul.Log.Err(err).Msg("Failed to delete user login") + } + if !opts.unlocked { + ul.Bridge.cacheLock.Lock() + } + delete(ul.User.logins, ul.ID) + delete(ul.Bridge.userLoginsByID, ul.ID) + if !opts.unlocked { + ul.Bridge.cacheLock.Unlock() + } + backgroundCtx := zerolog.Ctx(ctx).WithContext(ul.Bridge.BackgroundCtx) + if !opts.BlockingCleanup { + go ul.deleteSpace(backgroundCtx) + } else { + ul.deleteSpace(backgroundCtx) + } + if portals != nil { + if !opts.BlockingCleanup { + go ul.kickUserFromPortals(backgroundCtx, portals, state.StateEvent == status.StateBadCredentials, false) + } else { + ul.kickUserFromPortals(backgroundCtx, portals, state.StateEvent == status.StateBadCredentials, false) + } + } + if state.StateEvent != "" { + ul.BridgeState.Send(state) + } + ul.BridgeState.Destroy() + ul.BridgeState = nil +} + +func (ul *UserLogin) deleteSpace(ctx context.Context) { + if ul.SpaceRoom == "" { + return + } + err := ul.Bridge.Bot.DeleteRoom(ctx, ul.SpaceRoom, false) + if err != nil { + ul.Log.Err(err).Msg("Failed to delete space room") + } +} + +// KickUserFromPortalsForBadCredentials can be called to kick the user from portals without deleting the entire UserLogin object. +func (ul *UserLogin) KickUserFromPortalsForBadCredentials(ctx context.Context) { + log := zerolog.Ctx(ctx) + portals, err := ul.Bridge.DB.UserPortal.GetAllForLogin(ctx, ul.UserLogin) + if err != nil { + log.Err(err).Msg("Failed to get user portals") + } + ul.kickUserFromPortals(ctx, portals, true, true) +} + +func DeleteManyPortals(ctx context.Context, portals []*Portal, errorCallback func(portal *Portal, delete bool, err error)) { + // TODO is there a more sensible place/name for this function? + if len(portals) == 0 { + return + } + getDepth := func(portal *Portal) int { + depth := 0 + for portal.Parent != nil { + depth++ + portal = portal.Parent + } + return depth + } + // Sort portals so parents are last (to avoid errors caused by deleting parent portals before children) + slices.SortFunc(portals, func(a, b *Portal) int { + return cmp.Compare(getDepth(b), getDepth(a)) + }) + for _, portal := range portals { + err := portal.Delete(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("portal_mxid", portal.MXID). + Object("portal_key", portal.PortalKey). + Msg("Failed to delete portal row from database") + if errorCallback != nil { + errorCallback(portal, false, err) + } + continue + } + if portal.MXID != "" { + err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("portal_mxid", portal.MXID). + Msg("Failed to clean up portal room") + if errorCallback != nil { + errorCallback(portal, true, err) + } + } + } + } +} + +func (ul *UserLogin) kickUserFromPortals(ctx context.Context, portals []*database.UserPortal, badCredentials, deleteRow bool) { + var portalsToDelete []*Portal + for _, up := range portals { + portalToDelete, err := ul.kickUserFromPortal(ctx, up, badCredentials, deleteRow) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Object("portal_key", up.Portal). + Stringer("user_mxid", up.UserMXID). + Msg("Failed to apply logout action") + } else if portalToDelete != nil { + portalsToDelete = append(portalsToDelete, portalToDelete) + } + } + DeleteManyPortals(ctx, portalsToDelete, nil) +} + +func (ul *UserLogin) kickUserFromPortal(ctx context.Context, up *database.UserPortal, badCredentials, deleteRow bool) (*Portal, error) { + portal, action, reason, err := ul.getLogoutAction(ctx, up, badCredentials) + if err != nil { + return nil, err + } else if portal == nil { + return nil, nil + } + zerolog.Ctx(ctx).Debug(). + Str("login_id", string(ul.ID)). + Stringer("user_mxid", ul.UserMXID). + Str("logout_action", string(action)). + Str("action_reason", reason). + Object("portal_key", portal.PortalKey). + Stringer("portal_mxid", portal.MXID). + Msg("Calculated portal action for logout processing") + switch action { + case bridgeconfig.CleanupActionNull, bridgeconfig.CleanupActionNothing: + // do nothing + case bridgeconfig.CleanupActionKick: + _, err = ul.Bridge.Bot.SendState(ctx, portal.MXID, event.StateMember, ul.UserMXID.String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: "Logged out of bridge", + }, + }, time.Time{}) + if err != nil { + return nil, fmt.Errorf("failed to kick user from portal: %w", err) + } + zerolog.Ctx(ctx).Debug(). + Str("login_id", string(ul.ID)). + Stringer("user_mxid", ul.UserMXID). + Stringer("portal_mxid", portal.MXID). + Msg("Kicked user from portal") + if deleteRow { + err = ul.Bridge.DB.UserPortal.Delete(ctx, up) + if err != nil { + zerolog.Ctx(ctx).Warn(). + Str("login_id", string(ul.ID)). + Stringer("user_mxid", ul.UserMXID). + Stringer("portal_mxid", portal.MXID). + Msg("Failed to delete user portal row") + } + } + case bridgeconfig.CleanupActionDelete, bridgeconfig.CleanupActionUnbridge: + // return portal instead of deleting here to allow sorting by depth + return portal, nil + } + return nil, nil +} + +func (ul *UserLogin) getLogoutAction(ctx context.Context, up *database.UserPortal, badCredentials bool) (*Portal, bridgeconfig.CleanupAction, string, error) { + portal, err := ul.Bridge.GetExistingPortalByKey(ctx, up.Portal) + if err != nil { + return nil, bridgeconfig.CleanupActionNull, "", fmt.Errorf("failed to get full portal: %w", err) + } else if portal == nil || portal.MXID == "" { + return nil, bridgeconfig.CleanupActionNull, "portal not found", nil + } + actionsSet := ul.Bridge.Config.CleanupOnLogout.Manual + if badCredentials { + actionsSet = ul.Bridge.Config.CleanupOnLogout.BadCredentials + } + if portal.Receiver != "" { + return portal, actionsSet.Private, "portal has receiver", nil + } + otherUPs, err := ul.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + return portal, bridgeconfig.CleanupActionNull, "", fmt.Errorf("failed to get other logins in portal: %w", err) + } + hasOtherUsers := false + for _, otherUP := range otherUPs { + if otherUP.LoginID == ul.ID { + continue + } + if otherUP.UserMXID == ul.UserMXID { + otherUL := ul.Bridge.GetCachedUserLoginByID(otherUP.LoginID) + if otherUL != nil && otherUL.Client.IsLoggedIn() { + return portal, bridgeconfig.CleanupActionNull, "user has another login in portal", nil + } + } else { + hasOtherUsers = true + } + } + if portal.RelayLoginID != "" { + return portal, actionsSet.Relayed, "portal has relay login", nil + } else if hasOtherUsers { + return portal, actionsSet.SharedHasUsers, "portal has logins of other users", nil + } + return portal, actionsSet.SharedNoUsers, "portal doesn't have logins of other users", nil +} + +func (ul *UserLogin) MarkAsPreferredIn(ctx context.Context, portal *Portal) error { + return ul.Bridge.DB.UserPortal.MarkAsPreferred(ctx, ul.UserLogin, portal.PortalKey) +} + +var _ status.BridgeStateFiller = (*UserLogin)(nil) + +func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeState { + state.UserID = ul.UserMXID + state.RemoteID = ul.ID + state.RemoteName = ul.RemoteName + state.RemoteProfile = ul.RemoteProfile + if space := ul.SpaceRoom; space != "" { + if state.Info == nil { + state.Info = make(map[string]any) + } + state.Info["personal_filtering_space"] = space + } + filler, ok := ul.Client.(status.BridgeStateFiller) + if ok { + return filler.FillBridgeState(state) + } + return state +} + +func (ul *UserLogin) Disconnect() { + ul.DisconnectWithTimeout(0) +} + +func (ul *UserLogin) DisconnectWithTimeout(timeout time.Duration) { + ul.disconnectOnce.Do(func() { + ul.disconnectInternal(timeout) + }) +} + +func (ul *UserLogin) disconnectInternal(timeout time.Duration) { + ul.BridgeState.StopUnknownErrorReconnect() + disconnected := make(chan struct{}) + go func() { + ul.Client.Disconnect() + close(disconnected) + }() + + var timeoutC <-chan time.Time + if timeout > 0 { + timeoutC = time.After(timeout) + } + for { + select { + case <-disconnected: + return + case <-time.After(2 * time.Second): + ul.Log.Warn().Msg("Client disconnection taking long") + case <-timeoutC: + ul.Log.Error().Msg("Client disconnection timed out") + return + } + } +} + +func (ul *UserLogin) recreateClient(ctx context.Context) error { + oldClient := ul.Client + err := ul.Bridge.Network.LoadUserLogin(ctx, ul) + if err != nil { + return err + } + if ul.Client == oldClient { + zerolog.Ctx(ctx).Warn().Msg("LoadUserLogin didn't update client") + } else { + zerolog.Ctx(ctx).Debug().Msg("Recreated user login client") + } + ul.disconnectOnce = sync.Once{} + return nil +} diff --git a/mautrix-patched/client.go b/mautrix-patched/client.go new file mode 100644 index 00000000..ba13c70b --- /dev/null +++ b/mautrix-patched/client.go @@ -0,0 +1,2999 @@ +// Package mautrix implements the Matrix Client-Server API. +// +// Specification can be found at https://spec.matrix.org/v1.2/client-server-api/ +package mautrix + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exsync" + "go.mau.fi/util/ptr" + "go.mau.fi/util/random" + "go.mau.fi/util/retryafter" + "golang.org/x/exp/maps" + + "maunium.net/go/mautrix/crypto/backup" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +type CryptoHelper interface { + Encrypt(context.Context, id.RoomID, event.Type, any) (*event.EncryptedEventContent, error) + Decrypt(context.Context, *event.Event) (*event.Event, error) + WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool + RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) + Init(context.Context) error +} + +type VerificationHelper interface { + // Init initializes the helper. This should be called before any other + // methods. + Init(context.Context) error + + // StartVerification starts an interactive verification flow with the given + // user via a to-device event. + StartVerification(ctx context.Context, to id.UserID) (id.VerificationTransactionID, error) + // StartInRoomVerification starts an interactive verification flow with the + // given user in the given room. + StartInRoomVerification(ctx context.Context, roomID id.RoomID, to id.UserID) (id.VerificationTransactionID, error) + + // AcceptVerification accepts a verification request. + AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error + // DismissVerification dismisses a verification request. This will not send + // a cancellation to the other device. This method should only be called + // *before* the request has been accepted and will error otherwise. + DismissVerification(ctx context.Context, txnID id.VerificationTransactionID) error + // CancelVerification cancels a verification request. This method should + // only be called *after* the request has been accepted, although it will + // not error if called beforehand. + CancelVerification(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) error + + // HandleScannedQRData handles the data from a QR code scan. + HandleScannedQRData(ctx context.Context, data []byte) error + // ConfirmQRCodeScanned confirms that our QR code has been scanned. + ConfirmQRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) error + + // StartSAS starts a SAS verification flow. + StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error + // ConfirmSAS indicates that the user has confirmed that the SAS matches + // SAS shown on the other user's device. + ConfirmSAS(ctx context.Context, txnID id.VerificationTransactionID) error +} + +// Client represents a Matrix client. +type Client struct { + HomeserverURL *url.URL // The base homeserver URL + UserID id.UserID // The user ID of the client. Used for forming HTTP paths which use the client's user ID. + DeviceID id.DeviceID // The device ID of the client. + AccessToken string // The access_token for the client. + UserAgent string // The value for the User-Agent header + Client *http.Client // The underlying HTTP client which will be used to make HTTP requests. + Syncer Syncer // The thing which can process /sync responses + Store SyncStore // The thing which can store tokens/ids + StateStore StateStore + Crypto CryptoHelper + Verification VerificationHelper + SpecVersions *RespVersions + ExternalClient *http.Client // The HTTP client used for external (not matrix) media HTTP requests. + + Log zerolog.Logger + + RequestHook func(req *http.Request) + ResponseHook func(req *http.Request, resp *http.Response, err error, duration time.Duration) + + RequestRetryTrigger *exsync.Event + + SyncPresence event.Presence + SyncTraceLog bool + + StreamSyncMinAge time.Duration + + // Number of times that mautrix will retry any HTTP request + // if the request fails entirely or returns a HTTP gateway error (502-504) + DefaultHTTPRetries int + // Amount of time to wait between HTTP retries, defaults to 4 seconds + DefaultHTTPBackoff time.Duration + // Maximum time to wait between HTTP retries, defaults to 10 minutes. + // This applies to both the exponential backoff from gateway/network errors and to 429 errors. + MaxHTTPBackoff time.Duration + // Set to true to disable automatically sleeping on 429 errors. + IgnoreRateLimit bool + + ResponseSizeLimit int64 + + txnID int32 + + // Should the ?user_id= query parameter be set in requests? + // See https://spec.matrix.org/v1.6/application-service-api/#identity-assertion + SetAppServiceUserID bool + // Should the org.matrix.msc3202.device_id query parameter be set in requests? + // See https://github.com/matrix-org/matrix-spec-proposals/pull/3202 + SetAppServiceDeviceID bool + + syncingID uint32 // Identifies the current Sync. Only one Sync can be active at any given time. +} + +type ClientWellKnown struct { + Homeserver HomeserverInfo `json:"m.homeserver"` + IdentityServer IdentityServerInfo `json:"m.identity_server"` +} + +type HomeserverInfo struct { + BaseURL string `json:"base_url"` +} + +type IdentityServerInfo struct { + BaseURL string `json:"base_url"` +} + +// DiscoverClientAPI resolves the client API URL from a Matrix server name. +// Use ParseUserID to extract the server name from a user ID. +// https://spec.matrix.org/v1.2/client-server-api/#server-discovery +func DiscoverClientAPI(ctx context.Context, serverName string) (*ClientWellKnown, error) { + return DiscoverClientAPIWithClient(ctx, &http.Client{Timeout: 30 * time.Second}, serverName) +} + +const WellKnownMaxSize = 64 * 1024 + +func DiscoverClientAPIWithClient(ctx context.Context, client *http.Client, serverName string) (*ClientWellKnown, error) { + wellKnownURL := url.URL{ + Scheme: "https", + Host: serverName, + Path: "/.well-known/matrix/client", + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL.String(), nil) + if err != nil { + return nil, err + } + + if runtime.GOOS != "js" { + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", DefaultUserAgent+" (.well-known fetcher)") + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } else if resp.ContentLength > WellKnownMaxSize { + return nil, errors.New(".well-known response too large") + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, WellKnownMaxSize)) + if err != nil { + return nil, err + } else if len(data) >= WellKnownMaxSize { + return nil, errors.New(".well-known response too large") + } + + var wellKnown ClientWellKnown + err = json.Unmarshal(data, &wellKnown) + if err != nil { + return nil, errors.New(".well-known response not JSON") + } + + return &wellKnown, nil +} + +// SetCredentials sets the user ID and access token on this client instance. +// +// Deprecated: use the StoreCredentials field in ReqLogin instead. +func (cli *Client) SetCredentials(userID id.UserID, accessToken string) { + cli.AccessToken = accessToken + cli.UserID = userID +} + +// ClearCredentials removes the user ID and access token on this client instance. +func (cli *Client) ClearCredentials() { + cli.AccessToken = "" + cli.UserID = "" + cli.DeviceID = "" +} + +// Sync starts syncing with the provided Homeserver. If Sync() is called twice then the first sync will be stopped and the +// error will be nil. +// +// This function will block until a fatal /sync error occurs, so it should almost always be started as a new goroutine. +// Fatal sync errors can be caused by: +// - The failure to create a filter. +// - Client.Syncer.OnFailedSync returning an error in response to a failed sync. +// - Client.Syncer.ProcessResponse returning an error. +// +// If you wish to continue retrying in spite of these fatal errors, call Sync() again. +func (cli *Client) Sync() error { + return cli.SyncWithContext(context.Background()) +} + +func (cli *Client) SyncWithContext(ctx context.Context) error { + // Mark the client as syncing. + // We will keep syncing until the syncing state changes. Either because + // Sync is called or StopSync is called. + syncingID := cli.incrementSyncingID() + nextBatch, err := cli.Store.LoadNextBatch(ctx, cli.UserID) + if err != nil { + return err + } + filterID, err := cli.Store.LoadFilterID(ctx, cli.UserID) + if err != nil { + return err + } + + if filterID == "" { + filterJSON := cli.Syncer.GetFilterJSON(cli.UserID) + resFilter, err := cli.CreateFilter(ctx, filterJSON) + if err != nil { + return err + } + filterID = resFilter.FilterID + if err := cli.Store.SaveFilterID(ctx, cli.UserID, filterID); err != nil { + return err + } + } + lastSuccessfulSync := time.Now().Add(-cli.StreamSyncMinAge - 1*time.Hour) + // Always do first sync with 0 timeout + isFailing := true + for { + streamResp := false + if cli.StreamSyncMinAge > 0 && time.Since(lastSuccessfulSync) > cli.StreamSyncMinAge { + cli.Log.Debug().Msg("Last sync is old, will stream next response") + streamResp = true + } + timeout := 30000 + if isFailing || nextBatch == "" { + timeout = 0 + } + resSync, err := cli.FullSyncRequest(ctx, ReqSync{ + Timeout: timeout, + Since: nextBatch, + FilterID: filterID, + FullState: false, + SetPresence: cli.SyncPresence, + StreamResponse: streamResp, + }) + if err != nil { + isFailing = true + if ctx.Err() != nil { + return ctx.Err() + } + duration, err2 := cli.Syncer.OnFailedSync(resSync, err) + if err2 != nil { + return err2 + } + if duration <= 0 { + continue + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(duration): + continue + } + } + isFailing = false + lastSuccessfulSync = time.Now() + + // Check that the syncing state hasn't changed + // Either because we've stopped syncing or another sync has been started. + // We discard the response from our sync. + if cli.getSyncingID() != syncingID { + return nil + } + + // Save the token now *before* processing it. This means it's possible + // to not process some events, but it means that we won't get constantly stuck processing + // a malformed/buggy event which keeps making us panic. + err = cli.Store.SaveNextBatch(ctx, cli.UserID, resSync.NextBatch) + if err != nil { + return err + } + if err = cli.Syncer.ProcessResponse(ctx, resSync, nextBatch); err != nil { + return err + } + + nextBatch = resSync.NextBatch + } +} + +func (cli *Client) incrementSyncingID() uint32 { + return atomic.AddUint32(&cli.syncingID, 1) +} + +func (cli *Client) getSyncingID() uint32 { + return atomic.LoadUint32(&cli.syncingID) +} + +// StopSync stops the ongoing sync started by Sync. +func (cli *Client) StopSync() { + // Advance the syncing state so that any running Syncs will terminate. + cli.incrementSyncingID() +} + +type contextKey int + +const ( + LogBodyContextKey contextKey = iota + LogRequestIDContextKey + MaxAttemptsContextKey + SyncTokenContextKey +) + +func (cli *Client) RequestStart(req *http.Request) { + if cli != nil && cli.RequestHook != nil { + cli.RequestHook(req) + } +} + +// WithMaxRetries updates the context to set the maximum number of retries for any HTTP requests made with the context. +// +// 0 means the request will only be attempted once and will not be retried. +// Negative values will remove the override and fallback to the defaults. +func WithMaxRetries(ctx context.Context, maxRetries int) context.Context { + return context.WithValue(ctx, MaxAttemptsContextKey, maxRetries+1) +} + +func (cli *Client) LogRequestDone(req *http.Request, resp *http.Response, err error, handlerErr error, contentLength int, duration time.Duration) { + if cli == nil { + return + } + var evt *zerolog.Event + if errors.Is(err, context.Canceled) { + evt = zerolog.Ctx(req.Context()).Warn() + } else if err != nil { + evt = zerolog.Ctx(req.Context()).Err(err) + } else if handlerErr != nil { + evt = zerolog.Ctx(req.Context()).Warn(). + AnErr("body_parse_err", handlerErr) + } else if cli.SyncTraceLog && strings.HasSuffix(req.URL.Path, "/_matrix/client/v3/sync") { + evt = zerolog.Ctx(req.Context()).Trace() + } else { + evt = zerolog.Ctx(req.Context()).Debug() + } + evt = evt. + Str("method", req.Method). + Str("url", req.URL.String()). + Dur("duration", duration) + if cli.ResponseHook != nil { + cli.ResponseHook(req, resp, err, duration) + } + if resp != nil { + mime := resp.Header.Get("Content-Type") + length := resp.ContentLength + if length == -1 && contentLength > 0 { + length = int64(contentLength) + } + evt = evt.Int("status_code", resp.StatusCode). + Int64("response_length", length). + Str("response_mime", mime) + if serverRequestID := resp.Header.Get("X-Beeper-Request-ID"); serverRequestID != "" { + evt.Str("beeper_request_id", serverRequestID) + } + } + if body := req.Context().Value(LogBodyContextKey); body != nil { + switch typedLogBody := body.(type) { + case json.RawMessage: + evt.RawJSON("req_body", typedLogBody) + case string: + evt.Str("req_body", typedLogBody) + default: + panic(fmt.Errorf("invalid type for LogBodyContextKey: %T", body)) + } + } + if errors.Is(err, context.Canceled) { + evt.Msg("Request canceled") + } else if err != nil { + evt.Msg("Request failed") + } else if handlerErr != nil { + evt.Msg("Request parsing failed") + } else { + evt.Msg("Request completed") + } +} + +func (cli *Client) MakeRequest(ctx context.Context, method string, httpURL string, reqBody any, resBody any) ([]byte, error) { + return cli.MakeFullRequest(ctx, FullRequest{Method: method, URL: httpURL, RequestJSON: reqBody, ResponseJSON: resBody}) +} + +type ClientResponseHandler = func(req *http.Request, res *http.Response, responseJSON any, sizeLimit int64) ([]byte, error) + +type FullRequest struct { + Method string + URL string + Headers http.Header + RequestJSON interface{} + RequestBytes []byte + RequestBody io.Reader + RequestLength int64 + ResponseJSON interface{} + MaxAttempts int + BackoffDuration time.Duration + SensitiveContent bool + Handler ClientResponseHandler + DontReadResponse bool + ResponseSizeLimit int64 + Logger *zerolog.Logger + Client *http.Client +} + +var requestID int32 +var logSensitiveContent = os.Getenv("MAUTRIX_LOG_SENSITIVE_CONTENT") == "yes" + +func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, error) { + reqID := atomic.AddInt32(&requestID, 1) + logger := zerolog.Ctx(ctx) + if logger.GetLevel() == zerolog.Disabled || logger == zerolog.DefaultContextLogger { + logger = params.Logger + } + ctx = logger.With(). + Int32("req_id", reqID). + Logger().WithContext(ctx) + + var logBody any + var reqBody io.Reader + var reqLen int64 + if params.RequestJSON != nil { + jsonStr, err := json.Marshal(params.RequestJSON) + if err != nil { + return nil, HTTPError{ + Message: "failed to marshal JSON", + WrappedError: err, + } + } + if params.SensitiveContent && !logSensitiveContent { + logBody = "" + } else if len(jsonStr) > 32768 { + logBody = fmt.Sprintf("", len(jsonStr)) + } else { + logBody = json.RawMessage(jsonStr) + } + reqBody = bytes.NewReader(jsonStr) + reqLen = int64(len(jsonStr)) + } else if params.RequestBytes != nil { + logBody = fmt.Sprintf("<%d bytes>", len(params.RequestBytes)) + reqBody = bytes.NewReader(params.RequestBytes) + reqLen = int64(len(params.RequestBytes)) + } else if params.RequestBody != nil { + logBody = "" + reqLen = -1 + if params.RequestLength > 0 { + logBody = fmt.Sprintf("<%d bytes>", params.RequestLength) + reqLen = params.RequestLength + } else if params.RequestLength == 0 { + zerolog.Ctx(ctx).Warn(). + Msg("RequestBody passed without specifying request length") + } + reqBody = params.RequestBody + if rsc, ok := params.RequestBody.(io.ReadSeekCloser); ok { + // Prevent HTTP from closing the request body, it might be needed for retries + reqBody = nopCloseSeeker{rsc} + } + } else if params.Method != http.MethodGet && params.Method != http.MethodHead { + params.RequestJSON = struct{}{} + logBody = json.RawMessage("{}") + reqBody = bytes.NewReader([]byte("{}")) + reqLen = 2 + } + ctx = context.WithValue(ctx, LogBodyContextKey, logBody) + ctx = context.WithValue(ctx, LogRequestIDContextKey, int(reqID)) + req, err := http.NewRequestWithContext(ctx, params.Method, params.URL, reqBody) + if err != nil { + return nil, HTTPError{ + Message: "failed to create request", + WrappedError: err, + } + } + if params.Headers != nil { + req.Header = params.Headers + } + if params.RequestJSON != nil { + req.Header.Set("Content-Type", "application/json") + } + req.ContentLength = reqLen + return req, nil +} + +func (cli *Client) MakeFullRequest(ctx context.Context, params FullRequest) ([]byte, error) { + data, _, err := cli.MakeFullRequestWithResp(ctx, params) + return data, err +} + +func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullRequest) ([]byte, *http.Response, error) { + if cli == nil { + return nil, nil, ErrClientIsNil + } + if cli.HomeserverURL == nil || cli.HomeserverURL.Scheme == "" { + return nil, nil, ErrClientHasNoHomeserver + } + if params.MaxAttempts == 0 { + maxAttempts, ok := ctx.Value(MaxAttemptsContextKey).(int) + if ok && maxAttempts > 0 { + params.MaxAttempts = maxAttempts + } else { + params.MaxAttempts = 1 + cli.DefaultHTTPRetries + } + } + if params.BackoffDuration == 0 { + if cli.DefaultHTTPBackoff == 0 { + params.BackoffDuration = 4 * time.Second + } else { + params.BackoffDuration = cli.DefaultHTTPBackoff + } + } + if params.Logger == nil { + params.Logger = &cli.Log + } + req, err := params.compileRequest(ctx) + if err != nil { + return nil, nil, err + } + if params.Handler == nil { + if params.DontReadResponse { + params.Handler = noopHandleResponse + } else { + params.Handler = handleNormalResponse + } + } + if cli.UserAgent != "" { + req.Header.Set("User-Agent", cli.UserAgent) + } + if len(cli.AccessToken) > 0 { + req.Header.Set("Authorization", "Bearer "+cli.AccessToken) + } + if params.ResponseSizeLimit == 0 { + params.ResponseSizeLimit = cli.ResponseSizeLimit + } + if params.ResponseSizeLimit == 0 { + params.ResponseSizeLimit = DefaultResponseSizeLimit + } + if params.Client == nil { + params.Client = cli.Client + } + return cli.executeCompiledRequest( + req, + params.MaxAttempts-1, + params.BackoffDuration, + params.ResponseJSON, + params.Handler, + params.DontReadResponse, + params.ResponseSizeLimit, + params.Client, + ) +} + +func (cli *Client) cliOrContextLog(ctx context.Context) *zerolog.Logger { + log := zerolog.Ctx(ctx) + if log.GetLevel() == zerolog.Disabled || log == zerolog.DefaultContextLogger { + return &cli.Log + } + return log +} + +func (cli *Client) doRetry( + req *http.Request, + cause error, + retries int, + backoff time.Duration, + responseJSON any, + handler ClientResponseHandler, + dontReadResponse bool, + sizeLimit int64, + client *http.Client, +) ([]byte, *http.Response, error) { + log := zerolog.Ctx(req.Context()) + if req.Body != nil { + var err error + if req.GetBody != nil { + req.Body, err = req.GetBody() + if err != nil { + log.Warn().Err(err).Msg("Failed to get new body to retry request") + return nil, nil, cause + } + } else if bodySeeker, ok := req.Body.(io.ReadSeeker); ok { + _, err = bodySeeker.Seek(0, io.SeekStart) + if err != nil { + log.Warn().Err(err).Msg("Failed to seek to beginning of request body") + return nil, nil, cause + } + } else { + log.Warn().Msg("Failed to get new body to retry request: GetBody is nil and Body is not an io.ReadSeeker") + return nil, nil, cause + } + } + maxBackoff := cli.MaxHTTPBackoff + if maxBackoff <= 0 { + maxBackoff = 10 * time.Minute + } + backoff = min(backoff, maxBackoff) + log.Warn().Err(cause). + Str("method", req.Method). + Str("url", req.URL.String()). + Int("retry_in_seconds", int(backoff.Seconds())). + Msg("Request failed, retrying") + + // if this was due to our RequestRetryTrigger then just retry immediately + // the req.Context() will still be live, otherwise do a normal backoff + if !errors.Is(cause, ErrContextCancelRetry) { + select { + case <-time.After(backoff): + case <-req.Context().Done(): + return nil, nil, req.Context().Err() + } + } + return cli.executeCompiledRequest(req, retries-1, backoff*2, responseJSON, handler, dontReadResponse, sizeLimit, client) +} + +func readResponseBody(req *http.Request, res *http.Response, limit int64) ([]byte, error) { + if res.ContentLength > limit { + return nil, HTTPError{ + Request: req, + Response: res, + + Message: "not reading response", + WrappedError: fmt.Errorf("%w (%.2f MiB)", ErrResponseTooLong, float64(res.ContentLength)/1024/1024), + } + } + contents, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) + if err == nil && len(contents) > int(limit) { + err = ErrBodyReadReachedLimit + } + if err != nil { + return nil, HTTPError{ + Request: req, + Response: res, + + Message: "failed to read response body", + WrappedError: err, + } + } + return contents, nil +} + +func closeTemp(log *zerolog.Logger, file *os.File) { + _ = file.Close() + err := os.Remove(file.Name()) + if err != nil { + log.Warn().Err(err).Str("file_name", file.Name()).Msg("Failed to remove response temp file") + } +} + +func streamResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { + log := zerolog.Ctx(req.Context()) + file, err := os.CreateTemp("", "mautrix-response-") + if err != nil { + log.Warn().Err(err).Msg("Failed to create temporary file for streaming response") + _, err = handleNormalResponse(req, res, responseJSON, limit) + return nil, err + } + defer closeTemp(log, file) + var n int64 + if n, err = io.Copy(file, io.LimitReader(res.Body, limit+1)); err != nil { + return nil, fmt.Errorf("failed to copy response to file: %w", err) + } else if n > limit { + return nil, ErrBodyReadReachedLimit + } else if _, err = file.Seek(0, 0); err != nil { + return nil, fmt.Errorf("failed to seek to beginning of response file: %w", err) + } else if err = json.NewDecoder(file).Decode(responseJSON); err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) + } else { + return nil, nil + } +} + +func noopHandleResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { + return nil, nil +} + +func handleNormalResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { + if contents, err := readResponseBody(req, res, limit); err != nil { + return nil, err + } else if responseJSON == nil { + return contents, nil + } else if err = json.Unmarshal(contents, &responseJSON); err != nil { + return nil, HTTPError{ + Request: req, + Response: res, + + Message: "failed to unmarshal response body", + ResponseBody: string(contents), + WrappedError: err, + } + } else { + return contents, nil + } +} + +const ErrorResponseSizeLimit = 512 * 1024 + +var DefaultResponseSizeLimit int64 = 512 * 1024 * 1024 + +func ParseErrorResponse(req *http.Request, res *http.Response) ([]byte, error) { + defer res.Body.Close() + contents, err := readResponseBody(req, res, ErrorResponseSizeLimit) + if err != nil { + return contents, err + } + + respErr := &RespError{ + StatusCode: res.StatusCode, + } + if _ = json.Unmarshal(contents, respErr); respErr.ErrCode == "" { + respErr = nil + } + + return contents, HTTPError{ + Request: req, + Response: res, + RespError: respErr, + } +} + +func (cli *Client) prepareRequestAttempt(req *http.Request) (*http.Request, func()) { + // if there's no retry trigger, nothing to do + if cli.RequestRetryTrigger == nil { + return req, nil + } + + attemptCtx, cancel := context.WithCancelCause(req.Context()) + + // Register as a waiter synchronously so we're waiting by the time this method returns, avoid + // race on trigger notify and the goroutine below. + resetCh := cli.RequestRetryTrigger.GetChan() + + go func() { + select { + case <-resetCh: + cancel(ErrContextCancelRetry) + case <-attemptCtx.Done(): + } + }() + + return req.WithContext(attemptCtx), sync.OnceFunc(func() { + cancel(context.Canceled) + }) +} + +type cleanupReadCloser struct { + io.ReadCloser + cleanup func() +} + +type cleanupReadCloserWriterTo struct { + io.ReadCloser + cleanup func() +} + +func (crc cleanupReadCloser) Close() error { + err := crc.ReadCloser.Close() + if crc.cleanup != nil { + crc.cleanup() + } + return err +} + +func (crc cleanupReadCloserWriterTo) Close() error { + err := crc.ReadCloser.Close() + if crc.cleanup != nil { + crc.cleanup() + } + return err +} + +func (crc cleanupReadCloserWriterTo) WriteTo(w io.Writer) (int64, error) { + return crc.ReadCloser.(io.WriterTo).WriteTo(w) +} + +func maybeWrapRespBody(rc io.ReadCloser, cleanup func()) io.ReadCloser { + if cleanup == nil { + return rc + } + if _, ok := rc.(io.WriterTo); ok { + return cleanupReadCloserWriterTo{ + ReadCloser: rc, + cleanup: cleanup, + } + } + return cleanupReadCloser{ + ReadCloser: rc, + cleanup: cleanup, + } +} + +func (cli *Client) executeCompiledRequest( + req *http.Request, + retries int, + backoff time.Duration, + responseJSON any, + handler ClientResponseHandler, + dontReadResponse bool, + sizeLimit int64, + client *http.Client, +) ([]byte, *http.Response, error) { + attemptReq, cleanup := cli.prepareRequestAttempt(req) + cli.RequestStart(attemptReq) + startTime := time.Now() + res, err := client.Do(attemptReq) + duration := time.Since(startTime) + if res != nil { + // Cleanup the child attempt context once the body is closed + res.Body = maybeWrapRespBody(res.Body, cleanup) + if !dontReadResponse { + defer res.Body.Close() + } + } + if err != nil { + // cleanup child attempt context on error + if cleanup != nil { + cleanup() + } + + // Either error is *not* canceled or the underlying cause of cancellation explicitly asks to retry + attemptCause := context.Cause(attemptReq.Context()) + retryCause := err + if errors.Is(attemptCause, ErrContextCancelRetry) { + retryCause = attemptCause + } + canRetry := !errors.Is(err, context.Canceled) || + errors.Is(attemptCause, ErrContextCancelRetry) + if retries > 0 && canRetry { + return cli.doRetry( + req, retryCause, retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, + ) + } + err = HTTPError{ + Request: attemptReq, + Response: res, + + Message: "request error", + WrappedError: err, + } + cli.LogRequestDone(attemptReq, res, err, nil, 0, duration) + return nil, res, err + } + + if retries > 0 && retryafter.Should(res.StatusCode, !cli.IgnoreRateLimit) { + backoff = retryafter.Parse(res.Header.Get("Retry-After"), backoff) + return cli.doRetry( + req, fmt.Errorf("HTTP %d", res.StatusCode), retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, + ) + } + + var body []byte + if res.StatusCode < 200 || res.StatusCode >= 300 { + body, err = ParseErrorResponse(attemptReq, res) + cli.LogRequestDone(attemptReq, res, nil, nil, len(body), duration) + } else { + body, err = handler(attemptReq, res, responseJSON, sizeLimit) + cli.LogRequestDone(attemptReq, res, nil, err, len(body), duration) + } + return body, res, err +} + +// Whoami gets the user ID of the current user. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3accountwhoami +func (cli *Client) Whoami(ctx context.Context) (resp *RespWhoami, err error) { + urlPath := cli.BuildClientURL("v3", "account", "whoami") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// CreateFilter makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridfilter +func (cli *Client) CreateFilter(ctx context.Context, filter *Filter) (resp *RespCreateFilter, err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "filter") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, filter, &resp) + return +} + +// SyncRequest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync +func (cli *Client) SyncRequest(ctx context.Context, timeout int, since, filterID string, fullState bool, setPresence event.Presence) (resp *RespSync, err error) { + return cli.FullSyncRequest(ctx, ReqSync{ + Timeout: timeout, + Since: since, + FilterID: filterID, + FullState: fullState, + SetPresence: setPresence, + }) +} + +type ReqSync struct { + Timeout int + Since string + FilterID string + FullState bool + SetPresence event.Presence + StreamResponse bool + UseStateAfter bool + BeeperStreaming bool + Client *http.Client +} + +func (req *ReqSync) BuildQuery() map[string]string { + query := map[string]string{ + "timeout": strconv.Itoa(req.Timeout), + } + if req.Since != "" { + query["since"] = req.Since + } + if req.FilterID != "" { + query["filter"] = req.FilterID + } + if req.SetPresence != "" { + query["set_presence"] = string(req.SetPresence) + } + if req.FullState { + query["full_state"] = "true" + } + if req.UseStateAfter { + query["use_state_after"] = "true" + } + if req.BeeperStreaming { + query["com.beeper.streaming"] = "true" + } + return query +} + +// FullSyncRequest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync +func (cli *Client) FullSyncRequest(ctx context.Context, req ReqSync) (resp *RespSync, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "sync"}, req.BuildQuery()) + fullReq := FullRequest{ + Method: http.MethodGet, + URL: urlPath, + ResponseJSON: &resp, + Client: req.Client, + // We don't want automatic retries for SyncRequest, the Sync() wrapper handles those. + MaxAttempts: 1, + } + if req.StreamResponse { + fullReq.Handler = streamResponse + } + start := time.Now() + _, err = cli.MakeFullRequest(ctx, fullReq) + duration := time.Since(start) + timeout := time.Duration(req.Timeout) * time.Millisecond + buffer := 10 * time.Second + if req.Since == "" { + buffer = 1 * time.Minute + } + if err == nil && duration > timeout+buffer { + cli.cliOrContextLog(ctx).Warn(). + Str("since", req.Since). + Dur("duration", duration). + Dur("timeout", timeout). + Msg("Sync request took unusually long") + } + return +} + +// RegisterAvailable checks if a username is valid and available for registration on the server. +// +// See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv3registeravailable for more details +// +// This will always return an error if the username isn't available, so checking the actual response struct is generally +// not necessary. It is still returned for future-proofing. For a simple availability check, just check that the returned +// error is nil. `errors.Is` can be used to find the exact reason why a username isn't available: +// +// _, err := cli.RegisterAvailable("cat") +// if errors.Is(err, mautrix.MUserInUse) { +// // Username is taken +// } else if errors.Is(err, mautrix.MInvalidUsername) { +// // Username is not valid +// } else if errors.Is(err, mautrix.MExclusive) { +// // Username is reserved for an appservice +// } else if errors.Is(err, mautrix.MLimitExceeded) { +// // Too many requests +// } else if err != nil { +// // Unknown error +// } else { +// // Username is available +// } +func (cli *Client) RegisterAvailable(ctx context.Context, username string) (resp *RespRegisterAvailable, err error) { + u := cli.BuildURLWithQuery(ClientURLPath{"v3", "register", "available"}, map[string]string{"username": username}) + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) + if err == nil && !resp.Available { + err = fmt.Errorf(`request returned OK status without "available": true`) + } + return +} + +func (cli *Client) register(ctx context.Context, url string, req *ReqRegister[any]) (resp *RespRegister, uiaResp *RespUserInteractive, err error) { + var bodyBytes []byte + bodyBytes, err = cli.MakeFullRequest(ctx, FullRequest{ + Method: http.MethodPost, + URL: url, + RequestJSON: req, + SensitiveContent: len(req.Password) > 0, + }) + if err != nil { + httpErr, ok := err.(HTTPError) + // if response has a 401 status, but doesn't have the errcode field, it's probably a UIA response. + if ok && httpErr.IsStatus(http.StatusUnauthorized) && httpErr.RespError == nil { + err = json.Unmarshal(bodyBytes, &uiaResp) + } + } else { + // body should be RespRegister + err = json.Unmarshal(bodyBytes, &resp) + } + return +} + +// Register makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register +// +// Registers with kind=user. For kind=guest, see RegisterGuest. +func (cli *Client) Register(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) { + u := cli.BuildClientURL("v3", "register") + return cli.register(ctx, u, req) +} + +// RegisterGuest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register +// with kind=guest. +// +// For kind=user, see Register. +func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) { + query := map[string]string{ + "kind": "guest", + } + u := cli.BuildURLWithQuery(ClientURLPath{"v3", "register"}, query) + return cli.register(ctx, u, req) +} + +// RegisterDummy performs m.login.dummy registration according to https://spec.matrix.org/v1.2/client-server-api/#dummy-auth +// +// Only a username and password need to be provided on the ReqRegister struct. Most local/developer homeservers will allow registration +// this way. If the homeserver does not, an error is returned. +// +// This does not set credentials on the client instance. See SetCredentials() instead. +// +// res, err := cli.RegisterDummy(&mautrix.ReqRegister{ +// Username: "alice", +// Password: "wonderland", +// }) +// if err != nil { +// panic(err) +// } +// token := res.AccessToken +func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister[any]) (*RespRegister, error) { + _, uia, err := cli.Register(ctx, req) + if err != nil && uia == nil { + return nil, err + } else if uia == nil { + return nil, errors.New("server did not return user-interactive auth flows") + } else if !uia.HasSingleStageFlow(AuthTypeDummy) { + return nil, errors.New("server does not support m.login.dummy") + } + req.Auth = BaseAuthData{Type: AuthTypeDummy, Session: uia.Session} + res, _, err := cli.Register(ctx, req) + if err != nil { + return nil, err + } + return res, nil +} + +// GetLoginFlows fetches the login flows that the homeserver supports using https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3login +func (cli *Client) GetLoginFlows(ctx context.Context) (resp *RespLoginFlows, err error) { + urlPath := cli.BuildClientURL("v3", "login") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// Login a user to the homeserver according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login +func (cli *Client) Login(ctx context.Context, req *ReqLogin) (resp *RespLogin, err error) { + _, err = cli.MakeFullRequest(ctx, FullRequest{ + Method: http.MethodPost, + URL: cli.BuildClientURL("v3", "login"), + RequestJSON: req, + ResponseJSON: &resp, + SensitiveContent: len(req.Password) > 0 || len(req.Token) > 0, + }) + if req.StoreCredentials && err == nil { + cli.DeviceID = resp.DeviceID + cli.AccessToken = resp.AccessToken + cli.UserID = resp.UserID + + cli.Log.Debug(). + Str("user_id", cli.UserID.String()). + Str("device_id", cli.DeviceID.String()). + Msg("Stored credentials after login") + } + if req.StoreHomeserverURL && err == nil && resp.WellKnown != nil && len(resp.WellKnown.Homeserver.BaseURL) > 0 { + var urlErr error + cli.HomeserverURL, urlErr = url.Parse(resp.WellKnown.Homeserver.BaseURL) + if urlErr != nil { + cli.Log.Warn(). + Err(urlErr). + Str("homeserver_url", resp.WellKnown.Homeserver.BaseURL). + Msg("Failed to parse homeserver URL in login response") + } else { + cli.Log.Debug(). + Str("homeserver_url", cli.HomeserverURL.String()). + Msg("Updated homeserver URL after login") + } + } + return +} + +// Create a device for an appservice user using MSC4190. +func (cli *Client) CreateDeviceMSC4190(ctx context.Context, deviceID id.DeviceID, initialDisplayName string) error { + if len(deviceID) == 0 { + deviceID = id.DeviceID(strings.ToUpper(random.String(10))) + } + _, err := cli.MakeRequest(ctx, http.MethodPut, cli.BuildClientURL("v3", "devices", deviceID), &ReqPutDevice{ + DisplayName: initialDisplayName, + }, nil) + if err != nil { + return err + } + cli.DeviceID = deviceID + cli.SetAppServiceDeviceID = true + return nil +} + +// Logout the current user. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logout +// This does not clear the credentials from the client instance. See ClearCredentials() instead. +func (cli *Client) Logout(ctx context.Context) (resp *RespLogout, err error) { + urlPath := cli.BuildClientURL("v3", "logout") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp) + return +} + +// LogoutAll logs out all the devices of the current user. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logoutall +// This does not clear the credentials from the client instance. See ClearCredentials() instead. +func (cli *Client) LogoutAll(ctx context.Context) (resp *RespLogout, err error) { + urlPath := cli.BuildClientURL("v3", "logout", "all") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp) + return +} + +// Versions returns the list of supported Matrix versions on this homeserver. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientversions +func (cli *Client) Versions(ctx context.Context) (resp *RespVersions, err error) { + urlPath := cli.BuildClientURL("versions") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + if resp != nil { + cli.SpecVersions = resp + } + return +} + +// Capabilities returns capabilities on this homeserver. See https://spec.matrix.org/v1.3/client-server-api/#capabilities-negotiation +func (cli *Client) Capabilities(ctx context.Context) (resp *RespCapabilities, err error) { + urlPath := cli.BuildClientURL("v3", "capabilities") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// JoinRoom joins the client to a room ID or alias. See https://spec.matrix.org/v1.13/client-server-api/#post_matrixclientv3joinroomidoralias +// +// The last parameter contains optional extra fields and can be left nil. +func (cli *Client) JoinRoom(ctx context.Context, roomIDorAlias string, req *ReqJoinRoom) (resp *RespJoinRoom, err error) { + if req == nil { + req = &ReqJoinRoom{} + } + urlPath := cli.BuildURLWithFullQuery(ClientURLPath{"v3", "join", roomIDorAlias}, func(q url.Values) { + if len(req.Via) > 0 { + q["via"] = req.Via + } + }) + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) + if err != nil { + err = fmt.Errorf("failed to update state store: %w", err) + } + } + return +} + +// KnockRoom requests to join a room ID or alias. See https://spec.matrix.org/v1.13/client-server-api/#post_matrixclientv3knockroomidoralias +// +// The last parameter contains optional extra fields and can be left nil. +func (cli *Client) KnockRoom(ctx context.Context, roomIDorAlias string, req *ReqKnockRoom) (resp *RespKnockRoom, err error) { + if req == nil { + req = &ReqKnockRoom{} + } + urlPath := cli.BuildURLWithFullQuery(ClientURLPath{"v3", "knock", roomIDorAlias}, func(q url.Values) { + if len(req.Via) > 0 { + q["via"] = req.Via + } + }) + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipKnock) + if err != nil { + err = fmt.Errorf("failed to update state store: %w", err) + } + } + return +} + +// JoinRoomByID joins the client to a room ID. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidjoin +// +// Unlike JoinRoom, this method can only be used to join rooms that the server already knows about. +// It's mostly intended for bridges and other things where it's already certain that the server is in the room. +func (cli *Client) JoinRoomByID(ctx context.Context, roomID id.RoomID) (resp *RespJoinRoom, err error) { + _, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildClientURL("v3", "rooms", roomID, "join"), nil, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) + if err != nil { + err = fmt.Errorf("failed to update state store: %w", err) + } + } + return +} + +func (cli *Client) GetProfile(ctx context.Context, mxid id.UserID) (resp *RespUserProfile, err error) { + urlPath := cli.BuildClientURL("v3", "profile", mxid) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) SearchUserDirectory(ctx context.Context, query string, limit int) (resp *RespSearchUserDirectory, err error) { + urlPath := cli.BuildClientURL("v3", "user_directory", "search") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqSearchUserDirectory{ + SearchTerm: query, + Limit: limit, + }, &resp) + return +} + +func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, extras ...ReqMutualRooms) (resp *RespMutualRooms, err error) { + supportsStable := cli.SpecVersions.Supports(FeatureStableMutualRooms) + supportsUnstable := cli.SpecVersions.Supports(FeatureUnstableMutualRooms) + if cli.SpecVersions != nil && !supportsUnstable && !supportsStable { + err = fmt.Errorf("server does not support fetching mutual rooms") + return + } + query := map[string]string{ + "user_id": otherUserID.String(), + } + if len(extras) > 0 { + query["from"] = extras[0].From + } + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "mutual_rooms"}, query) + if !supportsStable && supportsUnstable { + urlPath = cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query) + } + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) GetRoomSummary(ctx context.Context, roomIDOrAlias string, via ...string) (resp *RespRoomSummary, err error) { + urlPath := ClientURLPath{"unstable", "im.nheko.summary", "summary", roomIDOrAlias} + if cli.SpecVersions.ContainsGreaterOrEqual(SpecV115) { + urlPath = ClientURLPath{"v1", "room_summary", roomIDOrAlias} + } + // TODO add version check after one is added to MSC3266 + fullURL := cli.BuildURLWithFullQuery(urlPath, func(q url.Values) { + if len(via) > 0 { + q["via"] = via + } + }) + _, err = cli.MakeRequest(ctx, http.MethodGet, fullURL, nil, &resp) + return +} + +// GetDisplayName returns the display name of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname +func (cli *Client) GetDisplayName(ctx context.Context, mxid id.UserID) (resp *RespUserDisplayName, err error) { + err = cli.GetProfileField(ctx, mxid, "displayname", &resp) + return +} + +// GetOwnDisplayName returns the user's display name. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname +func (cli *Client) GetOwnDisplayName(ctx context.Context) (resp *RespUserDisplayName, err error) { + return cli.GetDisplayName(ctx, cli.UserID) +} + +// SetDisplayName sets the user's profile display name. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseriddisplayname +func (cli *Client) SetDisplayName(ctx context.Context, displayName string) (err error) { + return cli.SetProfileField(ctx, "displayname", displayName) +} + +// SetProfileField sets an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname +func (cli *Client) SetProfileField(ctx context.Context, key string, value any) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) + if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { + urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) + } + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, map[string]any{ + key: value, + }, nil) + return +} + +// DeleteProfileField deletes an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname +func (cli *Client) DeleteProfileField(ctx context.Context, key string) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) + if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { + urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) + } + _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) + return +} + +// GetProfileField gets an arbitrary profile field and parses the response into the given struct. See https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv3profileuseridkeyname +func (cli *Client) GetProfileField(ctx context.Context, userID id.UserID, key string, into any) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", userID, key) + if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { + urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) + } + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, into) + return +} + +// GetAvatarURL gets the avatar URL of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url +func (cli *Client) GetAvatarURL(ctx context.Context, mxid id.UserID) (url id.ContentURI, err error) { + s := struct { + AvatarURL id.ContentURI `json:"avatar_url"` + }{} + err = cli.GetProfileField(ctx, mxid, "avatar_url", &s) + url = s.AvatarURL + return +} + +// GetOwnAvatarURL gets the user's avatar URL. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url +func (cli *Client) GetOwnAvatarURL(ctx context.Context) (url id.ContentURI, err error) { + return cli.GetAvatarURL(ctx, cli.UserID) +} + +// SetAvatarURL sets the user's avatar URL. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseridavatar_url +func (cli *Client) SetAvatarURL(ctx context.Context, url id.ContentURI) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, "avatar_url") + s := struct { + AvatarURL string `json:"avatar_url"` + }{url.String()} + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &s, nil) + if err != nil { + return err + } + + return nil +} + +// BeeperUpdateProfile sets custom fields in the user's profile. +func (cli *Client) BeeperUpdateProfile(ctx context.Context, data any) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID) + _, err = cli.MakeRequest(ctx, http.MethodPatch, urlPath, data, nil) + return +} + +// UnstableOverwriteProfile replaces the user's entire profile +func (cli *Client) UnstableOverwriteProfile(ctx context.Context, data any) (err error) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID) + if cli.SpecVersions.Supports(FeatureUnstableReplaceProfile) && !cli.SpecVersions.Supports(FeatureStableReplaceProfile) { + urlPath = cli.BuildClientURL("unstable", "com.beeper.msc4437", "profile", cli.UserID) + } + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) + return +} + +// GetAccountData gets the user's account data of this type. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3useruseridaccount_datatype +func (cli *Client) GetAccountData(ctx context.Context, name string, output interface{}) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "account_data", name) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, output) + return +} + +// SetAccountData sets the user's account data of this type. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridaccount_datatype +func (cli *Client) SetAccountData(ctx context.Context, name string, data interface{}) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "account_data", name) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) + if err != nil { + return err + } + + return nil +} + +// GetRoomAccountData gets the user's account data of this type in a specific room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridaccount_datatype +func (cli *Client) GetRoomAccountData(ctx context.Context, roomID id.RoomID, name string, output interface{}) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "account_data", name) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, output) + return +} + +// SetRoomAccountData sets the user's account data of this type in a specific room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridroomsroomidaccount_datatype +func (cli *Client) SetRoomAccountData(ctx context.Context, roomID id.RoomID, name string, data interface{}) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "account_data", name) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) + if err != nil { + return err + } + + return nil +} + +// SendMessageEvent sends a message event into a room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid +// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. +func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { + var req ReqSendEvent + if len(extra) > 0 { + req = extra[0] + } + + var txnID string + if len(req.TransactionID) > 0 { + txnID = req.TransactionID + } else { + txnID = cli.TxnID() + } + + queryParams := map[string]string{} + if req.Timestamp > 0 { + queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) + } + if req.MeowEventID != "" { + queryParams["fi.mau.event_id"] = req.MeowEventID.String() + } + if req.UnstableDelay > 0 { + queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) + } + if req.UnstableStickyDuration > 0 { + queryParams["org.matrix.msc4354.sticky_duration_ms"] = strconv.FormatInt(req.UnstableStickyDuration.Milliseconds(), 10) + } + + if !req.DontEncrypt && cli != nil && cli.Crypto != nil && eventType != event.EventReaction && eventType != event.EventEncrypted { + var isEncrypted bool + isEncrypted, err = cli.StateStore.IsEncrypted(ctx, roomID) + if err != nil { + err = fmt.Errorf("failed to check if room is encrypted: %w", err) + return + } + if isEncrypted { + if contentJSON, err = cli.Crypto.Encrypt(ctx, roomID, eventType, contentJSON); err != nil { + err = fmt.Errorf("failed to encrypt event: %w", err) + return + } + eventType = event.EventEncrypted + } + } + + urlData := ClientURLPath{"v3", "rooms", roomID, "send", eventType.String(), txnID} + urlPath := cli.BuildURLWithQuery(urlData, queryParams) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp) + return +} + +// SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey +// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. +func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { + var req ReqSendEvent + if len(extra) > 0 { + req = extra[0] + } + + queryParams := map[string]string{} + if req.MeowEventID != "" { + queryParams["fi.mau.event_id"] = req.MeowEventID.String() + } + if req.TransactionID != "" { + queryParams["fi.mau.transaction_id"] = req.TransactionID + } + if req.UnstableDelay > 0 { + queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) + } + if req.UnstableStickyDuration > 0 { + queryParams["org.matrix.msc4354.sticky_duration_ms"] = strconv.FormatInt(req.UnstableStickyDuration.Milliseconds(), 10) + } + if req.Timestamp > 0 { + queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) + } + + urlData := ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey} + urlPath := cli.BuildURLWithQuery(urlData, queryParams) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp) + if err == nil && cli.StateStore != nil && req.UnstableDelay == 0 { + cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, contentJSON) + } + return +} + +// SendMassagedStateEvent sends a state event into a room with a custom timestamp. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey +// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. +// +// Deprecated: SendStateEvent accepts a timestamp via ReqSendEvent and should be used instead. +func (cli *Client) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (resp *RespSendEvent, err error) { + resp, err = cli.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, ReqSendEvent{ + Timestamp: ts, + }) + return +} + +func (cli *Client) DelayedEvents(ctx context.Context, req *ReqDelayedEvents) (resp *RespDelayedEvents, err error) { + query := map[string]string{} + if req.DelayID != "" { + query["delay_id"] = string(req.DelayID) + } + if req.Status != "" { + query["status"] = string(req.Status) + } + if req.NextBatch != "" { + query["next_batch"] = req.NextBatch + } + + urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "org.matrix.msc4140", "delayed_events"}, query) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, req, &resp) + + // Migration: merge old keys with new ones + if resp != nil { + resp.Scheduled = append(resp.Scheduled, resp.DelayedEvents...) + resp.DelayedEvents = nil + resp.Finalised = append(resp.Finalised, resp.FinalisedEvents...) + resp.FinalisedEvents = nil + } + + return +} + +func (cli *Client) UpdateDelayedEvent(ctx context.Context, req *ReqUpdateDelayedEvent) (resp *RespUpdateDelayedEvent, err error) { + urlPath := cli.BuildClientURL("unstable", "org.matrix.msc4140", "delayed_events", req.DelayID) + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +// SendText sends an m.room.message event into the given room with a msgtype of m.text +// See https://spec.matrix.org/v1.2/client-server-api/#mtext +func (cli *Client) SendText(ctx context.Context, roomID id.RoomID, text string) (*RespSendEvent, error) { + return cli.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + }) +} + +// SendNotice sends an m.room.message event into the given room with a msgtype of m.notice +// See https://spec.matrix.org/v1.2/client-server-api/#mnotice +func (cli *Client) SendNotice(ctx context.Context, roomID id.RoomID, text string) (*RespSendEvent, error) { + return cli.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: text, + }) +} + +func (cli *Client) SendReaction(ctx context.Context, roomID id.RoomID, eventID id.EventID, reaction string) (*RespSendEvent, error) { + return cli.SendMessageEvent(ctx, roomID, event.EventReaction, &event.ReactionEventContent{ + RelatesTo: event.RelatesTo{ + EventID: eventID, + Type: event.RelAnnotation, + Key: reaction, + }, + }) +} + +// RedactEvent redacts the given event. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidredacteventidtxnid +func (cli *Client) RedactEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, extra ...ReqRedact) (resp *RespSendEvent, err error) { + req := ReqRedact{} + if len(extra) > 0 { + req = extra[0] + } + if req.Extra == nil { + req.Extra = make(map[string]interface{}) + } + if len(req.Reason) > 0 { + req.Extra["reason"] = req.Reason + } + var txnID string + if len(req.TxnID) > 0 { + txnID = req.TxnID + } else { + txnID = cli.TxnID() + } + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "redact", eventID, txnID) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req.Extra, &resp) + return +} + +func (cli *Client) UnstableRedactUserEvents(ctx context.Context, roomID id.RoomID, userID id.UserID, req *ReqRedactUser) (resp *RespRedactUserEvents, err error) { + if req == nil { + req = &ReqRedactUser{} + } + query := map[string]string{} + if req.Limit > 0 { + query["limit"] = strconv.Itoa(req.Limit) + } + urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "org.matrix.msc4194", "rooms", roomID, "redact", "user", userID}, query) + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +// CreateRoom creates a new Matrix room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom +// +// resp, err := cli.CreateRoom(&mautrix.ReqCreateRoom{ +// Preset: "public_chat", +// }) +// fmt.Println("Room:", resp.RoomID) +func (cli *Client) CreateRoom(ctx context.Context, req *ReqCreateRoom) (resp *RespCreateRoom, err error) { + urlPath := cli.BuildClientURL("v3", "createRoom") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + if err == nil && cli.StateStore != nil { + storeErr := cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) + if storeErr != nil { + cli.cliOrContextLog(ctx).Warn().Err(storeErr). + Stringer("creator_user_id", cli.UserID). + Msg("Failed to update creator membership in state store after creating room") + } + for _, evt := range req.InitialState { + evt.RoomID = resp.RoomID + if evt.StateKey == nil { + evt.StateKey = ptr.Ptr("") + } + UpdateStateStore(ctx, cli.StateStore, evt) + } + inviteMembership := event.MembershipInvite + if req.BeeperAutoJoinInvites { + inviteMembership = event.MembershipJoin + } + for _, invitee := range req.Invite { + storeErr = cli.StateStore.SetMembership(ctx, resp.RoomID, invitee, inviteMembership) + if storeErr != nil { + cli.cliOrContextLog(ctx).Warn().Err(storeErr). + Stringer("invitee_user_id", invitee). + Msg("Failed to update membership in state store after creating room") + } + } + } + return +} + +// LeaveRoom leaves the given room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidleave +func (cli *Client) LeaveRoom(ctx context.Context, roomID id.RoomID, optionalReq ...*ReqLeave) (resp *RespLeaveRoom, err error) { + req := &ReqLeave{} + if len(optionalReq) == 1 { + req = optionalReq[0] + } else if len(optionalReq) > 1 { + panic("invalid number of arguments to LeaveRoom") + } + u := cli.BuildClientURL("v3", "rooms", roomID, "leave") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, roomID, cli.UserID, event.MembershipLeave) + if err != nil { + err = fmt.Errorf("failed to update membership in state store: %w", err) + } + } + return +} + +// ForgetRoom forgets a room entirely. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidforget +func (cli *Client) ForgetRoom(ctx context.Context, roomID id.RoomID) (resp *RespForgetRoom, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "forget") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, struct{}{}, &resp) + return +} + +// InviteUser invites a user to a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite +func (cli *Client) InviteUser(ctx context.Context, roomID id.RoomID, req *ReqInviteUser) (resp *RespInviteUser, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "invite") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipInvite) + if err != nil { + err = fmt.Errorf("failed to update membership in state store: %w", err) + } + } + return +} + +// InviteUserByThirdParty invites a third-party identifier to a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite-1 +func (cli *Client) InviteUserByThirdParty(ctx context.Context, roomID id.RoomID, req *ReqInvite3PID) (resp *RespInviteUser, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "invite") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + return +} + +// KickUser kicks a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick +func (cli *Client) KickUser(ctx context.Context, roomID id.RoomID, req *ReqKickUser) (resp *RespKickUser, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "kick") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipLeave) + if err != nil { + err = fmt.Errorf("failed to update membership in state store: %w", err) + } + } + return +} + +// BanUser bans a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban +func (cli *Client) BanUser(ctx context.Context, roomID id.RoomID, req *ReqBanUser) (resp *RespBanUser, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "ban") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipBan) + if err != nil { + err = fmt.Errorf("failed to update membership in state store: %w", err) + } + } + return +} + +// UnbanUser unbans a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban +func (cli *Client) UnbanUser(ctx context.Context, roomID id.RoomID, req *ReqUnbanUser) (resp *RespUnbanUser, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "unban") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + if err == nil && cli.StateStore != nil { + err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipLeave) + if err != nil { + err = fmt.Errorf("failed to update membership in state store: %w", err) + } + } + return +} + +// UserTyping sets the typing status of the user. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid +func (cli *Client) UserTyping(ctx context.Context, roomID id.RoomID, typing bool, timeout time.Duration) (resp *RespTyping, err error) { + req := ReqTyping{Typing: typing, Timeout: timeout.Milliseconds()} + u := cli.BuildClientURL("v3", "rooms", roomID, "typing", cli.UserID) + _, err = cli.MakeRequest(ctx, http.MethodPut, u, req, &resp) + return +} + +// GetPresence gets the presence of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus +func (cli *Client) GetPresence(ctx context.Context, userID id.UserID) (resp *RespPresence, err error) { + resp = new(RespPresence) + u := cli.BuildClientURL("v3", "presence", userID, "status") + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, resp) + return +} + +// GetOwnPresence gets the user's presence. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus +func (cli *Client) GetOwnPresence(ctx context.Context) (resp *RespPresence, err error) { + return cli.GetPresence(ctx, cli.UserID) +} + +func (cli *Client) SetPresence(ctx context.Context, presence ReqPresence) (err error) { + u := cli.BuildClientURL("v3", "presence", cli.UserID, "status") + _, err = cli.MakeRequest(ctx, http.MethodPut, u, presence, nil) + return +} + +func (cli *Client) updateStoreWithOutgoingEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) { + if cli == nil || cli.StateStore == nil { + return + } + fakeEvt := &event.Event{ + StateKey: &stateKey, + Type: eventType, + RoomID: roomID, + } + var err error + fakeEvt.Content.VeryRaw, err = json.Marshal(contentJSON) + if err != nil { + cli.Log.Warn().Err(err).Msg("Failed to marshal state event content to update state store") + return + } + err = json.Unmarshal(fakeEvt.Content.VeryRaw, &fakeEvt.Content.Raw) + if err != nil { + cli.Log.Warn().Err(err).Msg("Failed to unmarshal state event content to update state store") + return + } + err = fakeEvt.Content.ParseRaw(fakeEvt.Type) + if err != nil { + switch fakeEvt.Type { + case event.StateMember, event.StatePowerLevels, event.StateEncryption: + cli.Log.Warn().Err(err).Msg("Failed to parse state event content to update state store") + default: + cli.Log.Debug().Err(err).Msg("Failed to parse state event content to update state store") + } + return + } + UpdateStateStore(ctx, cli.StateStore, fakeEvt) +} + +// StateEvent gets the content of a single state event in a room. +// It will attempt to JSON unmarshal into the given "outContent" struct with the HTTP response body, or return an error. +// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstateeventtypestatekey +func (cli *Client) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) (err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "state", eventType.String(), stateKey) + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, outContent) + if err == nil && cli.StateStore != nil { + cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, outContent) + } + return +} + +// FullStateEvent gets a single state event in a room. Unlike [StateEvent], this gets the entire event +// (including details like the sender and timestamp). +// This requires the server to support the ?format=event query parameter, which is currently missing from the spec. +// See https://github.com/matrix-org/matrix-spec/issues/1047 for more info +func (cli *Client) FullStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (evt *event.Event, err error) { + u := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey}, map[string]string{ + "format": "event", + }) + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &evt) + if evt != nil { + evt.Type.Class = event.StateEventType + _ = evt.Content.ParseRaw(evt.Type) + if evt.RoomID == "" { + evt.RoomID = roomID + } + } + if err == nil && cli.StateStore != nil { + UpdateStateStore(ctx, cli.StateStore, evt) + } + return +} + +// parseRoomStateArray parses a JSON array as a stream and stores the events inside it in a room state map. +func parseRoomStateArray(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { + if res.ContentLength > limit { + return nil, HTTPError{ + Request: req, + Response: res, + + Message: "not reading response", + WrappedError: fmt.Errorf("%w (%.2f MiB)", ErrResponseTooLong, float64(res.ContentLength)/1024/1024), + } + } + response := make(RoomStateMap) + responsePtr := responseJSON.(*map[event.Type]map[string]*event.Event) + *responsePtr = response + dec := json.NewDecoder(io.LimitReader(res.Body, limit)) + + arrayStart, err := dec.Token() + if err != nil { + return nil, err + } else if arrayStart != json.Delim('[') { + return nil, fmt.Errorf("expected array start, got %+v", arrayStart) + } + + for i := 1; dec.More(); i++ { + var evt *event.Event + err = dec.Decode(&evt) + if err != nil { + return nil, fmt.Errorf("failed to parse state array item #%d: %v", i, err) + } + evt.Type.Class = event.StateEventType + _ = evt.Content.ParseRaw(evt.Type) + subMap, ok := response[evt.Type] + if !ok { + subMap = make(map[string]*event.Event) + response[evt.Type] = subMap + } + subMap[*evt.StateKey] = evt + } + + arrayEnd, err := dec.Token() + if err != nil { + return nil, err + } else if arrayEnd != json.Delim(']') { + return nil, fmt.Errorf("expected array end, got %+v", arrayStart) + } + return nil, nil +} + +type RoomStateMap = map[event.Type]map[string]*event.Event + +// State gets all state in a room. +// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstate +func (cli *Client) State(ctx context.Context, roomID id.RoomID) (stateMap RoomStateMap, err error) { + _, err = cli.MakeFullRequest(ctx, FullRequest{ + Method: http.MethodGet, + URL: cli.BuildClientURL("v3", "rooms", roomID, "state"), + ResponseJSON: &stateMap, + Handler: parseRoomStateArray, + }) + if stateMap != nil { + pls, ok := stateMap[event.StatePowerLevels][""] + if ok { + pls.Content.AsPowerLevels().CreateEvent = stateMap[event.StateCreate][""] + } + } + if err == nil && cli.StateStore != nil { + for evtType, evts := range stateMap { + if evtType == event.StateMember { + continue + } + for _, evt := range evts { + if evt.RoomID == "" { + evt.RoomID = roomID + } + UpdateStateStore(ctx, cli.StateStore, evt) + } + } + updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, maps.Values(stateMap[event.StateMember])) + if updateErr != nil { + cli.cliOrContextLog(ctx).Warn().Err(updateErr). + Stringer("room_id", roomID). + Msg("Failed to update members in state store after fetching members") + } + } + return +} + +// StateAsArray gets all the state in a room as an array. It does not update the state store. +// Use State to get the events as a map and also update the state store. +func (cli *Client) StateAsArray(ctx context.Context, roomID id.RoomID) (state []*event.Event, err error) { + _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildClientURL("v3", "rooms", roomID, "state"), nil, &state) + if err == nil { + for _, evt := range state { + evt.Type.Class = event.StateEventType + } + } + return +} + +// GetMediaConfig fetches the configuration of the content repository, such as upload limitations. +func (cli *Client) GetMediaConfig(ctx context.Context) (resp *RespMediaConfig, err error) { + _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildClientURL("v1", "media", "config"), nil, &resp) + return +} + +func (cli *Client) RequestOpenIDToken(ctx context.Context) (resp *RespOpenIDToken, err error) { + _, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildClientURL("v3", "user", cli.UserID, "openid", "request_token"), nil, &resp) + return +} + +// UploadLink uploads an HTTP URL and then returns an MXC URI. +func (cli *Client) UploadLink(ctx context.Context, link string) (*RespMediaUpload, error) { + if cli == nil { + return nil, ErrClientIsNil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil) + if err != nil { + return nil, err + } + + res, err := cli.Client.Do(req) + if res != nil { + defer res.Body.Close() + } + if err != nil { + return nil, err + } + return cli.Upload(ctx, res.Body, res.Header.Get("Content-Type"), res.ContentLength) +} + +func (cli *Client) Download(ctx context.Context, mxcURL id.ContentURI) (*http.Response, error) { + if mxcURL.IsEmpty() { + return nil, fmt.Errorf("empty mxc uri provided to Download") + } + _, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{ + Method: http.MethodGet, + URL: cli.BuildClientURL("v1", "media", "download", mxcURL.Homeserver, mxcURL.FileID), + DontReadResponse: true, + }) + return resp, err +} + +type DownloadThumbnailExtra struct { + Method string + Animated bool +} + +func (cli *Client) DownloadThumbnail(ctx context.Context, mxcURL id.ContentURI, height, width int, extras ...DownloadThumbnailExtra) (*http.Response, error) { + if mxcURL.IsEmpty() { + return nil, fmt.Errorf("empty mxc uri provided to DownloadThumbnail") + } + if len(extras) > 1 { + panic(fmt.Errorf("invalid number of arguments to DownloadThumbnail: %d", len(extras))) + } + var extra DownloadThumbnailExtra + if len(extras) == 1 { + extra = extras[0] + } + path := ClientURLPath{"v1", "media", "thumbnail", mxcURL.Homeserver, mxcURL.FileID} + query := map[string]string{ + "height": strconv.Itoa(height), + "width": strconv.Itoa(width), + } + if extra.Method != "" { + query["method"] = extra.Method + } + if extra.Animated { + query["animated"] = "true" + } + _, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{ + Method: http.MethodGet, + URL: cli.BuildURLWithQuery(path, query), + DontReadResponse: true, + }) + return resp, err +} + +func (cli *Client) DownloadBytes(ctx context.Context, mxcURL id.ContentURI) ([]byte, error) { + resp, err := cli.Download(ctx, mxcURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +type ReqCreateMXC struct { + BeeperUniqueID string + BeeperRoomID id.RoomID +} + +// CreateMXC creates a blank Matrix content URI to allow uploading the content asynchronously later. +// +// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create +func (cli *Client) CreateMXC(ctx context.Context, extra ...ReqCreateMXC) (*RespCreateMXC, error) { + var m RespCreateMXC + query := map[string]string{} + if len(extra) > 0 { + if extra[0].BeeperUniqueID != "" { + query["com.beeper.unique_id"] = extra[0].BeeperUniqueID + } + if extra[0].BeeperRoomID != "" { + query["com.beeper.room_id"] = string(extra[0].BeeperRoomID) + } + } + createURL := cli.BuildURLWithQuery(MediaURLPath{"v1", "create"}, query) + _, err := cli.MakeRequest(ctx, http.MethodPost, createURL, nil, &m) + return &m, err +} + +// UploadAsync creates a blank content URI with CreateMXC, starts uploading the data in the background +// and returns the created MXC immediately. +// +// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create +// and https://spec.matrix.org/v1.7/client-server-api/#put_matrixmediav3uploadservernamemediaid +func (cli *Client) UploadAsync(ctx context.Context, req ReqUploadMedia) (*RespCreateMXC, error) { + resp, err := cli.CreateMXC(ctx) + if err != nil { + req.DoneCallback() + return nil, err + } + req.MXC = resp.ContentURI + req.UnstableUploadURL = resp.UnstableUploadURL + if req.AsyncContext == nil { + req.AsyncContext = cli.cliOrContextLog(ctx).WithContext(context.Background()) + } + go func() { + _, err = cli.UploadMedia(req.AsyncContext, req) + if err != nil { + zerolog.Ctx(req.AsyncContext).Err(err). + Stringer("mxc", req.MXC). + Msg("Async upload of media failed") + } + }() + return resp, nil +} + +func (cli *Client) UploadBytes(ctx context.Context, data []byte, contentType string) (*RespMediaUpload, error) { + return cli.UploadBytesWithName(ctx, data, contentType, "") +} + +func (cli *Client) UploadBytesWithName(ctx context.Context, data []byte, contentType, fileName string) (*RespMediaUpload, error) { + return cli.UploadMedia(ctx, ReqUploadMedia{ + ContentBytes: data, + ContentType: contentType, + FileName: fileName, + }) +} + +// Upload uploads the given data to the content repository and returns an MXC URI. +// +// Deprecated: UploadMedia should be used instead. +func (cli *Client) Upload(ctx context.Context, content io.Reader, contentType string, contentLength int64) (*RespMediaUpload, error) { + return cli.UploadMedia(ctx, ReqUploadMedia{ + Content: content, + ContentLength: contentLength, + ContentType: contentType, + }) +} + +type ReqUploadMedia struct { + ContentBytes []byte + Content io.Reader + ContentLength int64 + ContentType string + FileName string + + AsyncContext context.Context + DoneCallback func() + + // MXC specifies an existing MXC URI which doesn't have content yet to upload into. + // See https://spec.matrix.org/unstable/client-server-api/#put_matrixmediav3uploadservernamemediaid + MXC id.ContentURI + + // UnstableUploadURL specifies the URL to upload the content to. MXC must also be set. + // see https://github.com/matrix-org/matrix-spec-proposals/pull/3870 for more info + UnstableUploadURL string +} + +func (cli *Client) tryUploadMediaToURL(ctx context.Context, url, contentType string, content io.Reader, contentLength int64) (*http.Response, error) { + cli.Log.Debug(). + Str("url", url). + Int64("content_length", contentLength). + Msg("Uploading media to external URL") + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, content) + if err != nil { + return nil, err + } + req.ContentLength = contentLength + req.Header.Set("Content-Type", contentType) + if cli.UserAgent != "" { + req.Header.Set("User-Agent", cli.UserAgent+" (external media uploader)") + } + + if cli.ExternalClient != nil { + return cli.ExternalClient.Do(req) + } else { + return http.DefaultClient.Do(req) + } +} + +func (cli *Client) uploadMediaToURL(ctx context.Context, data ReqUploadMedia) (*RespMediaUpload, error) { + retries := cli.DefaultHTTPRetries + reader := data.Content + if data.ContentBytes != nil { + data.ContentLength = int64(len(data.ContentBytes)) + reader = bytes.NewReader(data.ContentBytes) + } else if rsc, ok := reader.(io.ReadSeekCloser); ok { + // Prevent HTTP from closing the request body, it might be needed for retries + reader = nopCloseSeeker{rsc} + } + readerSeeker, canSeek := reader.(io.ReadSeeker) + if !canSeek { + retries = 0 + } + for { + resp, err := cli.tryUploadMediaToURL(ctx, data.UnstableUploadURL, data.ContentType, reader, data.ContentLength) + if err == nil { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + // Everything is fine + break + } + err = fmt.Errorf("HTTP %d", resp.StatusCode) + } else if errors.Is(err, context.Canceled) { + cli.Log.Warn().Str("url", data.UnstableUploadURL).Msg("External media upload canceled") + return nil, err + } + if retries <= 0 { + cli.Log.Warn().Str("url", data.UnstableUploadURL).Err(err). + Msg("Error uploading media to external URL, not retrying") + return nil, err + } + // TODO change to exponential like normal retries? + backoff := time.Second * time.Duration(cli.DefaultHTTPRetries-retries) + cli.Log.Warn().Err(err). + Str("url", data.UnstableUploadURL). + Int("retry_in_seconds", int(backoff.Seconds())). + Msg("Error uploading media to external URL, retrying") + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } + retries-- + _, err = readerSeeker.Seek(0, io.SeekStart) + if err != nil { + return nil, fmt.Errorf("failed to seek back to start of reader: %w", err) + } + } + + query := map[string]string{} + if len(data.FileName) > 0 { + query["filename"] = data.FileName + } + + notifyURL := cli.BuildURLWithQuery(MediaURLPath{"unstable", "com.beeper.msc3870", "upload", data.MXC.Homeserver, data.MXC.FileID, "complete"}, query) + + var m *RespMediaUpload + _, err := cli.MakeRequest(ctx, http.MethodPost, notifyURL, nil, &m) + if err != nil { + return nil, err + } + + return m, nil +} + +type nopCloseSeeker struct { + io.ReadSeeker +} + +func (nopCloseSeeker) Close() error { + return nil +} + +// UploadMedia uploads the given data to the content repository and returns an MXC URI. +// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav3upload +func (cli *Client) UploadMedia(ctx context.Context, data ReqUploadMedia) (*RespMediaUpload, error) { + if data.DoneCallback != nil { + defer data.DoneCallback() + } + if cli == nil { + return nil, ErrClientIsNil + } + if data.UnstableUploadURL != "" { + if data.MXC.IsEmpty() { + return nil, errors.New("MXC must also be set when uploading to external URL") + } + return cli.uploadMediaToURL(ctx, data) + } + u, _ := url.Parse(cli.BuildURL(MediaURLPath{"v3", "upload"})) + method := http.MethodPost + if !data.MXC.IsEmpty() { + u, _ = url.Parse(cli.BuildURL(MediaURLPath{"v3", "upload", data.MXC.Homeserver, data.MXC.FileID})) + method = http.MethodPut + } + if len(data.FileName) > 0 { + q := u.Query() + q.Set("filename", data.FileName) + u.RawQuery = q.Encode() + } + + var headers http.Header + if len(data.ContentType) > 0 { + headers = http.Header{"Content-Type": []string{data.ContentType}} + } + + var m RespMediaUpload + _, err := cli.MakeFullRequest(ctx, FullRequest{ + Method: method, + URL: u.String(), + Headers: headers, + RequestBytes: data.ContentBytes, + RequestBody: data.Content, + RequestLength: data.ContentLength, + ResponseJSON: &m, + }) + return &m, err +} + +// GetURLPreview asks the homeserver to fetch a preview for a given URL. +// +// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixmediav3preview_url +func (cli *Client) GetURLPreview(ctx context.Context, url string) (*RespPreviewURL, error) { + reqURL := cli.BuildURLWithQuery(ClientURLPath{"v1", "media", "preview_url"}, map[string]string{ + "url": url, + }) + var output RespPreviewURL + _, err := cli.MakeRequest(ctx, http.MethodGet, reqURL, nil, &output) + return &output, err +} + +// JoinedMembers returns a map of joined room members. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidjoined_members +// +// In general, usage of this API is discouraged in favour of /sync, as calling this API can race with incoming membership changes. +// This API is primarily designed for application services which may want to efficiently look up joined members in a room. +func (cli *Client) JoinedMembers(ctx context.Context, roomID id.RoomID) (resp *RespJoinedMembers, err error) { + u := cli.BuildClientURL("v3", "rooms", roomID, "joined_members") + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) + if err == nil && cli.StateStore != nil { + fakeEvents := make([]*event.Event, len(resp.Joined)) + i := 0 + for userID, member := range resp.Joined { + fakeEvents[i] = &event.Event{ + StateKey: ptr.Ptr(userID.String()), + Type: event.StateMember, + RoomID: roomID, + Content: event.Content{Parsed: &event.MemberEventContent{ + Membership: event.MembershipJoin, + AvatarURL: id.ContentURIString(member.AvatarURL), + Displayname: member.DisplayName, + }}, + } + i++ + } + updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, fakeEvents, event.MembershipJoin) + if updateErr != nil { + cli.cliOrContextLog(ctx).Warn().Err(updateErr). + Stringer("room_id", roomID). + Msg("Failed to update members in state store after fetching joined members") + } + } + return +} + +func (cli *Client) Members(ctx context.Context, roomID id.RoomID, req ...ReqMembers) (resp *RespMembers, err error) { + var extra ReqMembers + if len(req) > 0 { + extra = req[0] + } + query := map[string]string{} + if len(extra.At) > 0 { + query["at"] = extra.At + } + if len(extra.Membership) > 0 { + query["membership"] = string(extra.Membership) + } + if len(extra.NotMembership) > 0 { + query["not_membership"] = string(extra.NotMembership) + } + u := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "members"}, query) + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) + if err == nil { + for _, evt := range resp.Chunk { + _ = evt.Content.ParseRaw(evt.Type) + } + } + if err == nil && cli.StateStore != nil { + var onlyMemberships []event.Membership + if extra.Membership != "" { + onlyMemberships = []event.Membership{extra.Membership} + } else if extra.NotMembership != "" { + onlyMemberships = []event.Membership{event.MembershipJoin, event.MembershipLeave, event.MembershipInvite, event.MembershipBan, event.MembershipKnock} + onlyMemberships = slices.DeleteFunc(onlyMemberships, func(m event.Membership) bool { + return m == extra.NotMembership + }) + } + updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, resp.Chunk, onlyMemberships...) + if updateErr != nil { + cli.cliOrContextLog(ctx).Warn().Err(updateErr). + Stringer("room_id", roomID). + Msg("Failed to update members in state store after fetching members") + } + } + return +} + +// JoinedRooms returns a list of rooms which the client is joined to. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3joined_rooms +// +// In general, usage of this API is discouraged in favour of /sync, as calling this API can race with incoming membership changes. +// This API is primarily designed for application services which may want to efficiently look up joined rooms. +func (cli *Client) JoinedRooms(ctx context.Context) (resp *RespJoinedRooms, err error) { + u := cli.BuildClientURL("v3", "joined_rooms") + _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) + return +} + +func (cli *Client) PublicRooms(ctx context.Context, req *ReqPublicRooms) (resp *RespPublicRooms, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "publicRooms"}, req.Query()) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// Hierarchy returns a list of rooms that are in the room's hierarchy. See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy +// +// The hierarchy API is provided to walk the space tree and discover the rooms with their aesthetic details. works in a depth-first manner: +// when it encounters another space as a child it recurses into that space before returning non-space children. +// +// The second function parameter specifies query parameters to limit the response. No query parameters will be added if it's nil. +func (cli *Client) Hierarchy(ctx context.Context, roomID id.RoomID, req *ReqHierarchy) (resp *RespHierarchy, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "rooms", roomID, "hierarchy"}, req.Query()) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// Messages returns a list of message and state events for a room. It uses +// pagination query parameters to paginate history in the room. +// See https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3roomsroomidmessages +func (cli *Client) Messages(ctx context.Context, roomID id.RoomID, from, to string, dir Direction, filter *FilterPart, limit int) (resp *RespMessages, err error) { + query := map[string]string{ + "dir": string(dir), + } + if filter != nil { + filterJSON, err := json.Marshal(filter) + if err != nil { + return nil, err + } + query["filter"] = string(filterJSON) + } + if from != "" { + query["from"] = from + } + if to != "" { + query["to"] = to + } + if limit != 0 { + query["limit"] = strconv.Itoa(limit) + } + + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "messages"}, query) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// TimestampToEvent finds the ID of the event closest to the given timestamp. +// +// See https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv1roomsroomidtimestamp_to_event +func (cli *Client) TimestampToEvent(ctx context.Context, roomID id.RoomID, timestamp time.Time, dir Direction) (resp *RespTimestampToEvent, err error) { + query := map[string]string{ + "ts": strconv.FormatInt(timestamp.UnixMilli(), 10), + "dir": string(dir), + } + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "rooms", roomID, "timestamp_to_event"}, query) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// Context returns a number of events that happened just before and after the +// specified event. It use pagination query parameters to paginate history in +// the room. +// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidcontexteventid +func (cli *Client) Context(ctx context.Context, roomID id.RoomID, eventID id.EventID, filter *FilterPart, limit int) (resp *RespContext, err error) { + query := map[string]string{} + if filter != nil { + filterJSON, err := json.Marshal(filter) + if err != nil { + return nil, err + } + query["filter"] = string(filterJSON) + } + if limit != 0 { + query["limit"] = strconv.Itoa(limit) + } + + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "context", eventID}, query) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) Search(ctx context.Context, req *ReqSearch) (*RespSearch, error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "search"}, req.Query()) + wrappedReq := &ReqSearchWrapper{SearchCategories: ReqSearchCategoryWrapper{RoomEvents: req}} + var wrappedResp RespSearchWrapper + _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, wrappedReq, &wrappedResp) + return wrappedResp.SearchCategories.RoomEvents, err +} + +func (cli *Client) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (resp *event.Event, err error) { + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "event", eventID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) GetUnredactedEventContent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (resp *event.Event, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "event", eventID}, map[string]string{ + "fi.mau.msc2815.include_unredacted_content": "true", + }) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) GetRelations(ctx context.Context, roomID id.RoomID, eventID id.EventID, req *ReqGetRelations) (resp *RespGetRelations, err error) { + urlPath := cli.BuildURLWithQuery(append(ClientURLPath{"v1", "rooms", roomID, "relations", eventID}, req.PathSuffix()...), req.Query()) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID) (err error) { + return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, nil) +} + +// MarkReadWithContent sends a read receipt including custom data. +// +// Deprecated: Use SendReceipt instead. +func (cli *Client) MarkReadWithContent(ctx context.Context, roomID id.RoomID, eventID id.EventID, content interface{}) (err error) { + return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, content) +} + +// SendReceipt sends a receipt, usually specifically a read receipt. +// +// Passing nil as the content is safe, the library will automatically replace it with an empty JSON object. +// To mark a message in a specific thread as read, use pass a ReqSendReceipt as the content. +func (cli *Client) SendReceipt(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType, content interface{}) (err error) { + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "receipt", receiptType, eventID) + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, nil) + return +} + +func (cli *Client) SetReadMarkers(ctx context.Context, roomID id.RoomID, content interface{}) (err error) { + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "read_markers") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, nil) + return +} + +func (cli *Client) SetBeeperInboxState(ctx context.Context, roomID id.RoomID, content *ReqSetBeeperInboxState) (err error) { + urlPath := cli.BuildClientURL("unstable", "com.beeper.inbox", "user", cli.UserID, "rooms", roomID, "inbox_state") + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, content, nil) + return +} + +func (cli *Client) AddTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag, order float64) error { + return cli.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{ + Order: json.Number(strconv.FormatFloat(order, 'e', -1, 64)), + }) +} + +func (cli *Client) AddTagWithCustomData(ctx context.Context, roomID id.RoomID, tag event.RoomTag, data any) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) + return +} + +func (cli *Client) GetTags(ctx context.Context, roomID id.RoomID) (tags event.TagEventContent, err error) { + err = cli.GetTagsWithCustomData(ctx, roomID, &tags) + return +} + +func (cli *Client) GetTagsWithCustomData(ctx context.Context, roomID id.RoomID, resp any) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) RemoveTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag) (err error) { + urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag) + _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) + return +} + +// Deprecated: Synapse may not handle setting m.tag directly properly, so you should use the Add/RemoveTag methods instead. +func (cli *Client) SetTags(ctx context.Context, roomID id.RoomID, tags event.Tags) (err error) { + return cli.SetRoomAccountData(ctx, roomID, "m.tag", map[string]event.Tags{ + "tags": tags, + }) +} + +// TurnServer returns turn server details and credentials for the client to use when initiating calls. +// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3voipturnserver +func (cli *Client) TurnServer(ctx context.Context) (resp *RespTurnServer, err error) { + urlPath := cli.BuildClientURL("v3", "voip", "turnServer") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) CreateAlias(ctx context.Context, alias id.RoomAlias, roomID id.RoomID) (resp *RespAliasCreate, err error) { + urlPath := cli.BuildClientURL("v3", "directory", "room", alias) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqAliasCreate{RoomID: roomID}, &resp) + return +} + +func (cli *Client) ResolveAlias(ctx context.Context, alias id.RoomAlias) (resp *RespAliasResolve, err error) { + urlPath := cli.BuildClientURL("v3", "directory", "room", alias) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) DeleteAlias(ctx context.Context, alias id.RoomAlias) (resp *RespAliasDelete, err error) { + urlPath := cli.BuildClientURL("v3", "directory", "room", alias) + _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) + return +} + +func (cli *Client) GetAliases(ctx context.Context, roomID id.RoomID) (resp *RespAliasList, err error) { + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "aliases") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) UploadKeys(ctx context.Context, req *ReqUploadKeys) (resp *RespUploadKeys, err error) { + urlPath := cli.BuildClientURL("v3", "keys", "upload") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +func (cli *Client) QueryKeys(ctx context.Context, req *ReqQueryKeys) (resp *RespQueryKeys, err error) { + urlPath := cli.BuildClientURL("v3", "keys", "query") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +func (cli *Client) ClaimKeys(ctx context.Context, req *ReqClaimKeys) (resp *RespClaimKeys, err error) { + urlPath := cli.BuildClientURL("v3", "keys", "claim") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +func (cli *Client) GetKeyChanges(ctx context.Context, from, to string) (resp *RespKeyChanges, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "keys", "changes"}, map[string]string{ + "from": from, + "to": to, + }) + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp) + return +} + +// GetKeyBackup retrieves the keys from the backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeys +func (cli *Client) GetKeyBackup(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeys[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// PutKeysInBackup stores several keys in the backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeys +func (cli *Client) PutKeysInBackup(ctx context.Context, version id.KeyBackupVersion, req *ReqKeyBackup) (resp *RespRoomKeysUpdate, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) + return +} + +// DeleteKeyBackup deletes all keys from the backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeys +func (cli *Client) DeleteKeyBackup(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeysUpdate, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) + return +} + +// GetKeyBackupForRoom retrieves the keys from the backup for the given room. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeysroomid +func (cli *Client) GetKeyBackupForRoom( + ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, +) (resp *RespRoomKeyBackup[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// PutKeysInBackupForRoom stores several keys in the backup for the given room. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeysroomid +func (cli *Client) PutKeysInBackupForRoom(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, req *ReqRoomKeyBackup) (resp *RespRoomKeysUpdate, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) + return +} + +// DeleteKeysFromBackupForRoom deletes all the keys in the backup for the given +// room. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeysroomid +func (cli *Client) DeleteKeysFromBackupForRoom(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID) (resp *RespRoomKeysUpdate, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) + return +} + +// GetKeyBackupForRoomAndSession retrieves a key from the backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeysroomidsessionid +func (cli *Client) GetKeyBackupForRoomAndSession( + ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, +) (resp *RespKeyBackupData[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// PutKeysInBackupForRoomAndSession stores a key in the backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeysroomidsessionid +func (cli *Client) PutKeysInBackupForRoomAndSession(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, req *ReqKeyBackupData) (resp *RespRoomKeysUpdate, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) + return +} + +// DeleteKeysInBackupForRoomAndSession deletes a key from the backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeysroomidsessionid +func (cli *Client) DeleteKeysInBackupForRoomAndSession(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID) (resp *RespRoomKeysUpdate, err error) { + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{ + "version": string(version), + }) + _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) + return +} + +// GetKeyBackupLatestVersion returns information about the latest backup version. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keysversion +func (cli *Client) GetKeyBackupLatestVersion(ctx context.Context) (resp *RespRoomKeysVersion[backup.MegolmAuthData], err error) { + urlPath := cli.BuildClientURL("v3", "room_keys", "version") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// CreateKeyBackupVersion creates a new key backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#post_matrixclientv3room_keysversion +func (cli *Client) CreateKeyBackupVersion(ctx context.Context, req *ReqRoomKeysVersionCreate[backup.MegolmAuthData]) (resp *RespRoomKeysVersionCreate, err error) { + urlPath := cli.BuildClientURL("v3", "room_keys", "version") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +// GetKeyBackupVersion returns information about an existing key backup. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keysversionversion +func (cli *Client) GetKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeysVersion[backup.MegolmAuthData], err error) { + urlPath := cli.BuildClientURL("v3", "room_keys", "version", version) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +// UpdateKeyBackupVersion updates information about an existing key backup. Only +// the auth_data can be modified. +// +// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keysversionversion +func (cli *Client) UpdateKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion, req *ReqRoomKeysVersionUpdate[backup.MegolmAuthData]) error { + urlPath := cli.BuildClientURL("v3", "room_keys", "version", version) + _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, nil, nil) + return err +} + +// DeleteKeyBackupVersion deletes an existing key backup. Both the information +// about the backup, as well as all key data related to the backup will be +// deleted. +// +// See: https://spec.matrix.org/v1.1/client-server-api/#delete_matrixclientv3room_keysversionversion +func (cli *Client) DeleteKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) error { + urlPath := cli.BuildClientURL("v3", "room_keys", "version", version) + _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) + return err +} + +func (cli *Client) SendToDevice(ctx context.Context, eventType event.Type, req *ReqSendToDevice) (resp *RespSendToDevice, err error) { + urlPath := cli.BuildClientURL("v3", "sendToDevice", eventType.String(), cli.TxnID()) + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) + return +} + +func (cli *Client) GetDevicesInfo(ctx context.Context) (resp *RespDevicesInfo, err error) { + urlPath := cli.BuildClientURL("v3", "devices") + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) GetDeviceInfo(ctx context.Context, deviceID id.DeviceID) (resp *RespDeviceInfo, err error) { + urlPath := cli.BuildClientURL("v3", "devices", deviceID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) SetDeviceInfo(ctx context.Context, deviceID id.DeviceID, req *ReqDeviceInfo) error { + urlPath := cli.BuildClientURL("v3", "devices", deviceID) + _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, req, nil) + return err +} + +func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice[any]) error { + urlPath := cli.BuildClientURL("v3", "devices", deviceID) + _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil) + return err +} + +func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices[any]) error { + urlPath := cli.BuildClientURL("v3", "delete_devices") + _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, req, nil) + return err +} + +type UIACallback = func(*RespUserInteractive) interface{} + +// UploadCrossSigningKeys uploads the given cross-signing keys to the server. +// Because the endpoint requires user-interactive authentication a callback must be provided that, +// given the UI auth parameters, produces the required result (or nil to end the flow). +func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq[any], uiaCallback UIACallback) error { + content, err := cli.MakeFullRequest(ctx, FullRequest{ + Method: http.MethodPost, + URL: cli.BuildClientURL("v3", "keys", "device_signing", "upload"), + RequestJSON: keys, + SensitiveContent: keys.Auth != nil, + }) + if respErr, ok := err.(HTTPError); ok && respErr.IsStatus(http.StatusUnauthorized) && uiaCallback != nil { + // try again with UI auth + var uiAuthResp RespUserInteractive + if err := json.Unmarshal(content, &uiAuthResp); err != nil { + return fmt.Errorf("failed to decode UIA response: %w", err) + } + auth := uiaCallback(&uiAuthResp) + if auth != nil { + keys.Auth = auth + return cli.UploadCrossSigningKeys(ctx, keys, nil) + } + } + return err +} + +func (cli *Client) UploadSignatures(ctx context.Context, req *ReqUploadSignatures) (resp *RespUploadSignatures, err error) { + urlPath := cli.BuildClientURL("v3", "keys", "signatures", "upload") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +// GetPushRules returns the push notification rules for the global scope. +func (cli *Client) GetPushRules(ctx context.Context) (*pushrules.PushRuleset, error) { + return cli.GetScopedPushRules(ctx, "global") +} + +// GetScopedPushRules returns the push notification rules for the given scope. +func (cli *Client) GetScopedPushRules(ctx context.Context, scope string) (resp *pushrules.PushRuleset, err error) { + u, _ := url.Parse(cli.BuildClientURL("v3", "pushrules", scope)) + // client.BuildURL returns the URL without a trailing slash, but the pushrules endpoint requires the slash. + u.Path += "/" + _, err = cli.MakeRequest(ctx, http.MethodGet, u.String(), nil, &resp) + return +} + +func (cli *Client) GetPushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string) (resp *pushrules.PushRule, err error) { + urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + if resp != nil { + resp.Type = kind + } + return +} + +func (cli *Client) DeletePushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string) error { + urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID) + _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) + return err +} + +func (cli *Client) SetPushRuleEnabled(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, enabled bool) error { + urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID, "enabled") + _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, map[string]any{ + "enabled": enabled, + }, nil) + return err +} + +func (cli *Client) PutPushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, req *ReqPutPushRule) error { + query := make(map[string]string) + if len(req.After) > 0 { + query["after"] = req.After + } + if len(req.Before) > 0 { + query["before"] = req.Before + } + urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "pushrules", scope, kind, ruleID}, query) + _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, req, nil) + return err +} + +func (cli *Client) PutPushRuleActions(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, actions []*pushrules.PushAction) error { + urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID, "actions") + _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqPutPushRule{ + Actions: actions, + }, nil) + return err +} + +func (cli *Client) ReportEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, reason string) error { + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "report", eventID) + _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqReport{Reason: reason, Score: -100}, nil) + return err +} + +func (cli *Client) ReportRoom(ctx context.Context, roomID id.RoomID, reason string) error { + urlPath := cli.BuildClientURL("v3", "rooms", roomID, "report") + _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqReport{Reason: reason, Score: -100}, nil) + return err +} + +// AdminWhoIs fetches session information belonging to a specific user. Typically requires being a server admin. +// +// https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid +func (cli *Client) AdminWhoIs(ctx context.Context, userID id.UserID) (resp RespWhoIs, err error) { + urlPath := cli.BuildClientURL("v3", "admin", "whois", userID) + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return +} + +func (cli *Client) makeMSC4323URL(action string, target id.UserID) string { + if cli.SpecVersions.Supports(FeatureUnstableAccountModeration) { + return cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", action, target) + } else if cli.SpecVersions.Supports(FeatureStableAccountModeration) { + return cli.BuildClientURL("v1", "admin", action, target) + } + return "" +} + +// GetSuspendedStatus uses MSC4323 to check if a user is suspended. +func (cli *Client) GetSuspendedStatus(ctx context.Context, userID id.UserID) (res *RespSuspended, err error) { + urlPath := cli.makeMSC4323URL("suspend", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) + return +} + +// GetLockStatus uses MSC4323 to check if a user is locked. +func (cli *Client) GetLockStatus(ctx context.Context, userID id.UserID) (res *RespLocked, err error) { + urlPath := cli.makeMSC4323URL("lock", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } + _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) + return +} + +// SetSuspendedStatus uses MSC4323 to set whether a user account is suspended. +func (cli *Client) SetSuspendedStatus(ctx context.Context, userID id.UserID, suspended bool) (res *RespSuspended, err error) { + urlPath := cli.makeMSC4323URL("suspend", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqSuspend{Suspended: suspended}, res) + return +} + +// SetLockStatus uses MSC4323 to set whether a user account is locked. +func (cli *Client) SetLockStatus(ctx context.Context, userID id.UserID, locked bool) (res *RespLocked, err error) { + urlPath := cli.makeMSC4323URL("lock", userID) + if urlPath == "" { + return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") + } + _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqLocked{Locked: locked}, res) + return +} + +func (cli *Client) AppservicePing(ctx context.Context, id, txnID string) (resp *RespAppservicePing, err error) { + _, err = cli.MakeFullRequest(ctx, FullRequest{ + Method: http.MethodPost, + URL: cli.BuildClientURL("v1", "appservice", id, "ping"), + RequestJSON: &ReqAppservicePing{TxnID: txnID}, + ResponseJSON: &resp, + // This endpoint intentionally returns 50x, so don't retry + MaxAttempts: 1, + }) + return +} + +func (cli *Client) BeeperBatchSend(ctx context.Context, roomID id.RoomID, req *ReqBeeperBatchSend) (resp *RespBeeperBatchSend, err error) { + u := cli.BuildClientURL("unstable", "com.beeper.backfill", "rooms", roomID, "batch_send") + _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) + return +} + +func (cli *Client) BeeperMergeRooms(ctx context.Context, req *ReqBeeperMergeRoom) (resp *RespBeeperMergeRoom, err error) { + urlPath := cli.BuildClientURL("unstable", "com.beeper.chatmerging", "merge") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} + +func (cli *Client) BeeperSplitRoom(ctx context.Context, req *ReqBeeperSplitRoom) (resp *RespBeeperSplitRoom, err error) { + urlPath := cli.BuildClientURL("unstable", "com.beeper.chatmerging", "rooms", req.RoomID, "split") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) + return +} +func (cli *Client) BeeperDeleteRoom(ctx context.Context, roomID id.RoomID) (err error) { + urlPath := cli.BuildClientURL("unstable", "com.beeper.yeet", "rooms", roomID, "delete") + _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, nil) + return +} + +// TxnID returns the next transaction ID. +func (cli *Client) TxnID() string { + if cli == nil { + return "client is nil" + } + txnID := atomic.AddInt32(&cli.txnID, 1) + return fmt.Sprintf("mautrix-go_%d_%d", time.Now().UnixNano(), txnID) +} + +// NewClient creates a new Matrix Client ready for syncing +func NewClient(homeserverURL string, userID id.UserID, accessToken string) (*Client, error) { + hsURL, err := ParseAndNormalizeBaseURL(homeserverURL) + if err != nil { + return nil, err + } + return &Client{ + AccessToken: accessToken, + UserAgent: DefaultUserAgent, + HomeserverURL: hsURL, + UserID: userID, + Client: &http.Client{Timeout: 180 * time.Second}, + Syncer: NewDefaultSyncer(), + Log: zerolog.Nop(), + // By default, use an in-memory store which will never save filter ids / next batch tokens to disk. + // The client will work with this storer: it just won't remember across restarts. + // In practice, a database backend should be used. + Store: NewMemorySyncStore(), + }, nil +} diff --git a/mautrix-patched/client_retry_test.go b/mautrix-patched/client_retry_test.go new file mode 100644 index 00000000..b753e43b --- /dev/null +++ b/mautrix-patched/client_retry_test.go @@ -0,0 +1,491 @@ +package mautrix + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exsync" +) + +func newTestClient(t *testing.T, serverURL string) *Client { + t.Helper() + parsedURL, err := url.Parse(serverURL) + require.NoError(t, err) + return &Client{ + HomeserverURL: parsedURL, + Client: http.DefaultClient, + Log: zerolog.New(io.Discard), + DefaultHTTPRetries: 1, + DefaultHTTPBackoff: 200 * time.Millisecond, + RequestRetryTrigger: exsync.NewEvent(), + } +} + +func TestRequestRetryTriggerRetriesActiveAttempt(t *testing.T) { + requestStarted := make(chan struct{}) + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch attempts.Add(1) { + case 1: + close(requestStarted) + <-r.Context().Done() + case 2: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected extra request attempt %d", attempts.Load()) + } + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + var response struct { + OK bool `json:"ok"` + } + errCh := make(chan error, 1) + go func() { + _, err := client.MakeRequest(context.Background(), http.MethodGet, server.URL, nil, &response) + errCh <- err + }() + + select { + case <-requestStarted: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for initial request attempt") + } + + resetAt := time.Now() + client.RequestRetryTrigger.Notify() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for retried request to finish") + } + + require.True(t, response.OK) + require.EqualValues(t, 2, attempts.Load()) + require.Less(t, time.Since(resetAt), 150*time.Millisecond) +} + +func TestRequestRetryTriggerUsesNormalRetryBudget(t *testing.T) { + requestStarted := make(chan struct{}) + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch attempts.Add(1) { + case 1: + close(requestStarted) + <-r.Context().Done() + default: + t.Fatalf("unexpected extra request attempt %d", attempts.Load()) + } + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + client.DefaultHTTPRetries = 0 + + errCh := make(chan error, 1) + go func() { + _, err := client.MakeRequest(context.Background(), http.MethodGet, server.URL, nil, nil) + errCh <- err + }() + + select { + case <-requestStarted: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for request start") + } + + client.RequestRetryTrigger.Notify() + + select { + case err := <-errCh: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for canceled request to finish") + } + + require.EqualValues(t, 1, attempts.Load()) +} + +func TestCallerCancellationDoesNotRetry(t *testing.T) { + requestStarted := make(chan struct{}) + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + close(requestStarted) + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := client.MakeRequest(ctx, http.MethodGet, server.URL, nil, nil) + errCh <- err + }() + + select { + case <-requestStarted: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for request start") + } + + cancel() + + select { + case err := <-errCh: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for canceled request to finish") + } + + require.EqualValues(t, 1, attempts.Load()) +} + +func TestRequestRetryTriggerDoesNotInterruptBackoff(t *testing.T) { + firstAttemptDone := make(chan time.Time, 1) + secondAttemptStarted := make(chan time.Time, 1) + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch attempts.Add(1) { + case 1: + w.WriteHeader(http.StatusBadGateway) + firstAttemptDone <- time.Now() + case 2: + secondAttemptStarted <- time.Now() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + default: + t.Fatalf("unexpected extra request attempt %d", attempts.Load()) + } + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + client.DefaultHTTPBackoff = 250 * time.Millisecond + errCh := make(chan error, 1) + go func() { + _, err := client.MakeRequest(context.Background(), http.MethodGet, server.URL, nil, nil) + errCh <- err + }() + + var firstAt time.Time + select { + case firstAt = <-firstAttemptDone: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for first attempt to fail") + } + + time.Sleep(50 * time.Millisecond) + client.RequestRetryTrigger.Notify() + + var secondAt time.Time + select { + case secondAt = <-secondAttemptStarted: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for retried request") + } + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for request completion") + } + + require.GreaterOrEqual(t, secondAt.Sub(firstAt), 200*time.Millisecond) + require.EqualValues(t, 2, attempts.Load()) +} + +func TestRequestRetryTriggerCancelsStreamingBody(t *testing.T) { + streamStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello")) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + close(streamStarted) + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ + Method: http.MethodGet, + URL: server.URL, + DontReadResponse: true, + }) + require.NoError(t, err) + require.NotNil(t, resp) + defer resp.Body.Close() + + select { + case <-streamStarted: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for stream start") + } + + buf := make([]byte, 5) + n, err := io.ReadFull(resp.Body, buf) + require.NoError(t, err) + require.Equal(t, 5, n) + require.Equal(t, "hello", string(buf)) + + client.RequestRetryTrigger.Notify() + + _, err = resp.Body.Read(make([]byte, 1)) + require.Error(t, err) +} + +func TestDontReadResponseCleanupRunsOnBodyClose(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello")) + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + attemptCtxCh := make(chan context.Context, 1) + client.RequestHook = func(req *http.Request) { + select { + case attemptCtxCh <- req.Context(): + default: + } + } + + _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ + Method: http.MethodGet, + URL: server.URL, + DontReadResponse: true, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + var attemptCtx context.Context + select { + case attemptCtx = <-attemptCtxCh: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for attempt context") + } + + select { + case <-attemptCtx.Done(): + t.Fatal("attempt context canceled before body close") + case <-time.After(100 * time.Millisecond): + } + + require.NoError(t, resp.Body.Close()) + + select { + case <-attemptCtx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for attempt context cleanup after body close") + } + require.ErrorIs(t, context.Cause(attemptCtx), context.Canceled) +} + +func TestRedirectErrorCleansUpAttemptContext(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/final" { + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, server.URL+"/final", http.StatusFound) + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + httpClient := server.Client() + httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return errors.New("stop redirect") + } + client.Client = httpClient + + attemptCtxCh := make(chan context.Context, 1) + client.RequestHook = func(req *http.Request) { + select { + case attemptCtxCh <- req.Context(): + default: + } + } + + _, _, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ + Method: http.MethodGet, + URL: server.URL, + }) + require.Error(t, err) + + var attemptCtx context.Context + select { + case attemptCtx = <-attemptCtxCh: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for attempt context") + } + + select { + case <-attemptCtx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for attempt context cleanup after redirect error") + } + require.ErrorIs(t, context.Cause(attemptCtx), context.Canceled) +} + +type readSeekCloser struct { + *bytes.Reader +} + +func (r readSeekCloser) Close() error { + return nil +} + +type testRoundTripper func(*http.Request) (*http.Response, error) + +func (trt testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return trt(req) +} + +type writerToReadCloser struct { + *bytes.Reader +} + +func (wrc *writerToReadCloser) Close() error { + return nil +} + +func (wrc *writerToReadCloser) WriteTo(w io.Writer) (int64, error) { + return io.Copy(w, wrc.Reader) +} + +func TestRequestRetryTriggerReplaysRequestBody(t *testing.T) { + requestStarted := make(chan struct{}) + bodyBytes := []byte("hello retry body") + var attempts atomic.Int32 + receivedBodies := make(chan []byte, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + receivedBodies <- body + + switch attempts.Add(1) { + case 1: + close(requestStarted) + <-r.Context().Done() + case 2: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected extra request attempt %d", attempts.Load()) + } + })) + t.Cleanup(server.Close) + + client := newTestClient(t, server.URL) + var response struct { + OK bool `json:"ok"` + } + errCh := make(chan error, 1) + go func() { + _, err := client.MakeFullRequest(context.Background(), FullRequest{ + Method: http.MethodPost, + URL: server.URL, + RequestBody: readSeekCloser{bytes.NewReader(bodyBytes)}, + RequestLength: int64(len(bodyBytes)), + ResponseJSON: &response, + }) + errCh <- err + }() + + select { + case <-requestStarted: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for initial request attempt") + } + + client.RequestRetryTrigger.Notify() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for retried request to finish") + } + + require.True(t, response.OK) + require.EqualValues(t, 2, attempts.Load()) + require.Equal(t, bodyBytes, <-receivedBodies) + require.Equal(t, bodyBytes, <-receivedBodies) +} + +func TestDontReadResponseCleanupWrapperPreservesWriterTo(t *testing.T) { + body := &writerToReadCloser{Reader: bytes.NewReader([]byte("hello writer-to"))} + client := newTestClient(t, "https://example.com") + client.Client = &http.Client{ + Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Body: body, + }, nil + }), + } + + _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ + Method: http.MethodGet, + URL: "https://example.com", + DontReadResponse: true, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + writerTo, ok := resp.Body.(io.WriterTo) + require.True(t, ok) + + var copied bytes.Buffer + _, err = writerTo.WriteTo(&copied) + require.NoError(t, err) + require.Equal(t, "hello writer-to", copied.String()) + require.NoError(t, resp.Body.Close()) +} + +func TestDontReadResponseWithoutRetryTriggerDoesNotWrapBody(t *testing.T) { + body := &writerToReadCloser{Reader: bytes.NewReader([]byte("hello raw body"))} + client := newTestClient(t, "https://example.com") + client.RequestRetryTrigger = nil + client.Client = &http.Client{ + Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Body: body, + }, nil + }), + } + + _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ + Method: http.MethodGet, + URL: "https://example.com", + DontReadResponse: true, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Same(t, body, resp.Body) + require.NoError(t, resp.Body.Close()) +} diff --git a/mautrix-patched/commands/container.go b/mautrix-patched/commands/container.go new file mode 100644 index 00000000..9b909b75 --- /dev/null +++ b/mautrix-patched/commands/container.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "fmt" + "slices" + "strings" + "sync" + + "go.mau.fi/util/exmaps" + + "maunium.net/go/mautrix/event/cmdschema" +) + +type CommandContainer[MetaType any] struct { + commands map[string]*Handler[MetaType] + aliases map[string]string + lock sync.RWMutex + parent *Handler[MetaType] +} + +func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] { + return &CommandContainer[MetaType]{ + commands: make(map[string]*Handler[MetaType]), + aliases: make(map[string]string), + } +} + +func (cont *CommandContainer[MetaType]) AllSpecs() []*cmdschema.EventContent { + data := make(exmaps.Set[*Handler[MetaType]]) + cont.collectHandlers(data) + specs := make([]*cmdschema.EventContent, 0, data.Size()) + for handler := range data.Iter() { + if handler.Parameters != nil { + specs = append(specs, handler.Spec()) + } + } + return specs +} + +func (cont *CommandContainer[MetaType]) collectHandlers(into exmaps.Set[*Handler[MetaType]]) { + cont.lock.RLock() + defer cont.lock.RUnlock() + for _, handler := range cont.commands { + into.Add(handler) + if handler.subcommandContainer != nil { + handler.subcommandContainer.collectHandlers(into) + } + } +} + +// Register registers the given command handlers. +func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) { + if cont == nil { + return + } + cont.lock.Lock() + defer cont.lock.Unlock() + for i, handler := range handlers { + if handler == nil { + panic(fmt.Errorf("handler #%d is nil", i+1)) + } + cont.registerOne(handler) + } +} + +func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType]) { + if strings.ToLower(handler.Name) != handler.Name { + panic(fmt.Errorf("command %q is not lowercase", handler.Name)) + } else if val, alreadyExists := cont.commands[handler.Name]; alreadyExists && val != handler { + panic(fmt.Errorf("tried to register command %q, but it's already registered", handler.Name)) + } else if aliasTarget, alreadyExists := cont.aliases[handler.Name]; alreadyExists { + panic(fmt.Errorf("tried to register command %q, but it's already registered as an alias for %q", handler.Name, aliasTarget)) + } + if !slices.Contains(handler.parents, cont.parent) { + handler.parents = append(handler.parents, cont.parent) + handler.nestedNameCache = nil + } + cont.commands[handler.Name] = handler + for _, alias := range handler.Aliases { + if strings.ToLower(alias) != alias { + panic(fmt.Errorf("alias %q is not lowercase", alias)) + } else if val, alreadyExists := cont.aliases[alias]; alreadyExists && val != handler.Name { + panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered for %q", alias, handler.Name, cont.aliases[alias])) + } else if _, alreadyExists = cont.commands[alias]; alreadyExists { + panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered as a command", alias, handler.Name)) + } + cont.aliases[alias] = handler.Name + } + handler.initSubcommandContainer() +} + +func (cont *CommandContainer[MetaType]) Unregister(handlers ...*Handler[MetaType]) { + if cont == nil { + return + } + cont.lock.Lock() + defer cont.lock.Unlock() + for _, handler := range handlers { + cont.unregisterOne(handler) + } +} + +func (cont *CommandContainer[MetaType]) unregisterOne(handler *Handler[MetaType]) { + delete(cont.commands, handler.Name) + for _, alias := range handler.Aliases { + if cont.aliases[alias] == handler.Name { + delete(cont.aliases, alias) + } + } +} + +func (cont *CommandContainer[MetaType]) GetHandler(name string) *Handler[MetaType] { + if cont == nil { + return nil + } + cont.lock.RLock() + defer cont.lock.RUnlock() + alias, ok := cont.aliases[name] + if ok { + name = alias + } + handler, ok := cont.commands[name] + if !ok { + handler = cont.commands[UnknownCommandName] + } + return handler +} diff --git a/mautrix-patched/commands/event.go b/mautrix-patched/commands/event.go new file mode 100644 index 00000000..76d6c9f0 --- /dev/null +++ b/mautrix-patched/commands/event.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +// Event contains the data of a single command event. +// It also provides some helper methods for responding to the command. +type Event[MetaType any] struct { + *event.Event + // RawInput is the entire message before splitting into command and arguments. + RawInput string + // ParentCommands is the chain of commands leading up to this command. + // This is only set if the command is a subcommand. + ParentCommands []string + ParentHandlers []*Handler[MetaType] + // Command is the lowercased first word of the message. + Command string + // Args are the rest of the message split by whitespace ([strings.Fields]). + Args []string + // RawArgs is the same as args, but without the splitting by whitespace. + RawArgs string + + StructuredArgs json.RawMessage + + Ctx context.Context + Log *zerolog.Logger + Proc *Processor[MetaType] + Handler *Handler[MetaType] + Meta MetaType + + redactedBy id.EventID +} + +var IDHTMLParser = &format.HTMLParser{ + PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { + if len(mxid) == 0 { + return displayname + } + if eventID != "" { + return fmt.Sprintf("https://matrix.to/#/%s/%s", mxid, eventID) + } + return mxid + }, + ItalicConverter: func(s string, c format.Context) string { + return fmt.Sprintf("*%s*", s) + }, + Newline: "\n", +} + +// ParseEvent parses a message into a command event struct. +func (proc *Processor[MetaType]) ParseEvent(ctx context.Context, evt *event.Event) *Event[MetaType] { + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok || content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" { + return nil + } + text := content.Body + if content.Format == event.FormatHTML { + text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) + } + if content.MSC4391BotCommand != nil { + if !content.Mentions.Has(proc.Client.UserID) || len(content.Mentions.UserIDs) != 1 { + return nil + } + wrapped := StructuredCommandToEvent[MetaType](ctx, evt, content.MSC4391BotCommand) + wrapped.RawInput = text + return wrapped + } + if len(text) == 0 { + return nil + } + return RawTextToEvent[MetaType](ctx, evt, text) +} + +func StructuredCommandToEvent[MetaType any](ctx context.Context, evt *event.Event, content *event.MSC4391BotCommandInput) *Event[MetaType] { + commandParts := strings.Split(content.Command, " ") + return &Event[MetaType]{ + Event: evt, + // Fake a command and args to let the subcommand finder in Process work. + Command: commandParts[0], + Args: commandParts[1:], + Ctx: ctx, + Log: zerolog.Ctx(ctx), + + StructuredArgs: content.Arguments, + } +} + +func RawTextToEvent[MetaType any](ctx context.Context, evt *event.Event, text string) *Event[MetaType] { + parts := strings.Fields(text) + if len(parts) == 0 { + parts = []string{""} + } + return &Event[MetaType]{ + Event: evt, + RawInput: text, + Command: strings.ToLower(parts[0]), + Args: parts[1:], + RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "), + Log: zerolog.Ctx(ctx), + Ctx: ctx, + } +} + +type ReplyOpts struct { + AllowHTML bool + AllowMarkdown bool + Reply bool + Thread bool + SendAsText bool + Edit id.EventID + OverrideMentions *event.Mentions + Extra map[string]any +} + +func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + return evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true}) +} + +func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID { + content := format.RenderMarkdown(msg, opts.AllowMarkdown, opts.AllowHTML) + if opts.Thread { + content.SetThread(evt.Event) + } + if opts.Reply { + content.SetReply(evt.Event) + } + if !opts.SendAsText { + content.MsgType = event.MsgNotice + } + if opts.Edit != "" { + content.SetEdit(opts.Edit) + } + if opts.OverrideMentions != nil { + content.Mentions = opts.OverrideMentions + } + var wrapped any = &content + if opts.Extra != nil { + wrapped = &event.Content{ + Parsed: &content, + Raw: opts.Extra, + } + } + resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, wrapped) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply") + return "" + } + return resp.EventID +} + +func (evt *Event[MetaType]) React(emoji string) id.EventID { + resp, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reaction") + return "" + } + return resp.EventID +} + +func (evt *Event[MetaType]) Redact() id.EventID { + if evt.redactedBy != "" { + return evt.redactedBy + } + resp, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command") + return "" + } + evt.redactedBy = resp.EventID + return resp.EventID +} + +func (evt *Event[MetaType]) MarkRead() { + err := evt.Proc.Client.MarkRead(evt.Ctx, evt.RoomID, evt.ID) + if err != nil { + zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send read receipt") + } +} + +// ShiftArg removes the first argument from the Args list and RawArgs data and returns it. +// RawInput will not be modified. +func (evt *Event[MetaType]) ShiftArg() string { + if len(evt.Args) == 0 { + return "" + } + firstArg := evt.Args[0] + evt.RawArgs = strings.TrimLeft(strings.TrimPrefix(evt.RawArgs, evt.Args[0]), " ") + evt.Args = evt.Args[1:] + return firstArg +} + +// UnshiftArg reverses ShiftArg by adding the given value to the beginning of the Args list and RawArgs data. +func (evt *Event[MetaType]) UnshiftArg(arg string) { + evt.RawArgs = arg + " " + evt.RawArgs + evt.Args = append([]string{arg}, evt.Args...) +} + +func (evt *Event[MetaType]) ParseArgs(into any) error { + return json.Unmarshal(evt.StructuredArgs, into) +} + +func ParseArgs[T, MetaType any](evt *Event[MetaType]) (into T, err error) { + err = evt.ParseArgs(&into) + return +} + +func WithParsedArgs[T, MetaType any](fn func(*Event[MetaType], T)) func(*Event[MetaType]) { + return func(evt *Event[MetaType]) { + parsed, err := ParseArgs[T, MetaType](evt) + if err != nil { + evt.Log.Debug().Err(err).Msg("Failed to parse structured args into struct") + // TODO better error, usage info? deduplicate with Process + evt.Reply("Failed to parse arguments: %v", err) + return + } + fn(evt, parsed) + } +} diff --git a/mautrix-patched/commands/handler.go b/mautrix-patched/commands/handler.go new file mode 100644 index 00000000..56f27f06 --- /dev/null +++ b/mautrix-patched/commands/handler.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "strings" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/event/cmdschema" +) + +type Handler[MetaType any] struct { + // Func is the function that is called when the command is executed. + Func func(ce *Event[MetaType]) + + // Name is the primary name of the command. It must be lowercase. + Name string + // Aliases are alternative names for the command. They must be lowercase. + Aliases []string + // Subcommands are subcommands of this command. + Subcommands []*Handler[MetaType] + // PreFunc is a function that is called before checking subcommands. + // It can be used to have parameters between subcommands (e.g. `!rooms `). + // Event.ShiftArg will likely be useful for implementing such parameters. + PreFunc func(ce *Event[MetaType]) + + // Description is a short description of the command. + Description *event.ExtensibleTextContainer + // Parameters is a description of structured command parameters. + // If set, the StructuredArgs field of Event will be populated. + Parameters []*cmdschema.Parameter + TailParam string + + parents []*Handler[MetaType] + nestedNameCache []string + subcommandContainer *CommandContainer[MetaType] +} + +func (h *Handler[MetaType]) NestedNames() []string { + if h.nestedNameCache != nil { + return h.nestedNameCache + } + nestedNames := make([]string, 0, (1+len(h.Aliases))*len(h.parents)) + for _, parent := range h.parents { + if parent == nil { + nestedNames = append(nestedNames, h.Name) + nestedNames = append(nestedNames, h.Aliases...) + } else { + for _, parentName := range parent.NestedNames() { + nestedNames = append(nestedNames, parentName+" "+h.Name) + for _, alias := range h.Aliases { + nestedNames = append(nestedNames, parentName+" "+alias) + } + } + } + } + h.nestedNameCache = nestedNames + return nestedNames +} + +func (h *Handler[MetaType]) Spec() *cmdschema.EventContent { + names := h.NestedNames() + return &cmdschema.EventContent{ + Command: names[0], + Aliases: names[1:], + Parameters: h.Parameters, + Description: h.Description, + TailParam: h.TailParam, + } +} + +func (h *Handler[MetaType]) CopyFrom(other *Handler[MetaType]) { + if h.Parameters == nil { + h.Parameters = other.Parameters + h.TailParam = other.TailParam + } + h.Func = other.Func +} + +func (h *Handler[MetaType]) initSubcommandContainer() { + if len(h.Subcommands) > 0 { + h.subcommandContainer = NewCommandContainer[MetaType]() + h.subcommandContainer.parent = h + h.subcommandContainer.Register(h.Subcommands...) + } else { + h.subcommandContainer = nil + } +} + +func MakeUnknownCommandHandler[MetaType any](prefix string) *Handler[MetaType] { + return &Handler[MetaType]{ + Name: UnknownCommandName, + Func: func(ce *Event[MetaType]) { + if len(ce.ParentCommands) == 0 { + ce.Reply("Unknown command `%s%s`", prefix, ce.Command) + } else { + ce.Reply("Unknown subcommand `%s%s %s`", prefix, strings.Join(ce.ParentCommands, " "), ce.Command) + } + }, + } +} diff --git a/mautrix-patched/commands/prevalidate.go b/mautrix-patched/commands/prevalidate.go new file mode 100644 index 00000000..facca4da --- /dev/null +++ b/mautrix-patched/commands/prevalidate.go @@ -0,0 +1,84 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "strings" +) + +// A PreValidator contains a function that takes an Event and returns true if the event should be processed further. +// +// The [PreValidator] field in [Processor] is called before the handler of the command is checked. +// It can be used to modify the command or arguments, or to skip the command entirely. +// +// The primary use case is removing a static command prefix, such as requiring all commands start with `!`. +type PreValidator[MetaType any] interface { + Validate(*Event[MetaType]) bool +} + +// FuncPreValidator is a simple function that implements the PreValidator interface. +type FuncPreValidator[MetaType any] func(*Event[MetaType]) bool + +func (f FuncPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { + return f(ce) +} + +// AllPreValidator can be used to combine multiple PreValidators, such that +// all of them must return true for the command to be processed further. +type AllPreValidator[MetaType any] []PreValidator[MetaType] + +func (f AllPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { + for _, validator := range f { + if !validator.Validate(ce) { + return false + } + } + return true +} + +// AnyPreValidator can be used to combine multiple PreValidators, such that +// at least one of them must return true for the command to be processed further. +type AnyPreValidator[MetaType any] []PreValidator[MetaType] + +func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { + for _, validator := range f { + if validator.Validate(ce) { + return true + } + } + return false +} + +// ValidatePrefixCommand checks that the first word in the input is exactly the given string, +// and if so, removes it from the command and sets the command to the next word. +// +// For example, `ValidateCommandPrefix("!mybot")` would only allow commands in the form `!mybot foo`, +// where `foo` would be used to look up the command handler. +func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] { + return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { + if ce.Command == prefix && len(ce.Args) > 0 { + ce.Command = strings.ToLower(ce.ShiftArg()) + return true + } + return false + }) +} + +// ValidatePrefixSubstring checks that the command starts with the given prefix, +// and if so, removes it from the command. +// +// For example, `ValidatePrefixSubstring("!")` would only allow commands in the form `!foo`, +// where `foo` would be used to look up the command handler. +func ValidatePrefixSubstring[MetaType any](prefix string) PreValidator[MetaType] { + return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { + if strings.HasPrefix(ce.Command, prefix) { + ce.Command = ce.Command[len(prefix):] + return true + } + return false + }) +} diff --git a/mautrix-patched/commands/processor.go b/mautrix-patched/commands/processor.go new file mode 100644 index 00000000..80f6745d --- /dev/null +++ b/mautrix-patched/commands/processor.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "runtime/debug" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// Processor implements boilerplate code for splitting messages into a command and arguments, +// and finding the appropriate handler for the command. +type Processor[MetaType any] struct { + *CommandContainer[MetaType] + + Client *mautrix.Client + LogArgs bool + PreValidator PreValidator[MetaType] + Meta MetaType + + ReactionCommandPrefix string +} + +// UnknownCommandName is the name of the fallback handler which is used if no other handler is found. +// If even the unknown command handler is not found, the command is ignored. +const UnknownCommandName = "__unknown-command__" + +func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { + proc := &Processor[MetaType]{ + CommandContainer: NewCommandContainer[MetaType](), + Client: cli, + PreValidator: ValidatePrefixSubstring[MetaType]("!"), + } + proc.Register(MakeUnknownCommandHandler[MetaType]("!")) + return proc +} + +func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) { + log := zerolog.Ctx(ctx).With(). + Stringer("sender", evt.Sender). + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.ID). + Logger() + defer func() { + panicErr := recover() + if panicErr != nil { + logEvt := log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()) + if realErr, ok := panicErr.(error); ok { + logEvt = logEvt.Err(realErr) + } else { + logEvt = logEvt.Any(zerolog.ErrorFieldName, panicErr) + } + logEvt.Msg("Panic in command handler") + _, err := proc.Client.SendReaction(ctx, evt.RoomID, evt.ID, "💥") + if err != nil { + log.Err(err).Msg("Failed to send reaction after panic") + } + } + }() + var parsed *Event[MetaType] + switch evt.Type { + case event.EventReaction: + parsed = proc.ParseReaction(ctx, evt) + case event.EventMessage: + parsed = proc.ParseEvent(ctx, evt) + } + if parsed == nil || (!proc.PreValidator.Validate(parsed) && parsed.StructuredArgs == nil) { + return + } + parsed.Proc = proc + parsed.Meta = proc.Meta + parsed.Ctx = ctx + + handler := proc.GetHandler(parsed.Command) + if handler == nil { + return + } + parsed.Handler = handler + if handler.PreFunc != nil { + handler.PreFunc(parsed) + } + handlerChain := zerolog.Arr() + handlerChain.Str(handler.Name) + for handler.subcommandContainer != nil && len(parsed.Args) > 0 { + subHandler := handler.subcommandContainer.GetHandler(strings.ToLower(parsed.Args[0])) + if subHandler != nil { + parsed.ParentCommands = append(parsed.ParentCommands, parsed.Command) + parsed.ParentHandlers = append(parsed.ParentHandlers, handler) + handler = subHandler + handlerChain.Str(subHandler.Name) + parsed.Command = strings.ToLower(parsed.ShiftArg()) + parsed.Handler = subHandler + if subHandler.PreFunc != nil { + subHandler.PreFunc(parsed) + } + } else { + break + } + } + if parsed.StructuredArgs != nil && len(parsed.Args) > 0 { + // TODO allow unknown command handlers to be called? + // The client sent MSC4391 data, but the target command wasn't found + log.Debug().Msg("Didn't find handler for MSC4391 command") + return + } + + logWith := log.With(). + Str("command", parsed.Command). + Array("handler", handlerChain) + if len(parsed.ParentCommands) > 0 { + logWith = logWith.Strs("parent_commands", parsed.ParentCommands) + } + if proc.LogArgs { + logWith = logWith.Strs("args", parsed.Args) + if parsed.StructuredArgs != nil { + logWith = logWith.RawJSON("structured_args", parsed.StructuredArgs) + } + } + log = logWith.Logger() + parsed.Ctx = log.WithContext(ctx) + parsed.Log = &log + + if handler.Parameters != nil && parsed.StructuredArgs == nil { + // The handler wants structured parameters, but the client didn't send MSC4391 data + var err error + parsed.StructuredArgs, err = handler.Spec().ParseArguments(parsed.RawArgs) + if err != nil { + log.Debug().Err(err).Msg("Failed to parse structured arguments") + // TODO better error, usage info? deduplicate with WithParsedArgs + parsed.Reply("Failed to parse arguments: %v", err) + return + } + if proc.LogArgs { + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.RawJSON("structured_args", parsed.StructuredArgs) + }) + } + } + + log.Debug().Msg("Processing command") + handler.Func(parsed) +} diff --git a/mautrix-patched/commands/reactions.go b/mautrix-patched/commands/reactions.go new file mode 100644 index 00000000..0d316219 --- /dev/null +++ b/mautrix-patched/commands/reactions.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "context" + "encoding/json" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +const ReactionCommandsKey = "fi.mau.reaction_commands" +const ReactionMultiUseKey = "fi.mau.reaction_multi_use" + +type ReactionCommandData struct { + Command string `json:"command"` + Args any `json:"args,omitempty"` +} + +func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] { + content, ok := evt.Content.Parsed.(*event.ReactionEventContent) + if !ok { + return nil + } + evtID := content.RelatesTo.EventID + if evtID == "" || !strings.HasPrefix(content.RelatesTo.Key, proc.ReactionCommandPrefix) { + return nil + } + targetEvt, err := proc.Client.GetEvent(ctx, evt.RoomID, evtID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", evtID).Msg("Failed to get target event for reaction") + return nil + } else if targetEvt.Sender != proc.Client.UserID || targetEvt.Unsigned.RedactedBecause != nil { + return nil + } + if targetEvt.Type == event.EventEncrypted { + if proc.Client.Crypto == nil { + zerolog.Ctx(ctx).Warn(). + Stringer("target_event_id", evtID). + Msg("Received reaction to encrypted event, but don't have crypto helper in client") + return nil + } + _ = targetEvt.Content.ParseRaw(targetEvt.Type) + targetEvt, err = proc.Client.Crypto.Decrypt(ctx, targetEvt) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("target_event_id", evtID). + Msg("Failed to decrypt target event for reaction") + return nil + } + } + reactionCommands, ok := targetEvt.Content.Raw[ReactionCommandsKey].(map[string]any) + if !ok { + zerolog.Ctx(ctx).Trace(). + Stringer("target_event_id", evtID). + Msg("Reaction target event doesn't have commands key") + return nil + } + isMultiUse, _ := targetEvt.Content.Raw[ReactionMultiUseKey].(bool) + rawCmd, ok := reactionCommands[content.RelatesTo.Key] + if !ok { + zerolog.Ctx(ctx).Debug(). + Stringer("target_event_id", evtID). + Str("reaction_key", content.RelatesTo.Key). + Msg("Reaction command not found in target event") + return nil + } + var wrappedEvt *Event[MetaType] + switch typedCmd := rawCmd.(type) { + case string: + wrappedEvt = RawTextToEvent[MetaType](ctx, evt, typedCmd) + case map[string]any: + var input event.MSC4391BotCommandInput + if marshaled, err := json.Marshal(typedCmd); err != nil { + + } else if err = json.Unmarshal(marshaled, &input); err != nil { + + } else { + wrappedEvt = StructuredCommandToEvent[MetaType](ctx, evt, &input) + } + } + if wrappedEvt == nil { + zerolog.Ctx(ctx).Debug(). + Stringer("target_event_id", evtID). + Str("reaction_key", content.RelatesTo.Key). + Msg("Reaction command data is invalid") + return nil + } + wrappedEvt.Proc = proc + wrappedEvt.Redact() + if !isMultiUse { + DeleteAllReactions(ctx, proc.Client, evt) + } + if wrappedEvt.Command == "" { + return nil + } + return wrappedEvt +} + +func DeleteAllReactionsCommandFunc[MetaType any](ce *Event[MetaType]) { + DeleteAllReactions(ce.Ctx, ce.Proc.Client, ce.Event) +} + +func DeleteAllReactions(ctx context.Context, client *mautrix.Client, evt *event.Event) { + rel, ok := evt.Content.Parsed.(event.Relatable) + if !ok { + return + } + relation := rel.OptionalGetRelatesTo() + if relation == nil { + return + } + targetEvt := relation.GetReplyTo() + if targetEvt == "" { + targetEvt = relation.GetAnnotationID() + } + if targetEvt == "" { + return + } + relations, err := client.GetRelations(ctx, evt.RoomID, targetEvt, &mautrix.ReqGetRelations{ + RelationType: event.RelAnnotation, + EventType: event.EventReaction, + Limit: 20, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get reactions to delete") + return + } + for _, relEvt := range relations.Chunk { + _, err = client.RedactEvent(ctx, relEvt.RoomID, relEvt.ID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("event_id", relEvt.ID).Msg("Failed to redact reaction event") + } + } +} diff --git a/mautrix-patched/crypto/account.go b/mautrix-patched/crypto/account.go new file mode 100644 index 00000000..cc037edb --- /dev/null +++ b/mautrix-patched/crypto/account.go @@ -0,0 +1,128 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "fmt" + + "github.com/tidwall/sjson" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/id" +) + +type OlmAccount struct { + Internal olm.Account + signingKey id.SigningKey + identityKey id.IdentityKey + Shared bool + KeyBackupVersion id.KeyBackupVersion +} + +func NewOlmAccount() *OlmAccount { + account, err := olm.NewAccount() + if err != nil { + panic(err) + } + return &OlmAccount{ + Internal: account, + } +} + +func (account *OlmAccount) Keys() (id.SigningKey, id.IdentityKey) { + if len(account.signingKey) == 0 || len(account.identityKey) == 0 { + var err error + account.signingKey, account.identityKey, err = account.Internal.IdentityKeys() + if err != nil { + panic(err) + } + } + return account.signingKey, account.identityKey +} + +func (account *OlmAccount) SigningKey() id.SigningKey { + if len(account.signingKey) == 0 { + var err error + account.signingKey, account.identityKey, err = account.Internal.IdentityKeys() + if err != nil { + panic(err) + } + } + return account.signingKey +} + +func (account *OlmAccount) IdentityKey() id.IdentityKey { + if len(account.identityKey) == 0 { + var err error + account.signingKey, account.identityKey, err = account.Internal.IdentityKeys() + if err != nil { + panic(err) + } + } + return account.identityKey +} + +// SignJSON signs the given JSON object following the Matrix specification: +// https://matrix.org/docs/spec/appendices#signing-json +func (account *OlmAccount) SignJSON(obj any) (string, error) { + objJSON, err := canonicaljson.Marshal(obj) + if err != nil { + return "", err + } + objJSON, _ = sjson.DeleteBytes(objJSON, "unsigned") + objJSON, _ = sjson.DeleteBytes(objJSON, "signatures") + // This is probably not necessary + err = canonicaljson.Canonicalize(&objJSON) + if err != nil { + return "", fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) + } + signed, err := account.Internal.Sign(objJSON) + return string(signed), err +} + +func (account *OlmAccount) getInitialKeys(userID id.UserID, deviceID id.DeviceID) *mautrix.DeviceKeys { + deviceKeys := &mautrix.DeviceKeys{ + UserID: userID, + DeviceID: deviceID, + Algorithms: []id.Algorithm{id.AlgorithmMegolmV1, id.AlgorithmOlmV1}, + Keys: map[id.DeviceKeyID]string{ + id.NewDeviceKeyID(id.KeyAlgorithmCurve25519, deviceID): string(account.IdentityKey()), + id.NewDeviceKeyID(id.KeyAlgorithmEd25519, deviceID): string(account.SigningKey()), + }, + } + + signature, err := account.SignJSON(deviceKeys) + if err != nil { + panic(err) + } + + deviceKeys.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, deviceID.String(), signature) + return deviceKeys +} + +func (account *OlmAccount) getOneTimeKeys(userID id.UserID, deviceID id.DeviceID, currentOTKCount int) map[id.KeyID]mautrix.OneTimeKey { + newCount := int(account.Internal.MaxNumberOfOneTimeKeys()/2) - currentOTKCount + if newCount > 0 { + account.Internal.GenOneTimeKeys(uint(newCount)) + } + oneTimeKeys := make(map[id.KeyID]mautrix.OneTimeKey) + internalKeys, err := account.Internal.OneTimeKeys() + if err != nil { + panic(err) + } + for keyID, key := range internalKeys { + key := mautrix.OneTimeKey{Key: key} + signature, _ := account.SignJSON(key) + key.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, deviceID.String(), signature) + key.IsSigned = true + oneTimeKeys[id.NewKeyID(id.KeyAlgorithmSignedCurve25519, keyID)] = key + } + return oneTimeKeys +} diff --git a/mautrix-patched/crypto/aescbc/aes_cbc.go b/mautrix-patched/crypto/aescbc/aes_cbc.go new file mode 100644 index 00000000..e0381635 --- /dev/null +++ b/mautrix-patched/crypto/aescbc/aes_cbc.go @@ -0,0 +1,59 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package aescbc + +import ( + "crypto/aes" + "crypto/cipher" + + "go.mau.fi/util/pkcs7" +) + +// Encrypt encrypts the plaintext with the key and IV. The IV length must be +// equal to the AES block size. +func Encrypt(key, iv, plaintext []byte) ([]byte, error) { + if len(key) == 0 { + return nil, ErrNoKeyProvided + } + if len(iv) != aes.BlockSize { + return nil, ErrIVNotBlockSize + } + // This will copy the input to ensure the CryptBlocks doesn't mutate it later + plaintext = pkcs7.Pad(plaintext, aes.BlockSize) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + cipher.NewCBCEncrypter(block, iv).CryptBlocks(plaintext, plaintext) + return plaintext, nil +} + +// Decrypt decrypts the ciphertext with the key and IV. The IV length must be +// equal to the block size. +// +// This function mutates the ciphertext. +func Decrypt(key, iv, ciphertext []byte) ([]byte, error) { + if len(key) == 0 { + return nil, ErrNoKeyProvided + } + if len(iv) != aes.BlockSize { + return nil, ErrIVNotBlockSize + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, ErrNotMultipleBlockSize + } + + cipher.NewCBCDecrypter(block, iv).CryptBlocks(ciphertext, ciphertext) + return pkcs7.Unpad(ciphertext) +} diff --git a/mautrix-patched/crypto/aescbc/aes_cbc_test.go b/mautrix-patched/crypto/aescbc/aes_cbc_test.go new file mode 100644 index 00000000..d6611dc9 --- /dev/null +++ b/mautrix-patched/crypto/aescbc/aes_cbc_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package aescbc_test + +import ( + "crypto/aes" + "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/aescbc" +) + +func TestAESCBC(t *testing.T) { + var ciphertext, plaintext []byte + var err error + + // The key length can be 32, 24, 16 bytes (OR in bits: 128, 192 or 256) + key := make([]byte, 32) + _, err = rand.Read(key) + require.NoError(t, err) + iv := make([]byte, aes.BlockSize) + _, err = rand.Read(iv) + require.NoError(t, err) + plaintext = []byte("secret message for testing") + //increase to next block size + for len(plaintext)%8 != 0 { + plaintext = append(plaintext, []byte("-")...) + } + + ciphertext, err = aescbc.Encrypt(key, iv, plaintext) + require.NoError(t, err) + + resultPlainText, err := aescbc.Decrypt(key, iv, ciphertext) + require.NoError(t, err) + + assert.Equal(t, string(resultPlainText), string(plaintext)) +} + +func TestAESCBCCase1(t *testing.T) { + expected := []byte{ + 0xDC, 0x95, 0xC0, 0x78, 0xA2, 0x40, 0x89, 0x89, + 0xAD, 0x48, 0xA2, 0x14, 0x92, 0x84, 0x20, 0x87, + 0xF3, 0xC0, 0x03, 0xDD, 0xC4, 0xA7, 0xB8, 0xA9, + 0x4B, 0xAE, 0xDF, 0xFC, 0x3D, 0x21, 0x4C, 0x38, + } + input := make([]byte, 16) + key := make([]byte, 32) + iv := make([]byte, aes.BlockSize) + encrypted, err := aescbc.Encrypt(key, iv, input) + require.NoError(t, err) + assert.Equal(t, expected, encrypted, "encrypted output does not match expected") + + decrypted, err := aescbc.Decrypt(key, iv, encrypted) + require.NoError(t, err) + assert.Equal(t, input, decrypted, "decrypted output does not match input") +} diff --git a/mautrix-patched/crypto/aescbc/errors.go b/mautrix-patched/crypto/aescbc/errors.go new file mode 100644 index 00000000..f3d2d7ce --- /dev/null +++ b/mautrix-patched/crypto/aescbc/errors.go @@ -0,0 +1,15 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package aescbc + +import "errors" + +var ( + ErrNoKeyProvided = errors.New("no key") + ErrIVNotBlockSize = errors.New("IV length does not match AES block size") + ErrNotMultipleBlockSize = errors.New("ciphertext length is not a multiple of the AES block size") +) diff --git a/mautrix-patched/crypto/attachment/attachments.go b/mautrix-patched/crypto/attachment/attachments.go new file mode 100644 index 00000000..727aacbf --- /dev/null +++ b/mautrix-patched/crypto/attachment/attachments.go @@ -0,0 +1,317 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package attachment + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "hash" + "io" + + "maunium.net/go/mautrix/crypto/utils" +) + +var ( + ErrHashMismatch = errors.New("mismatching SHA-256 digest") + ErrUnsupportedVersion = errors.New("unsupported Matrix file encryption version") + ErrUnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm") + ErrInvalidKey = errors.New("failed to decode key") + ErrInvalidInitVector = errors.New("failed to decode initialization vector") + ErrInvalidHash = errors.New("failed to decode SHA-256 hash") + ErrReaderClosed = errors.New("encrypting reader was already closed") +) + +// Deprecated: use variables prefixed with Err +var ( + HashMismatch = ErrHashMismatch + UnsupportedVersion = ErrUnsupportedVersion + UnsupportedAlgorithm = ErrUnsupportedAlgorithm + InvalidKey = ErrInvalidKey + InvalidInitVector = ErrInvalidInitVector + InvalidHash = ErrInvalidHash + ReaderClosed = ErrReaderClosed +) + +var ( + keyBase64Length = base64.RawURLEncoding.EncodedLen(utils.AESCTRKeyLength) + ivBase64Length = base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength) + hashBase64Length = base64.RawStdEncoding.EncodedLen(utils.SHAHashLength) +) + +type JSONWebKey struct { + Key string `json:"k"` + Algorithm string `json:"alg"` + Extractable bool `json:"ext"` + KeyType string `json:"kty"` + KeyOps []string `json:"key_ops"` +} + +type EncryptedFileHashes struct { + SHA256 string `json:"sha256"` +} + +type decodedKeys struct { + key [utils.AESCTRKeyLength]byte + iv [utils.AESCTRIVLength]byte + + sha256 [utils.SHAHashLength]byte +} + +type EncryptedFile struct { + Key JSONWebKey `json:"key"` + InitVector string `json:"iv"` + Hashes EncryptedFileHashes `json:"hashes"` + Version string `json:"v"` + + decoded *decodedKeys +} + +func NewEncryptedFile() *EncryptedFile { + key, iv := utils.GenAttachmentA256CTR() + return &EncryptedFile{ + Key: JSONWebKey{ + Key: base64.RawURLEncoding.EncodeToString(key[:]), + Algorithm: "A256CTR", + Extractable: true, + KeyType: "oct", + KeyOps: []string{"encrypt", "decrypt"}, + }, + InitVector: base64.RawStdEncoding.EncodeToString(iv[:]), + Version: "v2", + + decoded: &decodedKeys{key: key, iv: iv}, + } +} + +func (ef *EncryptedFile) decodeKeys(includeHash bool) error { + if ef.decoded != nil { + return nil + } else if len(ef.Key.Key) != keyBase64Length { + return ErrInvalidKey + } else if len(ef.InitVector) != ivBase64Length { + return ErrInvalidInitVector + } else if includeHash && len(ef.Hashes.SHA256) != hashBase64Length { + return ErrInvalidHash + } + ef.decoded = &decodedKeys{} + _, err := base64.RawURLEncoding.Decode(ef.decoded.key[:], []byte(ef.Key.Key)) + if err != nil { + return ErrInvalidKey + } + _, err = base64.RawStdEncoding.Decode(ef.decoded.iv[:], []byte(ef.InitVector)) + if err != nil { + return ErrInvalidInitVector + } + if includeHash { + _, err = base64.RawStdEncoding.Decode(ef.decoded.sha256[:], []byte(ef.Hashes.SHA256)) + if err != nil { + return ErrInvalidHash + } + } + return nil +} + +// Encrypt encrypts the given data, updates the SHA256 hash in the EncryptedFile struct and returns the ciphertext. +// +// Deprecated: this makes a copy for the ciphertext, which means 2x memory usage. EncryptInPlace is recommended. +func (ef *EncryptedFile) Encrypt(plaintext []byte) []byte { + ciphertext := make([]byte, len(plaintext)) + copy(ciphertext, plaintext) + ef.EncryptInPlace(ciphertext) + return ciphertext +} + +// EncryptInPlace encrypts the given data in-place (i.e. the provided data is overridden with the ciphertext) +// and updates the SHA256 hash in the EncryptedFile struct. +func (ef *EncryptedFile) EncryptInPlace(data []byte) { + ef.decodeKeys(false) + utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) + checksum := sha256.Sum256(data) + ef.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(checksum[:]) +} + +type ReadWriterAt interface { + io.WriterAt + io.Reader +} + +// EncryptFile encrypts the given file in-place and updates the SHA256 hash in the EncryptedFile struct. +func (ef *EncryptedFile) EncryptFile(file ReadWriterAt) error { + err := ef.decodeKeys(false) + if err != nil { + return err + } + block, _ := aes.NewCipher(ef.decoded.key[:]) + stream := cipher.NewCTR(block, ef.decoded.iv[:]) + hasher := sha256.New() + buf := make([]byte, 32*1024) + var writePtr int64 + var n int + for { + n, err = file.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + if n == 0 { + break + } + stream.XORKeyStream(buf[:n], buf[:n]) + _, err = file.WriteAt(buf[:n], writePtr) + if err != nil { + return err + } + writePtr += int64(n) + hasher.Write(buf[:n]) + } + ef.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(hasher.Sum(nil)) + return nil +} + +type encryptingReader struct { + stream cipher.Stream + hash hash.Hash + source io.Reader + file *EncryptedFile + closed bool + + isDecrypting bool +} + +var _ io.ReadSeekCloser = (*encryptingReader)(nil) + +func (r *encryptingReader) Seek(offset int64, whence int) (int64, error) { + if r.closed { + return 0, ErrReaderClosed + } + if offset != 0 || whence != io.SeekStart { + return 0, fmt.Errorf("attachments.EncryptStream: only seeking to the beginning is supported") + } + seeker, ok := r.source.(io.ReadSeeker) + if !ok { + return 0, fmt.Errorf("attachments.EncryptStream: source reader (%T) is not an io.ReadSeeker", r.source) + } + n, err := seeker.Seek(offset, whence) + if err != nil { + return 0, err + } + block, _ := aes.NewCipher(r.file.decoded.key[:]) + r.stream = cipher.NewCTR(block, r.file.decoded.iv[:]) + r.hash.Reset() + return n, nil +} + +func (r *encryptingReader) Read(dst []byte) (n int, err error) { + if r.closed { + return 0, ErrReaderClosed + } else if r.isDecrypting && r.file.decoded == nil { + if err = r.file.PrepareForDecryption(); err != nil { + return + } + } + n, err = r.source.Read(dst) + if r.isDecrypting { + r.hash.Write(dst[:n]) + } + r.stream.XORKeyStream(dst[:n], dst[:n]) + if !r.isDecrypting { + r.hash.Write(dst[:n]) + } + return +} + +func (r *encryptingReader) Close() (err error) { + closer, ok := r.source.(io.ReadCloser) + if ok { + err = closer.Close() + } + if r.isDecrypting { + if !hmac.Equal(r.hash.Sum(nil), r.file.decoded.sha256[:]) { + return ErrHashMismatch + } + } else { + r.file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(r.hash.Sum(nil)) + } + r.closed = true + return +} + +// EncryptStream wraps the given io.Reader in order to encrypt the data. +// +// The Close() method of the returned io.ReadCloser must be called for the SHA256 hash +// in the EncryptedFile struct to be updated. The metadata is not valid before the hash +// is filled. +func (ef *EncryptedFile) EncryptStream(reader io.Reader) io.ReadSeekCloser { + ef.decodeKeys(false) + block, _ := aes.NewCipher(ef.decoded.key[:]) + return &encryptingReader{ + stream: cipher.NewCTR(block, ef.decoded.iv[:]), + hash: sha256.New(), + source: reader, + file: ef, + } +} + +// Decrypt decrypts the given data and returns the plaintext. +// +// Deprecated: this makes a copy for the plaintext data, which means 2x memory usage. DecryptInPlace is recommended. +func (ef *EncryptedFile) Decrypt(ciphertext []byte) ([]byte, error) { + plaintext := make([]byte, len(ciphertext)) + copy(plaintext, ciphertext) + return plaintext, ef.DecryptInPlace(plaintext) +} + +// PrepareForDecryption checks that the version and algorithm are supported and decodes the base64 keys +// +// DecryptStream will call this with the first Read() call if this hasn't been called manually. +// +// DecryptInPlace will always call this automatically, so calling this manually is not necessary when using that function. +func (ef *EncryptedFile) PrepareForDecryption() error { + if ef.Version != "v2" { + return ErrUnsupportedVersion + } else if ef.Key.Algorithm != "A256CTR" { + return ErrUnsupportedAlgorithm + } else if err := ef.decodeKeys(true); err != nil { + return err + } + return nil +} + +// DecryptInPlace decrypts the given data in-place (i.e. the provided data is overridden with the plaintext). +func (ef *EncryptedFile) DecryptInPlace(data []byte) error { + if err := ef.PrepareForDecryption(); err != nil { + return err + } + dataHash := sha256.Sum256(data) + if !hmac.Equal(ef.decoded.sha256[:], dataHash[:]) { + return ErrHashMismatch + } + utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) + return nil +} + +// DecryptStream wraps the given io.Reader in order to decrypt the data. +// +// The first Read call will check the algorithm and decode keys, so it might return an error before actually reading anything. +// If you want to validate the file before opening the stream, call PrepareForDecryption manually and check for errors. +// +// The Close call will validate the hash and return an error if it doesn't match. +// In this case, the written data should be considered compromised and should not be used further. +func (ef *EncryptedFile) DecryptStream(reader io.Reader) io.ReadSeekCloser { + block, _ := aes.NewCipher(ef.decoded.key[:]) + return &encryptingReader{ + isDecrypting: true, + stream: cipher.NewCTR(block, ef.decoded.iv[:]), + hash: sha256.New(), + source: reader, + file: ef, + } +} diff --git a/mautrix-patched/crypto/attachment/attachments_test.go b/mautrix-patched/crypto/attachment/attachments_test.go new file mode 100644 index 00000000..9fe929ab --- /dev/null +++ b/mautrix-patched/crypto/attachment/attachments_test.go @@ -0,0 +1,85 @@ +package attachment + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +const helloWorldCiphertext = ":6\xc7O1yR\x06\xe8\xcf]" +const helloWorldRawFile = `{ + "v": "v2", + "key": { + "kty": "oct", + "alg": "A256CTR", + "ext": true, + "k": "35XNdmWKOpn6UYS82Y83wEY8LagwQZHX2X0kAFW7sdg", + "key_ops": [ + "encrypt", + "decrypt" + ] + }, + "iv": "DOtPz8bC3qgAAAAAAAAAAA", + "hashes": { + "sha256": "rO+040ZhUxbpbmIS9GUuMSen4NPKFxMzqOUJeemM8mk" + } +}` +const random32Bytes = "\x85\xb4\x16/\xcaO\x1d\xe6\x7f\x95\xeb\xdb+g\x11\xb1\x81\x1a\xafY\x00\x1dq!h{\x81F\xaa\xd7A\x00" + +func parseHelloWorld() *EncryptedFile { + file := &EncryptedFile{} + _ = json.Unmarshal([]byte(helloWorldRawFile), file) + return file +} + +func TestDecryptHelloWorld(t *testing.T) { + file := parseHelloWorld() + data := []byte(helloWorldCiphertext) + err := file.DecryptInPlace(data) + assert.NoError(t, err, "failed to decrypt file") + assert.Equal(t, "hello world", string(data), "unexpected decrypt output") +} + +func TestEncryptHelloWorld(t *testing.T) { + file := parseHelloWorld() + data := []byte("hello world") + file.EncryptInPlace(data) + assert.Equal(t, helloWorldCiphertext, string(data), "unexpected encrypt output") +} + +func TestUnsupportedVersion(t *testing.T) { + file := parseHelloWorld() + file.Version = "foo" + err := file.DecryptInPlace([]byte(helloWorldCiphertext)) + assert.ErrorIs(t, err, ErrUnsupportedVersion) +} + +func TestUnsupportedAlgorithm(t *testing.T) { + file := parseHelloWorld() + file.Key.Algorithm = "bar" + err := file.DecryptInPlace([]byte(helloWorldCiphertext)) + assert.ErrorIs(t, err, ErrUnsupportedAlgorithm) +} + +func TestHashMismatch(t *testing.T) { + file := parseHelloWorld() + file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString([]byte(random32Bytes)) + err := file.DecryptInPlace([]byte(helloWorldCiphertext)) + assert.ErrorIs(t, err, ErrHashMismatch) +} + +func TestTooLongHash(t *testing.T) { + file := parseHelloWorld() + file.Hashes.SHA256 = "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVlciBhZGlwaXNjaW5nIGVsaXQuIFNlZCBwb3N1ZXJlIGludGVyZHVtIHNlbS4gUXVpc3F1ZSBsaWd1bGEgZXJvcyB1bGxhbWNvcnBlciBxdWlzLCBsYWNpbmlhIHF1aXMgZmFjaWxpc2lzIHNlZCBzYXBpZW4uCg" + err := file.DecryptInPlace([]byte(helloWorldCiphertext)) + assert.ErrorIs(t, err, ErrInvalidHash) +} + +func TestTooShortHash(t *testing.T) { + file := parseHelloWorld() + file.Hashes.SHA256 = "5/Gy1JftyyQ" + err := file.DecryptInPlace([]byte(helloWorldCiphertext)) + assert.ErrorIs(t, err, ErrInvalidHash) +} diff --git a/mautrix-patched/crypto/backup/encryptedsessiondata.go b/mautrix-patched/crypto/backup/encryptedsessiondata.go new file mode 100644 index 00000000..dcd5708d --- /dev/null +++ b/mautrix-patched/crypto/backup/encryptedsessiondata.go @@ -0,0 +1,134 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package backup + +import ( + "crypto/ecdh" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "errors" + + "go.mau.fi/util/jsonbytes" + "golang.org/x/crypto/hkdf" + + "maunium.net/go/mautrix/crypto/aescbc" +) + +var ErrInvalidMAC = errors.New("invalid MAC") + +// EncryptedSessionData is the encrypted session_data field of a key backup as +// defined in [Section 11.12.3.2.2 of the Spec]. +// +// The type parameter T represents the format of the session data contained in +// the encrypted payload. +// +// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 +type EncryptedSessionData[T any] struct { + Ciphertext jsonbytes.UnpaddedBytes `json:"ciphertext"` + Ephemeral EphemeralKey `json:"ephemeral"` + MAC jsonbytes.UnpaddedBytes `json:"mac"` +} + +func calculateEncryptionParameters(sharedSecret []byte) (key, macKey, iv []byte, err error) { + hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, nil) + encryptionParams := make([]byte, 80) + _, err = hkdfReader.Read(encryptionParams) + if err != nil { + return nil, nil, nil, err + } + + return encryptionParams[:32], encryptionParams[32:64], encryptionParams[64:], nil +} + +// calculateCompatMAC calculates the MAC as described in step 5 of according to +// [Section 11.12.3.2.2] of the Spec which was updated in spec version 1.10 to +// reflect what is actually implemented in libolm and Vodozemac. +// +// Libolm implemented the MAC functionality incorrectly. The MAC is computed +// over an empty string rather than the ciphertext. Vodozemac implemented this +// functionality the same way as libolm for compatibility. In version 1.10 of +// the spec, the description of step 5 was updated to reflect the de-facto +// standard of libolm and Vodozemac. +// +// [Section 11.12.3.2.2]: https://spec.matrix.org/v1.11/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 +func calculateCompatMAC(macKey []byte) []byte { + hash := hmac.New(sha256.New, macKey) + return hash.Sum(nil)[:8] +} + +// EncryptSessionData encrypts the given session data with the given recovery +// key as defined in [Section 11.12.3.2.2 of the Spec]. +// +// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 +func EncryptSessionData[T any](backupKey *MegolmBackupKey, sessionData T) (*EncryptedSessionData[T], error) { + return EncryptSessionDataWithPubkey(backupKey.PublicKey(), sessionData) +} + +func EncryptSessionDataWithPubkey[T any](pubkey *ecdh.PublicKey, sessionData T) (*EncryptedSessionData[T], error) { + sessionJSON, err := json.Marshal(sessionData) + if err != nil { + return nil, err + } + + ephemeralKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + + sharedSecret, err := ephemeralKey.ECDH(pubkey) + if err != nil { + return nil, err + } + + key, macKey, iv, err := calculateEncryptionParameters(sharedSecret) + if err != nil { + return nil, err + } + + ciphertext, err := aescbc.Encrypt(key, iv, sessionJSON) + if err != nil { + return nil, err + } + + return &EncryptedSessionData[T]{ + Ciphertext: ciphertext, + Ephemeral: EphemeralKey{ephemeralKey.PublicKey()}, + MAC: calculateCompatMAC(macKey), + }, nil +} + +// Decrypt decrypts the [EncryptedSessionData] into a *T using the recovery key +// by reversing the process described in [Section 11.12.3.2.2 of the Spec]. +// +// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 +func (esd *EncryptedSessionData[T]) Decrypt(backupKey *MegolmBackupKey) (*T, error) { + sharedSecret, err := backupKey.ECDH(esd.Ephemeral.PublicKey) + if err != nil { + return nil, err + } + + key, macKey, iv, err := calculateEncryptionParameters(sharedSecret) + if err != nil { + return nil, err + } + + // Verify the MAC before decrypting. + if !hmac.Equal(calculateCompatMAC(macKey), esd.MAC) { + return nil, ErrInvalidMAC + } + + plaintext, err := aescbc.Decrypt(key, iv, esd.Ciphertext) + if err != nil { + return nil, err + } + + var sessionData T + err = json.Unmarshal(plaintext, &sessionData) + return &sessionData, err +} diff --git a/mautrix-patched/crypto/backup/encryptedsessiondata_test.go b/mautrix-patched/crypto/backup/encryptedsessiondata_test.go new file mode 100644 index 00000000..761c4328 --- /dev/null +++ b/mautrix-patched/crypto/backup/encryptedsessiondata_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package backup_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/backup" + "maunium.net/go/mautrix/id" +) + +func TestEncryptedSessionData_Decrypt(t *testing.T) { + testCases := []struct { + encryptedJSON []byte + expectedJSON string + }{ + { + []byte(` + { + "ciphertext": "hDCjEbyi2uMXt3RBWe9mRdeqhcoraPR84/cq5ll16LIIIICJ8ZLmiWG5IwmGqDFmd3Jw20cNo49b38LH3oBJUl5DG44VdjoI4nlgAzaMSLwMZ7JFGt0Enu1Csfgpvgt1qksTP6QB7YDwITD33iL7ucco1iOl7ABGzhyjCi2iZ3A6Xmx3RsAmHhmU5gJWE6/lIoI6/lh7dZFSfp4RTGfxQ8ToCCIsrgdx1weViv4I4ArXfcrdnaprPzP4cH77Ej1Wg1/bUHtB4C8nOiX+cYnOG29NbTHbtQF14zJpA+2XM2JngiLkss+NQj96PQzgPNhAMEFOLLy5ckY1WvS4sMMeCVzAyt5dwEGDcyxLTC4oJ/RrvLcHCHW0aOygPSlNoMRyDgC0f92+mPQGAmFv4GhfDFXfaauBxBdRAPjXj7Onn2B4UdfwQXGLT3RAihba8i9usOX5hLxqQqvtA3SUuV8hPrzHhpPEeRvx+PgZsXwV+gM7Aw3Mza6hwmILdngJh7NNQTINsCRqff9Ck3Kh7aSOoHsHvz7Ot+T514ObDwWYYCBMmS/6EG4XjSya6R98ggRWGrO9l21YYUvzBTv7OLtMck0Za3151Zqi/5LRKP95QIU", + "ephemeral": "o43y/Mck1DExWdHr0+qbPJbjzO97+RH1mw6phLhYQj0", + "mac": "Mnt8eXwFfjw" + } + `), + ` + { + "algorithm": "m.megolm.v1.aes-sha2", + "sender_key": "JUUfV6vErSATm3rIOU9DML+IX1SlYxnAAS824xhbhC4", + "session_key": "AQAAAABc1O9JP2/HXS22iLN1uScFv2UyL33/L3L0sysPKcovQFI0lwKTuutrVeww2SNOU9b2J62kV/QXEw7+N2I9klrvqqr9kdo1ywqFtZOnp8DlgR2+OhOnUYmj5YmJhmApPle9xnVVwZv57Q0REsmSAovHBLH4Kf3GEHPJ9WXEEnLINT9Gzit9qjIZ1fKKacLtvsZ+hbnTPvP5Df3ENalB+03E", + "sender_claimed_keys": {"ed25519":"R2UJWSfgGr64iPENthl/98WGqBtnNlYuP12d6TEuGo4"}, + "forwarding_curve25519_key_chain": [] + } + `, + }, + { + []byte(` + { + "ciphertext": "vdLkqNTijkM1L7HmbxdZs1EHygC7GFG0wPTAaLqpOCoir3K6tNYbjIJs36vzrwawdmfPxZvA9p/k3bZIhZDP7IivGYe69+4pWiIzrwYkHCidigKXkYD8KxKWvakBquO9vWUssXC05xdkQjHMNJK3zSJgtkbMhoY28i1VUdmIjts4xU0cIT40F52Uyx3iu1UrqywUREEE5vhoSbeWxW3Vo5lqPi6rnyvMGZhVzAOv6re2O7wPWnSp0YJUsPaEj6Q9QpLr8BB9vJ++3kwmP5vxfjJLUsXuNEHWIKP5QyhpmGCgwjNpjnU6VhCqBzqs2M/KKX8zxZMGTIRidc3gx2i8KtDwRHRzh3FsSJEaC0sfCfGijpH5g9Pa+2P6b1GxvGQ4TF5X6ayLiV6FyNilpZ4z3kYsy63fP06uinHkX0TUClMQgLLmn0BAiOxKWtLNSLxgFdSYFm5oU/rpOBXWQKbzQ3cvlJZxBtxnaAhJnt3+t/3pJahlKAOxrQbKZAPL/KbO4nF9dsHpMkfMs25pVLDoHLKEXSBhagEFDbPKL5Uv55kca1C1XGrx+8fYUDBRQtYSLBSbAtF3UMv+hIMdRnmyQntwOy2hKRRs2UxnIlExk0Q", + "ephemeral": "24PxRUfQDyYNZcTq0HT8pS3Gq+zkfsAcXHFJ3nZ56W4", + "mac": "T7xq9qHm4Js" + } + `), + ` + { + "algorithm": "m.megolm.v1.aes-sha2", + "sender_key": "JUUfV6vErSATm3rIOU9DML+IX1SlYxnAAS824xhbhC4", + "session_key": "AQAAAAB6cP1PrdPeIG/B0ZRHNUc65ujvIzOxKhW1HN25efyZFaq9xsLvCngm4WO56gEuUhS16E4m0pAa9B/KyRz3AnSOVcHYh1bYxm9qf6zU5PFm255n6FR2lGN0vrgUM7Xu2GNUDCWoNI4m4QsiBor9eCj2ZJRay75dZ4nkhNf3GxBKOkhzPCreKabLxVsseGGIkq8rf01b0CWIcp5ISQISLdza", + "sender_claimed_keys": {"ed25519":"R2UJWSfgGr64iPENthl/98WGqBtnNlYuP12d6TEuGo4"}, + "forwarding_curve25519_key_chain": [] + } + `, + }, + } + + keyBytes, err := base64.RawStdEncoding.DecodeString("ReSMMZeRtDSdrwXzu2OvN0B73KUXkYPt3kaYfFIkw10") + require.NoError(t, err) + backupKey, err := backup.MegolmBackupKeyFromBytes(keyBytes) + require.NoError(t, err) + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i+1), func(t *testing.T) { + var esd backup.EncryptedSessionData[backup.MegolmSessionData] + err := json.Unmarshal([]byte(tc.encryptedJSON), &esd) + assert.NoError(t, err) + + sessionData, err := esd.Decrypt(backupKey) + require.NoError(t, err) + + sessionDataJSON, err := json.Marshal(sessionData) + require.NoError(t, err) + assert.JSONEq(t, string(tc.expectedJSON), string(sessionDataJSON)) + }) + } +} + +func TestEncryptedSessionData_Roundtrip(t *testing.T) { + backupKey, err := backup.NewMegolmBackupKey() + require.NoError(t, err) + + sessionData := backup.MegolmSessionData{ + Algorithm: id.AlgorithmMegolmV1, + } + + encrypted, err := backup.EncryptSessionData(backupKey, sessionData) + require.NoError(t, err) + + encryptedJSON, err := json.Marshal(encrypted) + require.NoError(t, err) + + var roundTrippedEncryptedSessionData backup.EncryptedSessionData[backup.MegolmSessionData] + err = json.Unmarshal(encryptedJSON, &roundTrippedEncryptedSessionData) + require.NoError(t, err) + + decrypted, err := roundTrippedEncryptedSessionData.Decrypt(backupKey) + require.NoError(t, err) + + assert.Equal(t, id.AlgorithmMegolmV1, decrypted.Algorithm) +} diff --git a/mautrix-patched/crypto/backup/ephemeralkey.go b/mautrix-patched/crypto/backup/ephemeralkey.go new file mode 100644 index 00000000..e481e7a9 --- /dev/null +++ b/mautrix-patched/crypto/backup/ephemeralkey.go @@ -0,0 +1,41 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package backup + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" +) + +// EphemeralKey is a wrapper around an ECDH X25519 public key that implements +// JSON marshalling and unmarshalling. +type EphemeralKey struct { + *ecdh.PublicKey +} + +func (k *EphemeralKey) MarshalJSON() ([]byte, error) { + if k == nil || k.PublicKey == nil { + return json.Marshal(nil) + } + return json.Marshal(base64.RawStdEncoding.EncodeToString(k.Bytes())) +} + +func (k *EphemeralKey) UnmarshalJSON(data []byte) error { + var keyStr string + err := json.Unmarshal(data, &keyStr) + if err != nil { + return err + } + + keyBytes, err := base64.RawStdEncoding.DecodeString(keyStr) + if err != nil { + return err + } + k.PublicKey, err = ecdh.X25519().NewPublicKey(keyBytes) + return err +} diff --git a/mautrix-patched/crypto/backup/ephemeralkey_test.go b/mautrix-patched/crypto/backup/ephemeralkey_test.go new file mode 100644 index 00000000..0842f54f --- /dev/null +++ b/mautrix-patched/crypto/backup/ephemeralkey_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package backup_test + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/backup" +) + +type testStruct struct { + EphemeralKey *backup.EphemeralKey `json:"ephemeral"` +} + +func TestEphemeralKey_UnmarshalJSON(t *testing.T) { + testCases := []string{ + "o43y/Mck1DExWdHr0+qbPJbjzO97+RH1mw6phLhYQj0", + } + + testJSONTemplate := `{"ephemeral": "%s"}` + + for _, tc := range testCases { + t.Run(tc, func(t *testing.T) { + var test testStruct + jsonInput := fmt.Sprintf(testJSONTemplate, tc) + err := json.Unmarshal([]byte(jsonInput), &test) + require.NoError(t, err) + expected, err := base64.RawStdEncoding.DecodeString(tc) + require.NoError(t, err) + assert.Equal(t, expected, test.EphemeralKey.Bytes()) + }) + } +} + +func TestEphemeralKey_MarshallJSON(t *testing.T) { + key, err := ecdh.X25519().GenerateKey(rand.Reader) + require.NoError(t, err) + + test := &backup.EphemeralKey{key.PublicKey()} + marshalled, err := json.Marshal(test) + require.NoError(t, err) + assert.EqualValues(t, '"', marshalled[0]) + assert.Len(t, marshalled, 45) + assert.EqualValues(t, '"', marshalled[44]) +} diff --git a/mautrix-patched/crypto/backup/megolmbackup.go b/mautrix-patched/crypto/backup/megolmbackup.go new file mode 100644 index 00000000..71b8e88b --- /dev/null +++ b/mautrix-patched/crypto/backup/megolmbackup.go @@ -0,0 +1,39 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package backup + +import ( + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/id" +) + +// MegolmAuthData is the auth_data when the key backup is created with +// the [id.KeyBackupAlgorithmMegolmBackupV1] algorithm as defined in +// [Section 11.12.3.2.2 of the Spec]. +// +// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 +type MegolmAuthData struct { + PublicKey id.Ed25519 `json:"public_key"` + Signatures signatures.Signatures `json:"signatures"` +} + +type SenderClaimedKeys struct { + Ed25519 id.Ed25519 `json:"ed25519"` +} + +// MegolmSessionData is the decrypted session_data when the key backup is created +// with the [id.KeyBackupAlgorithmMegolmBackupV1] algorithm as defined in +// [Section 11.12.3.2.2 of the Spec]. +// +// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 +type MegolmSessionData struct { + Algorithm id.Algorithm `json:"algorithm"` + ForwardingKeyChain []string `json:"forwarding_curve25519_key_chain"` + SenderClaimedKeys SenderClaimedKeys `json:"sender_claimed_keys"` + SenderKey id.SenderKey `json:"sender_key"` + SessionKey string `json:"session_key"` +} diff --git a/mautrix-patched/crypto/backup/megolmbackupkey.go b/mautrix-patched/crypto/backup/megolmbackupkey.go new file mode 100644 index 00000000..8f23d104 --- /dev/null +++ b/mautrix-patched/crypto/backup/megolmbackupkey.go @@ -0,0 +1,34 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package backup + +import ( + "crypto/ecdh" + "crypto/rand" +) + +// MegolmBackupKey is a wrapper around an ECDH X25519 private key that is used +// to decrypt a megolm key backup. +type MegolmBackupKey struct { + *ecdh.PrivateKey +} + +func NewMegolmBackupKey() (*MegolmBackupKey, error) { + key, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + return &MegolmBackupKey{key}, nil +} + +func MegolmBackupKeyFromBytes(bytes []byte) (*MegolmBackupKey, error) { + key, err := ecdh.X25519().NewPrivateKey(bytes) + if err != nil { + return nil, err + } + return &MegolmBackupKey{key}, nil +} diff --git a/mautrix-patched/crypto/canonicaljson/README.md b/mautrix-patched/crypto/canonicaljson/README.md new file mode 100644 index 00000000..da9d71ff --- /dev/null +++ b/mautrix-patched/crypto/canonicaljson/README.md @@ -0,0 +1,6 @@ +# canonicaljson +This is a Go package to produce Matrix [Canonical JSON](https://matrix.org/docs/spec/appendices#canonical-json). +It is essentially just [json.go](https://github.com/matrix-org/gomatrixserverlib/blob/master/json.go) +from gomatrixserverlib without all the other files that are completely useless for non-server use cases. + +The original project is licensed under the Apache 2.0 license. diff --git a/mautrix-patched/crypto/canonicaljson/json.go b/mautrix-patched/crypto/canonicaljson/json.go new file mode 100644 index 00000000..1582b6b6 --- /dev/null +++ b/mautrix-patched/crypto/canonicaljson/json.go @@ -0,0 +1,281 @@ +//go:build !goexperiment.jsonv2 + +/* Copyright 2016-2017 Vector Creations Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package canonicaljson + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "sort" + "unicode/utf8" + + "github.com/tidwall/gjson" +) + +func Canonicalize(input *json.RawMessage) error { + *input = CanonicalJSONAssumeValid(*input) + return nil +} + +func Marshal(v any) (json.RawMessage, error) { + marshaled, err := json.Marshal(v) + if err != nil { + return nil, err + } + return CanonicalJSONAssumeValid(marshaled), nil +} + +// CanonicalJSON re-encodes the JSON in a canonical encoding. The encoding is +// the shortest possible encoding using integer values with sorted object keys. +// https://matrix.org/docs/spec/appendices#canonical-json +// +// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 +func CanonicalJSON(input []byte) ([]byte, error) { + if !gjson.Valid(string(input)) { + return nil, fmt.Errorf("invalid json") + } + + return CanonicalJSONAssumeValid(input), nil +} + +// CanonicalJSONAssumeValid is the same as CanonicalJSON, but assumes the +// input is valid JSON +// +// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 +func CanonicalJSONAssumeValid(input []byte) []byte { + input = CompactJSON(input, make([]byte, 0, len(input))) + return SortJSON(input, make([]byte, 0, len(input))) +} + +// SortJSON reencodes the JSON with the object keys sorted by lexicographically +// by codepoint. The input must be valid JSON. +// +// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 +func SortJSON(input, output []byte) []byte { + result := gjson.ParseBytes(input) + + return sortJSONValue(result, input, output) +} + +// sortJSONValue takes a gjson.Result and sorts it. inputJSON must be the +// raw JSON bytes that gjson.Result points to. +func sortJSONValue(input gjson.Result, inputJSON, output []byte) []byte { + if input.IsArray() { + return sortJSONArray(input, inputJSON, output) + } + + if input.IsObject() { + return sortJSONObject(input, inputJSON, output) + } + + // If its neither an object nor an array then there is no sub structure + // to sort, so just append the raw bytes. + return append(output, input.Raw...) +} + +// sortJSONArray takes a gjson.Result and sorts it, assuming its an array. +// inputJSON must be the raw JSON bytes that gjson.Result points to. +func sortJSONArray(input gjson.Result, inputJSON, output []byte) []byte { + sep := byte('[') + + // Iterate over each value in the array and sort it. + input.ForEach(func(_, value gjson.Result) bool { + output = append(output, sep) + sep = ',' + output = sortJSONValue(value, inputJSON, output) + return true // keep iterating + }) + + if sep == '[' { + // If sep is still '[' then the array was empty and we never wrote the + // initial '[', so we write it now along with the closing ']'. + output = append(output, '[', ']') + } else { + // Otherwise we end the array by writing a single ']' + output = append(output, ']') + } + return output +} + +// sortJSONObject takes a gjson.Result and sorts it, assuming its an object. +// inputJSON must be the raw JSON bytes that gjson.Result points to. +func sortJSONObject(input gjson.Result, inputJSON, output []byte) []byte { + type entry struct { + key string // The parsed key string + rawKey string // The raw, unparsed key JSON string + value gjson.Result + } + + var entries []entry + + // Iterate over each key/value pair and add it to a slice + // that we can sort + input.ForEach(func(key, value gjson.Result) bool { + entries = append(entries, entry{ + key: key.String(), + rawKey: key.Raw, + value: value, + }) + return true // keep iterating + }) + + // Sort the slice based on the *parsed* key + sort.Slice(entries, func(a, b int) bool { + return entries[a].key < entries[b].key + }) + + sep := byte('{') + + for _, entry := range entries { + output = append(output, sep) + sep = ',' + + // Append the raw unparsed JSON key, *not* the parsed key + output = append(output, entry.rawKey...) + output = append(output, ':') + output = sortJSONValue(entry.value, inputJSON, output) + } + if sep == '{' { + // If sep is still '{' then the object was empty and we never wrote the + // initial '{', so we write it now along with the closing '}'. + output = append(output, '{', '}') + } else { + // Otherwise we end the object by writing a single '}' + output = append(output, '}') + } + return output +} + +// CompactJSON makes the encoded JSON as small as possible by removing +// whitespace and unneeded unicode escapes +// +// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 +func CompactJSON(input, output []byte) []byte { + var i int + for i < len(input) { + c := input[i] + i++ + // The valid whitespace characters are all less than or equal to SPACE 0x20. + // The valid non-white characters are all greater than SPACE 0x20. + // So we can check for whitespace by comparing against SPACE 0x20. + if c <= ' ' { + // Skip over whitespace. + continue + } + // Add the non-whitespace character to the output. + output = append(output, c) + if c == '"' { + // We are inside a string. + for i < len(input) { + c = input[i] + i++ + // Check if this is an escape sequence. + if c == '\\' { + escape := input[i] + i++ + if escape == 'u' { + // If this is a unicode escape then we need to handle it specially + output, i = compactUnicodeEscape(input, output, i) + } else if escape == '/' { + // JSON does not require escaping '/', but allows encoders to escape it as a special case. + // Since the escape isn't required we remove it. + output = append(output, escape) + } else { + // All other permitted escapes are single charater escapes that are already in their shortest form. + output = append(output, '\\', escape) + } + } else { + output = append(output, c) + } + if c == '"' { + break + } + } + } + } + return output +} + +// compactUnicodeEscape unpacks a 4 byte unicode escape starting at index. +// If the escape is a surrogate pair then decode the 6 byte \uXXXX escape +// that follows. Returns the output slice and a new input index. +func compactUnicodeEscape(input, output []byte, index int) ([]byte, int) { + const ( + ESCAPES = "uuuuuuuubtnufruuuuuuuuuuuuuuuuuu" + HEX = "0123456789ABCDEF" + ) + // If there aren't enough bytes to decode the hex escape then return. + if len(input)-index < 4 { + return output, len(input) + } + // Decode the 4 hex digits. + c := readHexDigits(input[index:]) + index += 4 + if c < ' ' { + // If the character is less than SPACE 0x20 then it will need escaping. + escape := ESCAPES[c] + output = append(output, '\\', escape) + if escape == 'u' { + output = append(output, '0', '0', byte('0'+(c>>4)), HEX[c&0xF]) + } + } else if c == '\\' || c == '"' { + // Otherwise the character only needs escaping if it is a QUOTE '"' or BACKSLASH '\\'. + output = append(output, '\\', byte(c)) + } else if c < 0xD800 || c >= 0xE000 { + // If the character isn't a surrogate pair then encoded it directly as UTF-8. + var buffer [4]byte + n := utf8.EncodeRune(buffer[:], rune(c)) + output = append(output, buffer[:n]...) + } else { + // Otherwise the escaped character was the first part of a UTF-16 style surrogate pair. + // The next 6 bytes MUST be a '\uXXXX'. + // If there aren't enough bytes to decode the hex escape then return. + if len(input)-index < 6 { + return output, len(input) + } + // Decode the 4 hex digits from the '\uXXXX'. + surrogate := readHexDigits(input[index+2:]) + index += 6 + // Reconstruct the UCS4 codepoint from the surrogates. + codepoint := 0x10000 + (((c & 0x3FF) << 10) | (surrogate & 0x3FF)) + // Encode the charater as UTF-8. + var buffer [4]byte + n := utf8.EncodeRune(buffer[:], rune(codepoint)) + output = append(output, buffer[:n]...) + } + return output, index +} + +// Read 4 hex digits from the input slice. +// Taken from https://github.com/NegativeMjark/indolentjson-rust/blob/8b959791fe2656a88f189c5d60d153be05fe3deb/src/readhex.rs#L21 +func readHexDigits(input []byte) uint32 { + hex := binary.BigEndian.Uint32(input) + // subtract '0' + hex -= 0x30303030 + // strip the higher bits, maps 'a' => 'A' + hex &= 0x1F1F1F1F + mask := hex & 0x10101010 + // subtract 'A' - 10 - '9' - 9 = 7 from the letters. + hex -= mask >> 1 + hex += mask >> 4 + // collect the nibbles + hex |= hex >> 4 + hex &= 0xFF00FF + hex |= hex >> 8 + return hex & 0xFFFF +} diff --git a/mautrix-patched/crypto/canonicaljson/json_test.go b/mautrix-patched/crypto/canonicaljson/json_test.go new file mode 100644 index 00000000..90ec90eb --- /dev/null +++ b/mautrix-patched/crypto/canonicaljson/json_test.go @@ -0,0 +1,110 @@ +//go:build !goexperiment.jsonv2 + +/* Copyright 2016-2017 Vector Creations Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package canonicaljson + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortJSON(t *testing.T) { + var tests = []struct { + input string + want string + }{ + {"{}", "{}"}, + {`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, + {`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, + {`[true,false,null]`, `[true,false,null]`}, + {`[9007199254740991]`, `[9007199254740991]`}, + {"\t\n[9007199254740991]", `[9007199254740991]`}, + {`[true,false,null]`, `[true,false,null]`}, + {`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, + {`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, + {`[true,false,null]`, `[true,false,null]`}, + {`[9007199254740991]`, `[9007199254740991]`}, + {"\t\n[9007199254740991]", `[9007199254740991]`}, + {`[true,false,null]`, `[true,false,null]`}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got := SortJSON([]byte(test.input), nil) + + // Squash out the whitespace before comparing the JSON in case SortJSON had inserted whitespace. + assert.EqualValues(t, test.want, string(CompactJSON(got, nil))) + }) + } +} + +func testCompactJSON(t *testing.T, input, want string) { + t.Helper() + got := string(CompactJSON([]byte(input), nil)) + assert.EqualValues(t, want, got) +} + +func TestCompactJSON(t *testing.T) { + testCompactJSON(t, "{ }", "{}") + + input := `["\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007"]` + want := input + testCompactJSON(t, input, want) + + input = `["\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F"]` + want = `["\b\t\n\u000B\f\r\u000E\u000F"]` + testCompactJSON(t, input, want) + + input = `["\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017"]` + want = input + testCompactJSON(t, input, want) + + input = `["\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"]` + want = input + testCompactJSON(t, input, want) + + testCompactJSON(t, `["\u0061\u005C\u0042\u0022"]`, `["a\\B\""]`) + testCompactJSON(t, `["\u0120"]`, "[\"\u0120\"]") + testCompactJSON(t, `["\u0FFF"]`, "[\"\u0FFF\"]") + testCompactJSON(t, `["\u1820"]`, "[\"\u1820\"]") + testCompactJSON(t, `["\uFFFF"]`, "[\"\uFFFF\"]") + testCompactJSON(t, `["\uD842\uDC20"]`, "[\"\U00020820\"]") + testCompactJSON(t, `["\uDBFF\uDFFF"]`, "[\"\U0010FFFF\"]") + + testCompactJSON(t, `["\"\\\/"]`, `["\"\\/"]`) +} + +func TestReadHex(t *testing.T) { + tests := []struct { + input string + want uint32 + }{ + + {"0123", 0x0123}, + {"4567", 0x4567}, + {"89AB", 0x89AB}, + {"CDEF", 0xCDEF}, + {"89ab", 0x89AB}, + {"cdef", 0xCDEF}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got := readHexDigits([]byte(test.input)) + assert.Equal(t, test.want, got) + }) + } +} diff --git a/mautrix-patched/crypto/canonicaljson/jsonv2.go b/mautrix-patched/crypto/canonicaljson/jsonv2.go new file mode 100644 index 00000000..859073a2 --- /dev/null +++ b/mautrix-patched/crypto/canonicaljson/jsonv2.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package canonicaljson + +import ( + "bytes" + "cmp" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "slices" + "sync" +) + +var canonicalOpts = []json.Options{ + jsontext.CanonicalizeRawInts(true), + jsontext.CanonicalizeRawFloats(true), + jsontext.AllowDuplicateNames(false), + jsontext.AllowInvalidUTF8(false), + jsontext.ReorderRawObjects(false), // we want UTF-8 ordering rather than UTF-16 +} + +func Canonicalize(input *jsontext.Value) error { + err := input.Format(canonicalOpts...) + if err != nil { + return err + } + return reorderObjectsAndValidate(nil, *input, &[]byte{}) +} + +func Marshal(v any) (jsontext.Value, error) { + data, err := json.Marshal(v, canonicalOpts...) + if err != nil { + return nil, err + } + return data, reorderObjectsAndValidate(nil, data, &[]byte{}) +} + +type objectMember struct { + name []byte + buffer []byte +} + +func (x objectMember) Compare(y objectMember) int { + return bytes.Compare(x.name, y.name) +} + +var objectMemberPool = sync.Pool{ + New: func() any { + return new([]objectMember) + }, +} + +func getObjectMembers() *[]objectMember { + ns := objectMemberPool.Get().(*[]objectMember) + *ns = (*ns)[:0] + return ns +} + +func putObjectMembers(ns *[]objectMember) { + if cap(*ns) < 1<<10 { + clear(*ns) // avoid pinning name and buffer + objectMemberPool.Put(ns) + } +} + +// TODO replace with jsontext.Kind* after dropping Go 1.25 support +const ( + KindNull = 'n' + KindFalse = 'f' + KindTrue = 't' + KindString = '"' + KindNumber = '0' + KindBeginObject = '{' + KindEndObject = '}' + KindBeginArray = '[' + KindEndArray = ']' +) + +// This is based on the standard implementation of [jsontext.ReorderRawObjects] +// in https://github.com/golang/go/blob/go1.26.3/src/encoding/json/jsontext/value.go#L298-L395 +// It has been adjusted to: +// * work outside the standard library +// * assume the data is already minified rather than supporting extra whitespace +// * have additional matrix-specific validation for numbers (reject floats and enforce limits) +// * most importantly: sort objects based on UTF-8 instead of UTF-16 +func reorderObjectsAndValidate(d *jsontext.Decoder, buf []byte, scratch *[]byte) error { + if d == nil { + d = jsontext.NewDecoder( + bytes.NewBuffer(buf), + // This should improve performance slightly since the Format call earlier already rejected invalid data + jsontext.AllowDuplicateNames(true), + jsontext.AllowInvalidUTF8(true), + ) + } + switch tok, err := d.ReadToken(); tok.Kind() { + case KindBeginObject: + // Iterate and collect the name and offsets for every object member. + members := getObjectMembers() + defer putObjectMembers(members) + var prevMember objectMember + isSorted := true + + beforeBody := d.InputOffset() // offset after '{' + for d.PeekKind() != KindEndObject { + beforeName := d.InputOffset() + name, err := d.ReadValue() + if err != nil { + return fmt.Errorf("failed to read key: %w", err) + } + if !bytes.ContainsRune(name, '\\') { + name = name[1 : len(name)-1] // unquote without copying if possible + } else { + name, err = jsontext.AppendUnquote(nil, name) + if err != nil { + return fmt.Errorf("failed to unquote key: %w", err) + } + } + err = reorderObjectsAndValidate(d, buf, scratch) + if err != nil { + return fmt.Errorf("failed to reorder %s: %w", name, err) + } + afterValue := d.InputOffset() + + currMember := objectMember{name, buf[beforeName:afterValue]} + if isSorted && len(*members) > 0 { + isSorted = objectMember.Compare(prevMember, currMember) < 0 + } + *members = append(*members, currMember) + prevMember = currMember + } + afterBody := d.InputOffset() // offset before '}' + _, err = d.ReadToken() + if err != nil { + return fmt.Errorf("failed to read end of object: %w", err) + } + + // Sort the members; return early if it's already sorted. + if isSorted { + return nil + } + firstBufferBeforeSorting := (*members)[0].buffer + slices.SortFunc(*members, objectMember.Compare) + + // Append the reordered members to a new buffer, + // then copy the reordered members back over the original members. + // Avoid swapping in place since each member may be a different size + // where moving a member over a smaller member may corrupt the data + // for subsequent members before they have been moved. + // + // The following invariant must hold: + // sum([m.after-m.before for m in members]) == afterBody-beforeBody + sorted := (*scratch)[:0] + for i, member := range *members { + switch { + case i == 0 && &member.buffer[0] != &firstBufferBeforeSorting[0]: + // First member after sorting is not the first member before sorting, cut off the leading comma + if member.buffer[0] != ',' { + return fmt.Errorf("expected newly sorted first member buffer to start with a comma") + } + sorted = append(sorted, member.buffer[1:]...) + case i != 0 && &member.buffer[0] == &firstBufferBeforeSorting[0]: + // Later member after sorting is the first member before sorting, add leading comma + if member.buffer[0] == ',' { + return fmt.Errorf("expected newly sorted later member buffer to not start with a comma") + } + sorted = append(sorted, ',') + sorted = append(sorted, member.buffer...) + default: + sorted = append(sorted, member.buffer...) + } + } + if int(afterBody-beforeBody) != len(sorted) { + return fmt.Errorf("BUG: length invariant violated") + } + copy(buf[beforeBody:afterBody], sorted) + + // Update scratch buffer to the largest amount ever used. + if len(sorted) > len(*scratch) { + *scratch = sorted + } + return nil + case KindBeginArray: + for d.PeekKind() != KindEndArray { + err = reorderObjectsAndValidate(d, buf, scratch) + if err != nil { + return err + } + } + _, err = d.ReadToken() + return err + case KindNumber: + str := tok.String() + if str == "-0" { + return fmt.Errorf("invalid number: -0") + } else if str == "0" { + return nil + } else if len(str) > 17 { + return fmt.Errorf("too long number: %q", str) + } + var val uint64 + firstDigitIdx := 0 + for i, bt := range []byte(str) { + if i == 0 && bt == '-' { + firstDigitIdx = 1 + continue + } else if (bt >= '1' && bt <= '9') || (bt == '0' && i != firstDigitIdx) { + // ok + } else { + return fmt.Errorf("invalid character %c in number: %q", bt, str) + } + val = val*10 + uint64(bt-'0') + } + // val is always positive since we just ignore the leading - + if val >= 1<<53 { + return fmt.Errorf("number too large: %q", str) + } + return nil + case KindNull, KindFalse, KindTrue, KindString: + return err + default: + // This probably can't happen + return cmp.Or(err, fmt.Errorf("unexpected token: %s", tok)) + } +} + +// Deprecated: Use the new Canonicalize or Marshal functions +func CanonicalJSONAssumeValid(input []byte) []byte { + out, _ := CanonicalJSON(input) + return out +} + +// Deprecated: Use the new Canonicalize or Marshal functions +func CanonicalJSON(input []byte) ([]byte, error) { + out := jsontext.Value(bytes.Clone(input)) + err := Canonicalize(&out) + return out, err +} diff --git a/mautrix-patched/crypto/canonicaljson/jsonv2_test.go b/mautrix-patched/crypto/canonicaljson/jsonv2_test.go new file mode 100644 index 00000000..1d169b77 --- /dev/null +++ b/mautrix-patched/crypto/canonicaljson/jsonv2_test.go @@ -0,0 +1,364 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package canonicaljson_test + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "math" + "os/exec" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/canonicaljson" +) + +var canonicalizeTests = []struct { + name string + input string + want string +}{ + {"empty object", "{}", "{}"}, + {"reorder in array", `[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, + {"nested object reorder", `{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, + {"array with primitives", `[true,false,null]`, `[true,false,null]`}, + {"array with big number", `[9007199254740991]`, `[9007199254740991]`}, + {"array with small number", `[-9007199254740991]`, `[-9007199254740991]`}, + {"extra whitespace", "\t\n[9007199254740991]", `[9007199254740991]`}, + {"ascii 0-7", `"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007"`, `"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007"`}, + {"ascii 8-f expanded", `"\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F"`, `"\b\t\n\u000b\f\r\u000e\u000f"`}, + {"ascii 8-f already correct", `"\b\t\n\u000b\f\r\u000e\u000f"`, `"\b\t\n\u000b\f\r\u000e\u000f"`}, + {"ascii 10-17", `"\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017"`, `"\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017"`}, + {"ascii 18-1f", `"\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"`, `"\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f"`}, + {"quote and backslash expanded", `["\u0061\u005C\u0042\u0022"]`, `["a\\B\""]`}, + {"quote and backslash already correct", `["a\\B\""]`, `["a\\B\""]`}, + {"misc unicode escapes", `"\u0120\u0FFF\u1820\uFFFF"`, "\"\u0120\u0FFF\u1820\uFFFF\""}, + {"utf-16 surrogates", `["\uD842\uDC20", "\uDBFF\uDFFF"]`, "[\"\U00020820\",\"\U0010FFFF\"]"}, + {"escaped slash", `{"a": "\/"}`, `{"a":"/"}`}, + {"1.0", `{"a": 1.0}`, `{"a":1}`}, + {"0.0", `{"a": 0.0}`, `{"a":0}`}, + {"-0.0", `{"a": -0.0}`, `{"a":0}`}, + {"1e10", `{"a": 1e10}`, `{"a":10000000000}`}, + {"sorting with backslashes", `{"\"": 4, "\\": 5, "\n": 3, "\r": 2, "\u0000":1}`, `{"\u0000":1,"\n":3,"\r":2,"\"":4,"\\":5}`}, + { + "sorting where utf-8 and utf-16 disagree", + `{"\ud83d\udc31": "meow", "\ud800\udc00": {"\ud800\udc00": "hmm1", "\uFFFF": "meowo1", "\ud800\udc01": "hmm2", "\uEFFF": "meowo2"}, "\uf123": "woof"}`, + "{\"\uf123\":\"woof\",\"\U00010000\":{\"\uEFFF\":\"meowo2\",\"\uFFFF\":\"meowo1\",\"\U00010000\":\"hmm1\",\"\U00010001\":\"hmm2\"},\"\U0001F431\":\"meow\"}", + }, + + // Examples from the Matrix Canonical JSON spec + // (https://spec.matrix.org/v1.18/appendices/#canonical-json). + {"spec: simple", `{"one": 1, "two": "Two"}`, `{"one":1,"two":"Two"}`}, + {"spec: simple sort", `{"b": "2", "a": "1"}`, `{"a":"1","b":"2"}`}, + {"spec: simple sort minified", `{"b":"2","a":"1"}`, `{"a":"1","b":"2"}`}, + { + "spec: complex nested", + `{ + "auth": { + "success": true, + "mxid": "@john.doe:example.com", + "profile": { + "display_name": "John Doe", + "three_pids": [ + { + "medium": "email", + "address": "john.doe@example.org" + }, + { + "medium": "msisdn", + "address": "123456789" + } + ] + } + } +}`, + `{"auth":{"mxid":"@john.doe:example.com","profile":{"display_name":"John Doe","three_pids":[{"address":"john.doe@example.org","medium":"email"},{"address":"123456789","medium":"msisdn"}]},"success":true}}`, + }, + {"spec: japanese value", `{"a": "\u65e5\u672c\u8a9e"}`, "{\"a\":\"\u65e5\u672c\u8a9e\"}"}, + {"spec: japanese keys sort", `{"\u672c": 2, "\u65e5": 1}`, "{\"\u65e5\":1,\"\u672c\":2}"}, + {"spec: unicode escape to japanese", `{"a": "\u65e5"}`, "{\"a\":\"\u65e5\"}"}, + {"spec: null value", `{"a": null}`, `{"a":null}`}, + {"spec: -0 and 1e10", `{"a": -0, "b": 1e10}`, `{"a":0,"b":10000000000}`}, + + // Top-level non-object values. + {"top-level null", `null`, `null`}, + {"top-level true", `true`, `true`}, + {"top-level false", `false`, `false`}, + {"top-level number", `42`, `42`}, + {"top-level negative number", `-42`, `-42`}, + {"top-level string", `"hello"`, `"hello"`}, + {"top-level zero", `0`, `0`}, + + // Empty/single containers. + {"empty array", `[]`, `[]`}, + {"single element array", `[1]`, `[1]`}, + {"single key object", `{"a": 1}`, `{"a":1}`}, + {"empty key", `{"": 1}`, `{"":1}`}, + {"empty key empty value", `{"": ""}`, `{"":""}`}, + {"empty object as value", `{"a":{}}`, `{"a":{}}`}, + {"empty array as value", `{"a":[]}`, `{"a":[]}`}, + + // Number edge cases that exercise the int53 boundary and the + // canonicalization of various numeric representations. + {"integer 0", `{"a": 0}`, `{"a":0}`}, + {"integer -0", `{"a": -0}`, `{"a":0}`}, + {"integer 0e0", `{"a": 0e0}`, `{"a":0}`}, + {"integer -0e0", `{"a": -0e0}`, `{"a":0}`}, + {"integer 0e10", `{"a": 0e10}`, `{"a":0}`}, + {"max safe integer", `{"a": 9007199254740991}`, `{"a":9007199254740991}`}, + {"min safe integer", `{"a": -9007199254740991}`, `{"a":-9007199254740991}`}, + {"max safe as float", `{"a": 9007199254740991.0}`, `{"a":9007199254740991}`}, + {"max safe as scientific", `{"a": 9.007199254740991e15}`, `{"a":9007199254740991}`}, + {"1e15 (within range)", `{"a": 1e15}`, `{"a":1000000000000000}`}, + {"1.5e2 to integer 150", `{"a": 1.5e2}`, `{"a":150}`}, + {"1e2 to integer 100", `{"a": 1e2}`, `{"a":100}`}, + {"explicit positive exponent", `{"a": 1.5e+2}`, `{"a":150}`}, + {"integer with negative exponent", `{"a": 10e-1}`, `{"a":1}`}, + {"max safe integer with negative exponent", `{"a": 90071992547409910e-1}`, `{"a":9007199254740991}`}, + {"zero with huge negative exponent", `{"a": 0e-1000}`, `{"a":0}`}, + {"tiny exponent underflows to zero", `{"a": 1e-1000}`, `{"a":0}`}, + {"negative tiny exponent underflows to zero", `{"a": -1e-1000}`, `{"a":0}`}, + {"fraction rounds to one", `{"a": 0.99999999999999999}`, `{"a":1}`}, + {"fraction rounds down to max safe", `{"a": 9007199254740991.1}`, `{"a":9007199254740991}`}, + {"fraction rounds down within range", `{"a": 9007199254740990.5}`, `{"a":9007199254740990}`}, + {"fraction rounds up within range", `{"a": 999999999999999.99}`, `{"a":1000000000000000}`}, + {"scientific fraction near max safe", `{"a": 9.0071992547409911e15}`, `{"a":9007199254740991}`}, + + // Sorting edge cases. UTF-8 byte sort means uppercase precedes lowercase, + // and shorter prefixes precede longer strings sharing those prefixes. + {"prefix keys", `{"abc":1,"ab":2,"a":3}`, `{"a":3,"ab":2,"abc":1}`}, + {"case-sensitive sort", `{"a":1,"A":2}`, `{"A":2,"a":1}`}, + {"numeric string keys lex", `{"10":1,"2":2,"1":3}`, `{"1":3,"10":1,"2":2}`}, + {"three keys reverse", `{"c":3,"b":2,"a":1}`, `{"a":1,"b":2,"c":3}`}, + {"first element stays first", `{"a":1,"c":3,"b":2}`, `{"a":1,"b":2,"c":3}`}, + {"varying member sizes", `{"x":111111,"a":1,"m":22}`, `{"a":1,"m":22,"x":111111}`}, + {"escaped slash key", `{"b":2,"\/":1}`, `{"/":1,"b":2}`}, + + // Nested structures. + {"nested arrays with sort", `[[1,2],[3,{"b":2,"a":1}]]`, `[[1,2],[3,{"a":1,"b":2}]]`}, + {"deeply nested", `{"a":{"a":{"a":{"a":{"a":1}}}}}`, `{"a":{"a":{"a":{"a":{"a":1}}}}}`}, + + // Strings. + {"non-BMP value as escapes", `{"a":"\ud83d\udc31"}`, "{\"a\":\"\xf0\x9f\x90\xb1\"}"}, + {"non-BMP value raw", "{\"a\":\"\xf0\x9f\x90\xb1\"}", "{\"a\":\"\xf0\x9f\x90\xb1\"}"}, + {"null byte in value", `{"a":"\u0000"}`, `{"a":"\u0000"}`}, + {"DEL char raw 0x7f", "{\"a\":\"\x7f\"}", "{\"a\":\"\x7f\"}"}, + {"DEL char as escape", `{"a":"\u007f"}`, "{\"a\":\"\x7f\"}"}, +} + +func TestCanonicalize(t *testing.T) { + for _, test := range canonicalizeTests { + t.Run(test.name, func(t *testing.T) { + val := jsontext.Value(test.input) + err := canonicaljson.Canonicalize(&val) + assert.NoError(t, err) + assert.Equal(t, test.want, string(val)) + t.Run("python", func(t *testing.T) { + checkPython(t, string(val)) + }) + }) + } +} + +const pythonCanonicalJSON = `import json, sys; print(json.dumps(json.loads(sys.argv[1]), allow_nan=False, ensure_ascii=False, separators=(',',':'),sort_keys=True))` + +var python3, _ = exec.LookPath("python3") + +func checkPython(t *testing.T, val string) { + if python3 == "" { + t.SkipNow() + } + out, err := exec.CommandContext(t.Context(), python3, "-c", pythonCanonicalJSON, val).Output() + require.NoError(t, err) + assert.Equal(t, val, strings.TrimSuffix(string(out), "\n")) +} + +// Unmarshal preserves negative zeroes, so Marshal will reject it, +// while Canonicalize will accept it and convert to a plain zero. +// Both behaviors are acceptable, so skip tests for that here. +var roundtripSkip = map[string]bool{ + "-0.0": true, + "integer -0": true, + "integer -0e0": true, + "negative tiny exponent underflows to zero": true, + "spec: -0 and 1e10": true, +} + +func TestMarshal_Roundtrip(t *testing.T) { + for _, test := range canonicalizeTests { + t.Run(test.name, func(t *testing.T) { + if roundtripSkip[test.name] { + t.SkipNow() + } + var temp any + require.NoError(t, json.Unmarshal([]byte(test.input), &temp)) + val, err := canonicaljson.Marshal(temp) + require.NoError(t, err) + assert.Equal(t, test.want, string(val)) + }) + } +} + +type m = map[string]any + +type marshalStructInner struct { + B int `json:"b"` + A int `json:"a"` +} + +type marshalStruct struct { + Z int `json:"z"` + Inner marshalStructInner `json:"inner"` + Name string `json:"name"` +} + +func TestMarshal(t *testing.T) { + var tests = []struct { + name string + input any + want string + }{ + {"empty object", m{}, `{}`}, + {"simple types", m{"c": nil, "d": "foo", "e": 1234, "a": true, "b": false}, `{"a":true,"b":false,"c":null,"d":"foo","e":1234}`}, + {"nil", nil, `null`}, + {"top-level true", true, `true`}, + {"top-level false", false, `false`}, + {"top-level int", 42, `42`}, + {"top-level string", "hello", `"hello"`}, + {"empty slice", []int{}, `[]`}, + {"nil slice", []int(nil), `[]`}, + {"nil map", map[string]int(nil), `{}`}, + {"slice of maps", []m{{"b": 1, "a": 2}, {"d": 3, "c": 4}}, `[{"a":2,"b":1},{"c":4,"d":3}]`}, + {"nested map", m{"b": m{"y": 1, "x": 2}, "a": 5}, `{"a":5,"b":{"x":2,"y":1}}`}, + {"struct with json tags", marshalStruct{Z: 1, Inner: marshalStructInner{B: 2, A: 3}, Name: "test"}, `{"inner":{"a":3,"b":2},"name":"test","z":1}`}, + {"mixed any slice", []any{nil, true, false, 1, "hi"}, `[null,true,false,1,"hi"]`}, + {"max safe int64", int64(9007199254740991), `9007199254740991`}, + {"min safe int64", int64(-9007199254740991), `-9007199254740991`}, + {"max safe uint64", uint64(9007199254740991), `9007199254740991`}, + {"japanese keys", m{"本": 2, "日": 1}, "{\"日\":1,\"本\":2}"}, + {"non-bmp value", m{"a": "\U0001F431"}, "{\"a\":\"\U0001F431\"}"}, + {"int 0", 0, `0`}, + {"int -1", -1, `-1`}, + {"float that is integer", 1.0, `1`}, + {"float 1e10", 1e10, `10000000000`}, + {"empty struct", struct{}{}, `{}`}, + {"raw unknown value", unknownRaw{Foo: "meow", Unknown: jsontext.Value(`{ "hello": "world" } `)}, `{"foo":"meow","hello":"world"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := canonicaljson.Marshal(test.input) + assert.NoError(t, err) + assert.Equal(t, test.want, string(got)) + }) + } +} + +var canonicalizeErrorTests = []struct { + name string + input string +}{ + {"duplicate keys", `{"a":1,"a":2}`}, + {"escape-equivalent duplicate keys", `{"a":1,"\u0061":2}`}, + {"invalid UTF-8", "\"\xff\xfe\xfd\""}, + {"uppercase \\b", `"\B"`}, + {"uppercase \\t", `"\T"`}, + {"uppercase \\n", `"\N"`}, + {"uppercase \\f", `"\F"`}, + {"uppercase \\r", `"\R"`}, + {"lone surrogate", `"\uD800"`}, + {"lone trailing surrogate", `"\uDC00"`}, + {"floating point number", `{"a": 1.2}`}, + {"plain decimal 0.1", `{"a": 0.1}`}, + {"non-integer that rounds out of range", `{"a": 9007199254740991.5}`}, + {"too large number", `{"a": 9007199254740992}`}, + {"too small number", `{"a": -9007199254740992}`}, + {"zero-prefixed negative number", `{"a": -010}`}, + {"zero-prefixed number", `{"a": 010}`}, + {"big exponent number", `{"a": 1e100}`}, + {"too large via 1e16", `{"a": 1e16}`}, + {"too large via negative exponent", `{"a": 90071992547409920e-1}`}, + {"non-integer small exponent", `{"a": 1e-10}`}, + {"smallest subnormal float", `{"a": 5e-324}`}, + {"trailing comma", `{"a":1,}`}, + {"JS-style comment", `{/*hi*/"a":1}`}, + {"NaN literal", `{"a": NaN}`}, + {"Infinity literal", `{"a": Infinity}`}, + {"unquoted key", `{a:1}`}, + {"trailing data", `{}{}`}, + {"empty input", ``}, +} + +func TestCanonicalize_Error(t *testing.T) { + for _, test := range canonicalizeErrorTests { + t.Run(test.name, func(t *testing.T) { + val := jsontext.Value(test.input) + err := canonicaljson.Canonicalize(&val) + assert.Error(t, err, "Expected error, got %s instead", val) + }) + } +} +func TestMarshal_Roundtrip_Error(t *testing.T) { + for _, test := range canonicalizeErrorTests { + t.Run(test.name, func(t *testing.T) { + var temp any + err := json.Unmarshal([]byte(test.input), &temp) + if err != nil { + return // error during unmarshal is fine + } + out, err := canonicaljson.Marshal(temp) + assert.Error(t, err, "Expected error, got %s instead", out) + }) + } +} + +type unknownMap struct { + Foo string `json:"foo"` + Unknown map[string]any `json:",unknown"` +} + +type unknownRaw struct { + Foo string `json:"foo"` + Unknown jsontext.Value `json:",unknown"` +} + +func TestMarshal_Error(t *testing.T) { + var tests = []struct { + name string + input any + }{ + {"invalid UTF-8", m{"a": "\xff\xfe\xfd"}}, + {"floating point number", m{"a": 1.2}}, + {"too large number", m{"a": 9007199254740992}}, + {"too small number", m{"a": -9007199254740992}}, + {"big exponent number", m{"a": 1e100}}, + {"negative zero", m{"a": math.Copysign(0, -1)}}, + {"NaN", m{"a": math.NaN()}}, + {"+Inf", m{"a": math.Inf(1)}}, + {"-Inf", m{"a": math.Inf(-1)}}, + {"non-integer float", m{"a": 1.5}}, + {"uint64 way too large", m{"a": ^uint64(0)}}, + {"duplicate key in map unknown", unknownMap{Foo: "meow", Unknown: map[string]any{"foo": "woof"}}}, + {"duplicate key in raw unknown", unknownRaw{Foo: "meow", Unknown: jsontext.Value(`{"foo": "woof"}`)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Go 1.25 emitted NaN and infinities as strings + // TODO remove this check after dropping Go 1.25 support + if strings.HasPrefix(runtime.Version(), "go1.25") && (test.name == "NaN" || test.name == "+Inf" || test.name == "-Inf") { + t.SkipNow() + } + out, err := canonicaljson.Marshal(test.input) + assert.Error(t, err, "Expected error, got %s instead", out) + }) + } +} diff --git a/mautrix-patched/crypto/cross_sign_key.go b/mautrix-patched/crypto/cross_sign_key.go new file mode 100644 index 00000000..5d9bf5b3 --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_key.go @@ -0,0 +1,161 @@ +// Copyright (c) 2020 Nikos Filippakis +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "fmt" + + "go.mau.fi/util/jsonbytes" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/id" +) + +// CrossSigningKeysCache holds the three cross-signing keys for the current user. +type CrossSigningKeysCache struct { + MasterKey olm.PKSigning + SelfSigningKey olm.PKSigning + UserSigningKey olm.PKSigning +} + +func (cskc *CrossSigningKeysCache) PublicKeys() *CrossSigningPublicKeysCache { + return &CrossSigningPublicKeysCache{ + MasterKey: cskc.MasterKey.PublicKey(), + SelfSigningKey: cskc.SelfSigningKey.PublicKey(), + UserSigningKey: cskc.UserSigningKey.PublicKey(), + } +} + +type CrossSigningSeeds struct { + MasterKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.master"` + SelfSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.self_signing"` + UserSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.user_signing"` +} + +func (mach *OlmMachine) ExportCrossSigningKeys() CrossSigningSeeds { + return CrossSigningSeeds{ + MasterKey: mach.CrossSigningKeys.MasterKey.Seed(), + SelfSigningKey: mach.CrossSigningKeys.SelfSigningKey.Seed(), + UserSigningKey: mach.CrossSigningKeys.UserSigningKey.Seed(), + } +} + +func (mach *OlmMachine) ImportCrossSigningKeys(keys CrossSigningSeeds) (err error) { + var keysCache CrossSigningKeysCache + if keysCache.MasterKey, err = olm.NewPKSigningFromSeed(keys.MasterKey); err != nil { + return + } + if keysCache.SelfSigningKey, err = olm.NewPKSigningFromSeed(keys.SelfSigningKey); err != nil { + return + } + if keysCache.UserSigningKey, err = olm.NewPKSigningFromSeed(keys.UserSigningKey); err != nil { + return + } + + mach.Log.Debug(). + Str("master", keysCache.MasterKey.PublicKey().String()). + Str("self_signing", keysCache.SelfSigningKey.PublicKey().String()). + Str("user_signing", keysCache.UserSigningKey.PublicKey().String()). + Msg("Imported own cross-signing keys") + + mach.CrossSigningKeys = &keysCache + mach.crossSigningPubkeys = keysCache.PublicKeys() + return +} + +// GenerateCrossSigningKeys generates new cross-signing keys. +func (mach *OlmMachine) GenerateCrossSigningKeys() (*CrossSigningKeysCache, error) { + var keysCache CrossSigningKeysCache + var err error + if keysCache.MasterKey, err = olm.NewPKSigning(); err != nil { + return nil, fmt.Errorf("failed to generate master key: %w", err) + } + if keysCache.SelfSigningKey, err = olm.NewPKSigning(); err != nil { + return nil, fmt.Errorf("failed to generate self-signing key: %w", err) + } + if keysCache.UserSigningKey, err = olm.NewPKSigning(); err != nil { + return nil, fmt.Errorf("failed to generate user-signing key: %w", err) + } + mach.Log.Debug(). + Str("master", keysCache.MasterKey.PublicKey().String()). + Str("self_signing", keysCache.SelfSigningKey.PublicKey().String()). + Str("user_signing", keysCache.UserSigningKey.PublicKey().String()). + Msg("Generated cross-signing keys") + return &keysCache, nil +} + +// PublishCrossSigningKeys signs and uploads the public keys of the given cross-signing keys to the server. +func (mach *OlmMachine) PublishCrossSigningKeys(ctx context.Context, keys *CrossSigningKeysCache, uiaCallback mautrix.UIACallback) error { + userID := mach.Client.UserID + masterKeyID := id.NewKeyID(id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String()) + masterKey := mautrix.CrossSigningKeys{ + UserID: userID, + Usage: []id.CrossSigningUsage{id.XSUsageMaster}, + Keys: map[id.KeyID]id.Ed25519{ + masterKeyID: keys.MasterKey.PublicKey(), + }, + } + masterSig, err := mach.account.SignJSON(masterKey) + if err != nil { + return fmt.Errorf("failed to sign master key: %w", err) + } + masterKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, mach.Client.DeviceID.String(), masterSig) + + selfKey := mautrix.CrossSigningKeys{ + UserID: userID, + Usage: []id.CrossSigningUsage{id.XSUsageSelfSigning}, + Keys: map[id.KeyID]id.Ed25519{ + id.NewKeyID(id.KeyAlgorithmEd25519, keys.SelfSigningKey.PublicKey().String()): keys.SelfSigningKey.PublicKey(), + }, + } + selfSig, err := keys.MasterKey.SignJSON(selfKey) + if err != nil { + return fmt.Errorf("failed to sign self-signing key: %w", err) + } + selfKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), selfSig) + + userKey := mautrix.CrossSigningKeys{ + UserID: userID, + Usage: []id.CrossSigningUsage{id.XSUsageUserSigning}, + Keys: map[id.KeyID]id.Ed25519{ + id.NewKeyID(id.KeyAlgorithmEd25519, keys.UserSigningKey.PublicKey().String()): keys.UserSigningKey.PublicKey(), + }, + } + userSig, err := keys.MasterKey.SignJSON(userKey) + if err != nil { + return fmt.Errorf("failed to sign user-signing key: %w", err) + } + userKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), userSig) + + err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq[any]{ + Master: masterKey, + SelfSigning: selfKey, + UserSigning: userKey, + }, uiaCallback) + if err != nil { + return err + } + + if err := mach.CryptoStore.PutSignature(ctx, userID, keys.MasterKey.PublicKey(), userID, mach.account.SigningKey(), masterSig); err != nil { + return fmt.Errorf("error storing signature of master key by device signing key in crypto store: %w", err) + } + if err := mach.CryptoStore.PutSignature(ctx, userID, keys.SelfSigningKey.PublicKey(), userID, keys.MasterKey.PublicKey(), selfSig); err != nil { + return fmt.Errorf("error storing signature of self-signing key by master key in crypto store: %w", err) + } + if err := mach.CryptoStore.PutSignature(ctx, userID, keys.UserSigningKey.PublicKey(), userID, keys.MasterKey.PublicKey(), userSig); err != nil { + return fmt.Errorf("error storing signature of user-signing key by master key in crypto store: %w", err) + } + + mach.CrossSigningKeys = keys + mach.crossSigningPubkeys = keys.PublicKeys() + + return nil +} diff --git a/mautrix-patched/crypto/cross_sign_pubkey.go b/mautrix-patched/crypto/cross_sign_pubkey.go new file mode 100644 index 00000000..63f660e5 --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_pubkey.go @@ -0,0 +1,138 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type CrossSigningPublicKeysCache struct { + MasterKey id.Ed25519 + SelfSigningKey id.Ed25519 + UserSigningKey id.Ed25519 +} + +func (mach *OlmMachine) GetOwnCrossSigningVerificationStatus(ctx context.Context) (masterKeyVerified, selfSigningKeyVerified, userSigningKeyVerified bool, err error) { + var pubkeys *CrossSigningPublicKeysCache + pubkeys, err = mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil || pubkeys == nil { + return + } + // If we have the private keys, it means we trust the public keys too + if mach.CrossSigningKeys != nil && *mach.CrossSigningKeys.PublicKeys() == *pubkeys { + return true, true, true, nil + } + masterKeyVerified, err = mach.CryptoStore.IsKeySignedBy( + ctx, mach.Client.UserID, pubkeys.MasterKey, mach.Client.UserID, mach.account.SigningKey(), + ) + if err != nil { + err = fmt.Errorf("failed to check if master key is signed by current device key: %w", err) + return + } + selfSigningKeyVerified, err = mach.CryptoStore.IsKeySignedBy( + ctx, mach.Client.UserID, pubkeys.SelfSigningKey, mach.Client.UserID, pubkeys.MasterKey, + ) + if err != nil { + err = fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err) + return + } + userSigningKeyVerified, err = mach.CryptoStore.IsKeySignedBy( + ctx, mach.Client.UserID, pubkeys.UserSigningKey, mach.Client.UserID, pubkeys.MasterKey, + ) + if err != nil { + err = fmt.Errorf("failed to check if user-signing key is signed by master key: %w", err) + } + return +} + +func (mach *OlmMachine) GetOwnVerificationStatus(ctx context.Context) (hasKeys, isVerified bool, err error) { + var pubkeys *CrossSigningPublicKeysCache + pubkeys, err = mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil || pubkeys == nil { + return + } + hasKeys = true + isVerified, err = mach.CryptoStore.IsKeySignedBy( + ctx, mach.Client.UserID, mach.GetAccount().SigningKey(), mach.Client.UserID, pubkeys.SelfSigningKey, + ) + if err != nil { + err = fmt.Errorf("failed to check if current device is signed by own self-signing key: %w", err) + } + return +} + +func (mach *OlmMachine) GetOwnCrossSigningPublicKeys(ctx context.Context) (*CrossSigningPublicKeysCache, error) { + if mach.crossSigningPubkeys != nil { + return mach.crossSigningPubkeys, nil + } + if mach.CrossSigningKeys != nil { + mach.crossSigningPubkeys = mach.CrossSigningKeys.PublicKeys() + return mach.crossSigningPubkeys, nil + } + if mach.crossSigningPubkeysFetched { + return nil, nil + } + cspk, err := mach.GetCrossSigningPublicKeys(ctx, mach.Client.UserID) + if err != nil { + return nil, err + } + mach.crossSigningPubkeys = cspk + mach.crossSigningPubkeysFetched = true + return mach.crossSigningPubkeys, nil +} + +func (mach *OlmMachine) GetCrossSigningPublicKeys(ctx context.Context, userID id.UserID) (*CrossSigningPublicKeysCache, error) { + dbKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get keys from database: %w", err) + } + if len(dbKeys) > 0 { + masterKey, ok := dbKeys[id.XSUsageMaster] + if ok { + selfSigning := dbKeys[id.XSUsageSelfSigning] + userSigning := dbKeys[id.XSUsageUserSigning] + return &CrossSigningPublicKeysCache{ + MasterKey: masterKey.Key, + SelfSigningKey: selfSigning.Key, + UserSigningKey: userSigning.Key, + }, nil + } + } + + keys, err := mach.Client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ + DeviceKeys: mautrix.DeviceKeysRequest{ + userID: mautrix.DeviceIDList{}, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to query keys: %w", err) + } + + var cspk CrossSigningPublicKeysCache + + masterKeys, ok := keys.MasterKeys[userID] + if !ok { + return nil, nil + } + cspk.MasterKey = masterKeys.FirstKey() + + selfSigningKeys, ok := keys.SelfSigningKeys[userID] + if !ok { + return nil, nil + } + cspk.SelfSigningKey = selfSigningKeys.FirstKey() + + userSigningKeys, ok := keys.UserSigningKeys[userID] + if ok { + cspk.UserSigningKey = userSigningKeys.FirstKey() + } + return &cspk, nil +} diff --git a/mautrix-patched/crypto/cross_sign_signing.go b/mautrix-patched/crypto/cross_sign_signing.go new file mode 100644 index 00000000..4dead803 --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_signing.go @@ -0,0 +1,204 @@ +// Copyright (c) 2020 Nikos Filippakis +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "errors" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/id" +) + +var ( + ErrCrossSigningPubkeysNotCached = errors.New("cross-signing public keys not in cache") + ErrUserSigningKeyNotCached = errors.New("user-signing private key not in cache") + ErrSelfSigningKeyNotCached = errors.New("self-signing private key not in cache") + ErrSignatureUploadFail = errors.New("server-side failure uploading signatures") + ErrCantSignOwnMasterKey = errors.New("signing your own master key is not allowed") + ErrCantSignOtherDevice = errors.New("signing other users' devices is not allowed") + ErrUserNotInQueryResponse = errors.New("could not find user in query keys response") + ErrDeviceNotInQueryResponse = errors.New("could not find device in query keys response") + ErrOlmAccountNotLoaded = errors.New("olm account has not been loaded") + + ErrCrossSigningMasterKeyNotFound = errors.New("cross-signing master key not found") + ErrMasterKeyMACNotFound = errors.New("found cross-signing master key, but didn't find corresponding MAC in verification request") + ErrMismatchingMasterKeyMAC = errors.New("mismatching cross-signing master key MAC") +) + +// SignUser creates a cross-signing signature for a user, stores it and uploads it to the server. +func (mach *OlmMachine) SignUser(ctx context.Context, userID id.UserID, masterKey id.Ed25519) error { + if userID == mach.Client.UserID { + return ErrCantSignOwnMasterKey + } else if mach.CrossSigningKeys == nil || mach.CrossSigningKeys.UserSigningKey == nil { + return ErrUserSigningKeyNotCached + } + + masterKeyObj := mautrix.ReqKeysSignatures{ + UserID: userID, + Usage: []id.CrossSigningUsage{id.XSUsageMaster}, + Keys: map[id.KeyID]string{ + id.NewKeyID(id.KeyAlgorithmEd25519, masterKey.String()): masterKey.String(), + }, + } + + signature, err := mach.signAndUpload(ctx, masterKeyObj, userID, masterKey.String(), mach.CrossSigningKeys.UserSigningKey) + if err != nil { + return err + } + + mach.Log.Debug(). + Str("user_id", userID.String()). + Str("signature", signature). + Msg("Signed master key of user with our user-signing key") + + if err := mach.CryptoStore.PutSignature(ctx, userID, masterKey, mach.Client.UserID, mach.CrossSigningKeys.UserSigningKey.PublicKey(), signature); err != nil { + return fmt.Errorf("error storing signature in crypto store: %w", err) + } + + return nil +} + +// SignOwnMasterKey uses the current account for signing the current user's master key and uploads the signature. +func (mach *OlmMachine) SignOwnMasterKey(ctx context.Context) error { + crossSigningPubkeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } else if crossSigningPubkeys == nil { + return ErrCrossSigningPubkeysNotCached + } else if mach.account == nil { + return ErrOlmAccountNotLoaded + } + + userID := mach.Client.UserID + deviceID := mach.Client.DeviceID + masterKey := crossSigningPubkeys.MasterKey + + masterKeyObj := mautrix.ReqKeysSignatures{ + UserID: userID, + Usage: []id.CrossSigningUsage{id.XSUsageMaster}, + Keys: map[id.KeyID]string{ + id.NewKeyID(id.KeyAlgorithmEd25519, masterKey.String()): masterKey.String(), + }, + } + signature, err := mach.account.SignJSON(masterKeyObj) + if err != nil { + return fmt.Errorf("failed to sign JSON: %w", err) + } + masterKeyObj.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, deviceID.String(), signature) + mach.Log.Debug(). + Str("device_id", deviceID.String()). + Str("signature", signature). + Msg("Signed own master key with own device key") + + resp, err := mach.Client.UploadSignatures(ctx, &mautrix.ReqUploadSignatures{ + userID: map[string]mautrix.ReqKeysSignatures{ + masterKey.String(): masterKeyObj, + }, + }) + + if err != nil { + return fmt.Errorf("error while uploading signatures: %w", err) + } else if len(resp.Failures) > 0 { + return fmt.Errorf("%w: %+v", ErrSignatureUploadFail, resp.Failures) + } + + if err := mach.CryptoStore.PutSignature(ctx, userID, masterKey, userID, mach.account.SigningKey(), signature); err != nil { + return fmt.Errorf("error storing signature in crypto store: %w", err) + } + + return nil +} + +// SignOwnDevice creates a cross-signing signature for a device belonging to the current user and uploads it to the server. +func (mach *OlmMachine) SignOwnDevice(ctx context.Context, device *id.Device) error { + if device.UserID != mach.Client.UserID { + return ErrCantSignOtherDevice + } else if mach.CrossSigningKeys == nil || mach.CrossSigningKeys.SelfSigningKey == nil { + return ErrSelfSigningKeyNotCached + } + + deviceKeys, err := mach.getFullDeviceKeys(ctx, device) + if err != nil { + return err + } + + deviceKeyObj := mautrix.ReqKeysSignatures{ + UserID: device.UserID, + DeviceID: device.DeviceID, + Algorithms: deviceKeys.Algorithms, + Keys: make(map[id.KeyID]string), + } + for keyID, key := range deviceKeys.Keys { + deviceKeyObj.Keys[id.KeyID(keyID)] = key + } + + signature, err := mach.signAndUpload(ctx, deviceKeyObj, device.UserID, device.DeviceID.String(), mach.CrossSigningKeys.SelfSigningKey) + if err != nil { + return err + } + + mach.Log.Debug(). + Str("user_id", device.UserID.String()). + Str("device_id", device.DeviceID.String()). + Str("signature", signature). + Msg("Signed own device key with self-signing key") + + if err := mach.CryptoStore.PutSignature(ctx, device.UserID, device.SigningKey, mach.Client.UserID, mach.CrossSigningKeys.SelfSigningKey.PublicKey(), signature); err != nil { + return fmt.Errorf("error storing signature in crypto store: %w", err) + } + + return nil +} + +// getFullDeviceKeys gets the full device keys object for the given device. +// This is used because we don't cache some of the details like list of algorithms and unsupported key types. +func (mach *OlmMachine) getFullDeviceKeys(ctx context.Context, device *id.Device) (*mautrix.DeviceKeys, error) { + devicesKeys, err := mach.Client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ + DeviceKeys: mautrix.DeviceKeysRequest{ + device.UserID: mautrix.DeviceIDList{device.DeviceID}, + }, + }) + if err != nil { + return nil, fmt.Errorf("error querying device keys for %s: %w", device.DeviceID, err) + } + userKeys, ok := devicesKeys.DeviceKeys[device.UserID] + if !ok { + return nil, ErrUserNotInQueryResponse + } + deviceKeys, ok := userKeys[device.DeviceID] + if !ok { + return nil, ErrDeviceNotInQueryResponse + } + _, err = mach.validateDevice(device.UserID, device.DeviceID, deviceKeys, device) + return &deviceKeys, err +} + +// signAndUpload signs the given key signatures object and uploads it to the server. +func (mach *OlmMachine) signAndUpload(ctx context.Context, req mautrix.ReqKeysSignatures, userID id.UserID, signedThing string, key olm.PKSigning) (string, error) { + signature, err := key.SignJSON(req) + if err != nil { + return "", fmt.Errorf("failed to sign JSON: %w", err) + } + req.Signatures = signatures.NewSingleSignature(mach.Client.UserID, id.KeyAlgorithmEd25519, key.PublicKey().String(), signature) + + resp, err := mach.Client.UploadSignatures(ctx, &mautrix.ReqUploadSignatures{ + userID: map[string]mautrix.ReqKeysSignatures{ + signedThing: req, + }, + }) + if err != nil { + return "", fmt.Errorf("error while uploading signatures: %w", err) + } else if len(resp.Failures) > 0 { + return "", fmt.Errorf("%w: %+v", ErrSignatureUploadFail, resp.Failures) + } + return signature, nil +} diff --git a/mautrix-patched/crypto/cross_sign_ssss.go b/mautrix-patched/crypto/cross_sign_ssss.go new file mode 100644 index 00000000..fd42880d --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_ssss.go @@ -0,0 +1,176 @@ +// Copyright (c) 2020 Nikos Filippakis +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "errors" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/ssss" + "maunium.net/go/mautrix/crypto/utils" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// FetchCrossSigningKeysFromSSSS fetches all the cross-signing keys from SSSS, decrypts them using the given key and stores them in the olm machine. +func (mach *OlmMachine) FetchCrossSigningKeysFromSSSS(ctx context.Context, key *ssss.Key) error { + masterKey, err := mach.retrieveDecryptXSigningKey(ctx, event.AccountDataCrossSigningMaster, key) + if err != nil { + return err + } + selfSignKey, err := mach.retrieveDecryptXSigningKey(ctx, event.AccountDataCrossSigningSelf, key) + if err != nil { + return err + } + userSignKey, err := mach.retrieveDecryptXSigningKey(ctx, event.AccountDataCrossSigningUser, key) + if err != nil { + return err + } + + return mach.ImportCrossSigningKeys(CrossSigningSeeds{ + MasterKey: masterKey[:], + SelfSigningKey: selfSignKey[:], + UserSigningKey: userSignKey[:], + }) +} + +// retrieveDecryptXSigningKey retrieves the requested cross-signing key from SSSS and decrypts it using the given SSSS key. +func (mach *OlmMachine) retrieveDecryptXSigningKey(ctx context.Context, keyName event.Type, key *ssss.Key) ([utils.AESCTRKeyLength]byte, error) { + var decryptedKey [utils.AESCTRKeyLength]byte + var encData ssss.EncryptedAccountDataEventContent + + // retrieve and parse the account data for this key type from SSSS + err := mach.Client.GetAccountData(ctx, keyName.Type, &encData) + if err != nil { + return decryptedKey, err + } + + decrypted, err := encData.Decrypt(keyName.Type, key) + if err != nil { + return decryptedKey, err + } + copy(decryptedKey[:], decrypted) + return decryptedKey, nil +} + +func (mach *OlmMachine) GenerateAndUploadCrossSigningKeysWithPassword(ctx context.Context, userPassword, passphrase string) (string, *CrossSigningKeysCache, error) { + return mach.GenerateAndUploadCrossSigningKeys(ctx, func(uiResp *mautrix.RespUserInteractive) interface{} { + return &mautrix.ReqUIAuthLogin{ + BaseAuthData: mautrix.BaseAuthData{ + Type: mautrix.AuthTypePassword, + Session: uiResp.Session, + }, + User: mach.Client.UserID.String(), + Password: userPassword, + } + }, passphrase) +} + +func (mach *OlmMachine) VerifyWithRecoveryKey(ctx context.Context, recoveryKey string) error { + keyID, keyData, err := mach.SSSS.GetDefaultKeyData(ctx) + if err != nil { + return fmt.Errorf("failed to get default SSSS key data: %w", err) + } + key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey) + if errors.Is(err, ssss.ErrUnverifiableKey) { + mach.machOrContextLog(ctx).Warn(). + Str("key_id", keyID). + Msg("SSSS key is unverifiable, trying to use without verifying") + } else if err != nil { + return err + } + err = mach.FetchCrossSigningKeysFromSSSS(ctx, key) + if err != nil { + return fmt.Errorf("failed to fetch cross-signing keys from SSSS: %w", err) + } + err = mach.SignOwnDevice(ctx, mach.OwnIdentity()) + if err != nil { + return fmt.Errorf("failed to sign own device: %w", err) + } + err = mach.SignOwnMasterKey(ctx) + if err != nil { + return fmt.Errorf("failed to sign own master key: %w", err) + } + return nil +} + +func (mach *OlmMachine) GenerateAndVerifyWithRecoveryKey(ctx context.Context) (recoveryKey string, err error) { + recoveryKey, _, err = mach.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + if err != nil { + err = fmt.Errorf("failed to generate and upload cross-signing keys: %w", err) + } else if err = mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil { + err = fmt.Errorf("failed to sign own device: %w", err) + } else if err = mach.SignOwnMasterKey(ctx); err != nil { + err = fmt.Errorf("failed to sign own master key: %w", err) + } + return +} + +// GenerateAndUploadCrossSigningKeys generates a new key with all corresponding cross-signing keys. +// +// A passphrase can be provided to generate the SSSS key. If the passphrase is empty, a random key +// is used. The base58-formatted recovery key is the first return parameter. +// +// The account password of the user is required for uploading keys to the server. +func (mach *OlmMachine) GenerateAndUploadCrossSigningKeys(ctx context.Context, uiaCallback mautrix.UIACallback, passphrase string) (string, *CrossSigningKeysCache, error) { + key, err := mach.SSSS.GenerateAndUploadKey(ctx, passphrase) + if err != nil { + return "", nil, fmt.Errorf("failed to generate and upload SSSS key: %w", err) + } + + // generate the three cross-signing keys + keysCache, err := mach.GenerateCrossSigningKeys() + if err != nil { + return "", nil, err + } + + // Store the private keys in SSSS + if err := mach.UploadCrossSigningKeysToSSSS(ctx, key, keysCache); err != nil { + return "", nil, fmt.Errorf("failed to upload cross-signing keys to SSSS: %w", err) + } + + // Publish cross-signing keys + err = mach.PublishCrossSigningKeys(ctx, keysCache, uiaCallback) + if err != nil { + return key.RecoveryKey(), keysCache, fmt.Errorf("failed to publish cross-signing keys: %w", err) + } + + err = mach.SSSS.SetDefaultKeyID(ctx, key.ID) + if err != nil { + return key.RecoveryKey(), keysCache, fmt.Errorf("failed to mark %s as the default key: %w", key.ID, err) + } + + return key.RecoveryKey(), keysCache, nil +} + +// UploadCrossSigningKeysToSSSS stores the given cross-signing keys on the server encrypted with the given key. +func (mach *OlmMachine) UploadCrossSigningKeysToSSSS(ctx context.Context, key *ssss.Key, keys *CrossSigningKeysCache) error { + if err := mach.SSSS.SetEncryptedAccountData(ctx, event.AccountDataCrossSigningMaster, keys.MasterKey.Seed(), key); err != nil { + return err + } + if err := mach.SSSS.SetEncryptedAccountData(ctx, event.AccountDataCrossSigningSelf, keys.SelfSigningKey.Seed(), key); err != nil { + return err + } + if err := mach.SSSS.SetEncryptedAccountData(ctx, event.AccountDataCrossSigningUser, keys.UserSigningKey.Seed(), key); err != nil { + return err + } + + // Also store these locally + if err := mach.CryptoStore.PutCrossSigningKey(ctx, mach.Client.UserID, id.XSUsageMaster, keys.MasterKey.PublicKey()); err != nil { + return err + } + if err := mach.CryptoStore.PutCrossSigningKey(ctx, mach.Client.UserID, id.XSUsageSelfSigning, keys.SelfSigningKey.PublicKey()); err != nil { + return err + } + if err := mach.CryptoStore.PutCrossSigningKey(ctx, mach.Client.UserID, id.XSUsageUserSigning, keys.UserSigningKey.PublicKey()); err != nil { + return err + } + + return nil +} diff --git a/mautrix-patched/crypto/cross_sign_store.go b/mautrix-patched/crypto/cross_sign_store.go new file mode 100644 index 00000000..8fae1dc5 --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_store.go @@ -0,0 +1,110 @@ +// Copyright (c) 2020 Nikos Filippakis +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + + "go.mau.fi/util/exzerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/id" +) + +func (mach *OlmMachine) storeCrossSigningKeys(ctx context.Context, crossSigningKeys map[id.UserID]mautrix.CrossSigningKeys, deviceKeys map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys) { + log := mach.machOrContextLog(ctx) + for userID, userKeys := range crossSigningKeys { + log := log.With().Stringer("user_id", userID).Logger() + currentKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) + if err != nil { + log.Error().Err(err). + Msg("Error fetching current cross-signing keys of user") + } + for curKeyUsage, curKey := range currentKeys { + log := log.With().Stringer("old_key", curKey.Key).Str("old_key_usage", string(curKeyUsage)).Logger() + // got a new key with the same usage as an existing key + for _, newKeyUsage := range userKeys.Usage { + if newKeyUsage == curKeyUsage { + if _, ok := userKeys.Keys[id.NewKeyID(id.KeyAlgorithmEd25519, curKey.Key.String())]; !ok { + // old key is not in the new key map, so we drop signatures made by it + if count, err := mach.CryptoStore.DropSignaturesByKey(ctx, userID, curKey.Key); err != nil { + log.Error().Err(err).Msg("Error deleting old signatures made by user") + } else { + log.Debug(). + Int64("signature_count", count). + Msg("Dropped signatures made by old key as it has been replaced") + } + } + break + } + } + } + + for _, key := range userKeys.Keys { + log := log.With().Stringer("key", key).Array("usages", exzerolog.ArrayOfStrs(userKeys.Usage)).Logger() + for _, usage := range userKeys.Usage { + // Note: we store keys before verification, but that's fine because the presence of keys in the table + // doesn't imply it's trusted. All code paths that want trusted keys will also check the signature table. + // Also, PutCrossSigningKey will not overwrite the first seen key. + log.Trace().Str("usage", string(usage)).Msg("Storing cross-signing key") + if err = mach.CryptoStore.PutCrossSigningKey(ctx, userID, usage, key); err != nil { + log.Error().Err(err).Msg("Error storing cross-signing key") + } + } + + for signUserID, keySigs := range userKeys.Signatures { + for signKeyID, signature := range keySigs { + _, signKeyName := signKeyID.Parse() + signingKey := id.Ed25519(signKeyName) + log := log.With(). + Str("sign_key_id", signKeyID.String()). + Str("signer_user_id", signUserID.String()). + Str("signing_key", signingKey.String()). + Logger() + // if the signer is one of this user's own devices, find the key from the key ID + if signUserID == userID { + ownDeviceID := id.DeviceID(signKeyName) + if ownDeviceKeys, ok := deviceKeys[userID][ownDeviceID]; ok { + signingKey = ownDeviceKeys.Keys.GetEd25519(ownDeviceID) + log.Trace(). + Str("device_id", signKeyName). + Msg("Treating key name as device ID") + } + } + if len(signingKey) != 43 { + log.Trace().Msg("Cross-signing key has a signature from an unknown key") + continue + } + + log.Trace().Msg("Verifying cross-signing key signature") + if verified, err := signatures.VerifySignatureJSON(userKeys, signUserID, signKeyName, signingKey); err != nil { + log.Warn().Err(err).Msg("Error verifying cross-signing key signature") + } else { + if verified { + log.Trace().Err(err).Msg("Cross-signing key signature verified") + err = mach.CryptoStore.PutSignature(ctx, userID, key, signUserID, signingKey, signature) + if err != nil { + log.Error().Err(err).Msg("Error storing cross-signing key signature") + } + } else { + log.Warn().Err(err).Msg("Cross-siging key signature is invalid") + } + } + } + } + } + + // Clear internal cache so that it refreshes from crypto store + if userID == mach.Client.UserID && mach.crossSigningPubkeys != nil { + log.Debug().Msg("Resetting internal cross-signing key cache") + mach.crossSigningPubkeys = nil + mach.crossSigningPubkeysFetched = false + } + } +} diff --git a/mautrix-patched/crypto/cross_sign_test.go b/mautrix-patched/crypto/cross_sign_test.go new file mode 100644 index 00000000..39c2abbb --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Nikos Filippakis +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "database/sql" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +var noopLogger = zerolog.Nop() + +func getOlmMachine(t *testing.T) *OlmMachine { + rawDB, err := sql.Open("sqlite3", ":memory:?_busy_timeout=5000") + require.NoError(t, err, "Error opening raw database") + db, err := dbutil.NewWithDB(rawDB, "sqlite3") + require.NoError(t, err, "Error creating database wrapper") + sqlStore := NewSQLCryptoStore(db, nil, "accid", id.DeviceID("dev"), []byte("test")) + err = sqlStore.DB.Upgrade(context.TODO()) + require.NoError(t, err, "Error upgrading database") + + userID := id.UserID("@mautrix") + mk, _ := olm.NewPKSigning() + ssk, _ := olm.NewPKSigning() + usk, _ := olm.NewPKSigning() + + sqlStore.PutCrossSigningKey(context.TODO(), userID, id.XSUsageMaster, mk.PublicKey()) + sqlStore.PutCrossSigningKey(context.TODO(), userID, id.XSUsageSelfSigning, ssk.PublicKey()) + sqlStore.PutCrossSigningKey(context.TODO(), userID, id.XSUsageUserSigning, usk.PublicKey()) + + return &OlmMachine{ + account: NewOlmAccount(), + CryptoStore: sqlStore, + CrossSigningKeys: &CrossSigningKeysCache{ + MasterKey: mk, + SelfSigningKey: ssk, + UserSigningKey: usk, + }, + Client: &mautrix.Client{ + UserID: userID, + DeviceID: "OWNDEVICEID", + }, + Log: &noopLogger, + } +} + +func TestTrustOwnDevice(t *testing.T) { + m := getOlmMachine(t) + ownDevice := m.OwnIdentity() + ownDevice.Trust = id.TrustStateUnset + assert.False(t, m.IsDeviceTrusted(context.TODO(), ownDevice), "Own device trusted while it shouldn't be") + trustState, err := m.ResolveTrustContext(context.TODO(), ownDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateUnset, trustState) + + csKeys := m.CrossSigningKeys + m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, csKeys.SelfSigningKey.PublicKey(), ownDevice.UserID, csKeys.MasterKey.PublicKey(), "sig1") + m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, csKeys.UserSigningKey.PublicKey(), ownDevice.UserID, csKeys.MasterKey.PublicKey(), "sig1") + m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, ownDevice.SigningKey, + ownDevice.UserID, csKeys.SelfSigningKey.PublicKey(), "sig2") + + trusted, err := m.IsUserTrusted(context.TODO(), ownDevice.UserID) + require.NoError(t, err, "Error checking if own user is trusted") + assert.True(t, trusted, "Own user not trusted while they should be") + assert.True(t, m.IsDeviceTrusted(context.TODO(), ownDevice), "Own device not trusted while it should be") + + trustState, err = m.ResolveTrustContext(context.TODO(), ownDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateCrossSignedVerified, trustState) + + // Without cross-signing private keys, we don't know if they're really ours + m.CrossSigningKeys = nil + trustState, err = m.ResolveTrustContext(context.TODO(), ownDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateCrossSignedTOFU, trustState) + + // Now sign the master key with the current device to confirm it's ours + m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, csKeys.MasterKey.PublicKey(), ownDevice.UserID, ownDevice.SigningKey, "sig3") + trustState, err = m.ResolveTrustContext(context.TODO(), ownDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateCrossSignedVerified, trustState) +} + +func TestTrustOtherUser(t *testing.T) { + m := getOlmMachine(t) + otherUser := id.UserID("@anotheruser") + trusted, err := m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.False(t, trusted, "Other user trusted while they shouldn't be") + + theirMasterKey, _ := olm.NewPKSigning() + m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, theirMasterKey.PublicKey()) + + m.CryptoStore.PutSignature(context.TODO(), m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), + m.Client.UserID, m.CrossSigningKeys.MasterKey.PublicKey(), "sig1") + + // sign them with self-signing instead of user-signing key + m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), + m.Client.UserID, m.CrossSigningKeys.SelfSigningKey.PublicKey(), "invalid_sig") + + trusted, err = m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.False(t, trusted, "Other user trusted before their master key has been signed with our user-signing key") + + m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), + m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), "sig2") + + trusted, err = m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.True(t, trusted, "Other user not trusted while they should be") +} + +func TestTrustOtherDevice(t *testing.T) { + m := getOlmMachine(t) + otherUser := id.UserID("@anotheruser") + theirDevice := &id.Device{ + UserID: otherUser, + DeviceID: "theirDevice", + SigningKey: id.Ed25519("theirDeviceKey"), + } + + trusted, err := m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.False(t, trusted, "Other user trusted while they shouldn't be") + assert.False(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device trusted while it shouldn't be") + + trustState, err := m.ResolveTrustContext(context.TODO(), theirDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateUnset, trustState) + + theirMasterKey, _ := olm.NewPKSigning() + m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, theirMasterKey.PublicKey()) + theirSSK, _ := olm.NewPKSigning() + m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageSelfSigning, theirSSK.PublicKey()) + + m.CryptoStore.PutSignature(context.TODO(), otherUser, theirSSK.PublicKey(), + otherUser, theirMasterKey.PublicKey(), "sig3") + + assert.False(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device trusted before it has been signed with user's SSK") + trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateUnset, trustState) + + m.CryptoStore.PutSignature(context.TODO(), otherUser, theirDevice.SigningKey, + otherUser, theirSSK.PublicKey(), "sig4") + assert.True(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device not trusted after it has been signed with user's SSK") + trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateCrossSignedTOFU, trustState) + + m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), + m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), "sig2") + trusted, err = m.IsUserTrusted(context.TODO(), otherUser) + require.NoError(t, err, "Error checking if other user is trusted") + assert.True(t, trusted, "Other user not trusted while they should be") + + trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateCrossSignedVerified, trustState) + + rotatedMasterKey, _ := olm.NewPKSigning() + m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, rotatedMasterKey.PublicKey()) + rotatedSSK, _ := olm.NewPKSigning() + m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageSelfSigning, rotatedSSK.PublicKey()) + + m.CryptoStore.PutSignature(context.TODO(), otherUser, rotatedSSK.PublicKey(), + otherUser, rotatedMasterKey.PublicKey(), "sig3") + m.CryptoStore.PutSignature(context.TODO(), otherUser, theirDevice.SigningKey, + otherUser, rotatedSSK.PublicKey(), "sig4") + + trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) + assert.NoError(t, err) + assert.Equal(t, id.TrustStateCrossSignedUntrusted, trustState) +} diff --git a/mautrix-patched/crypto/cross_sign_validation.go b/mautrix-patched/crypto/cross_sign_validation.go new file mode 100644 index 00000000..5444b682 --- /dev/null +++ b/mautrix-patched/crypto/cross_sign_validation.go @@ -0,0 +1,158 @@ +// Copyright (c) 2020 Nikos Filippakis +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "fmt" + + "maunium.net/go/mautrix/id" +) + +// ResolveTrust resolves the trust state of the device from cross-signing. +// +// Deprecated: This method doesn't take a context. Use [OlmMachine.ResolveTrustContext] instead. +func (mach *OlmMachine) ResolveTrust(device *id.Device) id.TrustState { + state, _ := mach.ResolveTrustContext(context.Background(), device) + return state +} + +// ResolveTrustContext resolves the trust state of the device from cross-signing. +func (mach *OlmMachine) ResolveTrustContext(ctx context.Context, device *id.Device) (id.TrustState, error) { + if device.Trust == id.TrustStateVerified || device.Trust == id.TrustStateBlacklisted { + return device.Trust, nil + } + theirKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, device.UserID) + if err != nil { + mach.machOrContextLog(ctx).Error().Err(err). + Str("user_id", device.UserID.String()). + Msg("Error retrieving cross-signing key of user from database") + return id.TrustStateUnset, err + } + theirMSK, ok := theirKeys[id.XSUsageMaster] + if !ok { + mach.machOrContextLog(ctx).Debug(). + Str("user_id", device.UserID.String()). + Msg("Master key of user not found") + return id.TrustStateUnset, nil + } + if device.UserID == mach.Client.UserID { + ownKeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + mach.machOrContextLog(ctx).Err(err). + Str("user_id", device.UserID.String()). + Msg("Error retrieving own cross-signing keys from database") + return id.TrustStateUnset, err + } else if ownKeys != nil && ownKeys.MasterKey != theirMSK.Key { + mach.machOrContextLog(ctx).Error(). + Str("user_id", device.UserID.String()). + Stringer("new_master_key", theirMSK.Key). + Stringer("first_seen_master_key", theirMSK.First). + Stringer("cached_master_key", ownKeys.MasterKey). + Msg("Own master key has changed") + return id.TrustStateUnset, nil + } + } + theirSSK, ok := theirKeys[id.XSUsageSelfSigning] + if !ok { + mach.machOrContextLog(ctx).Error(). + Str("user_id", device.UserID.String()). + Msg("Self-signing key of user not found") + return id.TrustStateUnset, nil + } + sskSigExists, err := mach.CryptoStore.IsKeySignedBy(ctx, device.UserID, theirSSK.Key, device.UserID, theirMSK.Key) + if err != nil { + mach.machOrContextLog(ctx).Error().Err(err). + Str("user_id", device.UserID.String()). + Msg("Error retrieving cross-signing signatures for master key of user from database") + return id.TrustStateUnset, err + } + if !sskSigExists { + mach.machOrContextLog(ctx).Error(). + Str("user_id", device.UserID.String()). + Msg("Self-signing key of user is not signed by their master key") + return id.TrustStateUnset, nil + } + deviceSigExists, err := mach.CryptoStore.IsKeySignedBy(ctx, device.UserID, device.SigningKey, device.UserID, theirSSK.Key) + if err != nil { + mach.machOrContextLog(ctx).Error().Err(err). + Str("user_id", device.UserID.String()). + Str("device_key", device.SigningKey.String()). + Msg("Error retrieving cross-signing signatures for device from database") + return id.TrustStateUnset, err + } + if deviceSigExists { + if trusted, err := mach.IsUserTrusted(ctx, device.UserID); trusted { + return id.TrustStateCrossSignedVerified, err + } else if theirMSK.Key == theirMSK.First { + return id.TrustStateCrossSignedTOFU, nil + } + return id.TrustStateCrossSignedUntrusted, nil + } + return id.TrustStateUnset, nil +} + +// IsDeviceTrusted returns whether a device has been determined to be trusted either through verification or cross-signing. +// +// Note: this will return false if resolving the trust state fails due to database errors. +// Use [OlmMachine.ResolveTrustContext] if special error handling is required. +func (mach *OlmMachine) IsDeviceTrusted(ctx context.Context, device *id.Device) bool { + trust, _ := mach.ResolveTrustContext(ctx, device) + switch trust { + case id.TrustStateVerified, id.TrustStateCrossSignedTOFU, id.TrustStateCrossSignedVerified: + return true + default: + return false + } +} + +// IsUserTrusted returns whether a user has been determined to be trusted by our user-signing key having signed their master key. +// In the case the user ID is our own and we have successfully retrieved our cross-signing keys, we trust our own user. +func (mach *OlmMachine) IsUserTrusted(ctx context.Context, userID id.UserID) (bool, error) { + csPubkeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return false, fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } else if csPubkeys == nil { + return false, nil + } + mkTrusted, selfSigningTrusted, userSigningTrusted, err := mach.GetOwnCrossSigningVerificationStatus(ctx) + if err != nil { + return false, fmt.Errorf("failed to check if own cross-signing keys are trusted: %w", err) + } else if !mkTrusted || !userSigningTrusted { + mach.machOrContextLog(ctx).Warn(). + Bool("mk_trusted", mkTrusted). + Bool("usk_trusted", userSigningTrusted). + Msg("Own cross-signing keys are not fully trusted, treating other users as untrusted") + return false, nil + } + if userID == mach.Client.UserID { + return selfSigningTrusted, nil + } + theirKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) + if err != nil { + mach.machOrContextLog(ctx).Error().Err(err). + Str("user_id", userID.String()). + Msg("Error retrieving cross-signing key of user from database") + return false, err + } + theirMskKey, ok := theirKeys[id.XSUsageMaster] + if !ok { + mach.machOrContextLog(ctx).Error(). + Str("user_id", userID.String()). + Msg("Master key of user not found") + return false, nil + } + sigExists, err := mach.CryptoStore.IsKeySignedBy(ctx, userID, theirMskKey.Key, mach.Client.UserID, csPubkeys.UserSigningKey) + if err != nil { + mach.machOrContextLog(ctx).Error().Err(err). + Str("user_id", userID.String()). + Msg("Error retrieving cross-signing signatures for master key of user from database") + return false, err + } + return sigExists, nil +} diff --git a/mautrix-patched/crypto/cryptohelper/cryptohelper.go b/mautrix-patched/crypto/cryptohelper/cryptohelper.go new file mode 100644 index 00000000..b62dc128 --- /dev/null +++ b/mautrix-patched/crypto/cryptohelper/cryptohelper.go @@ -0,0 +1,440 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cryptohelper + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + _ "go.mau.fi/util/dbutil/litestream" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/sqlstatestore" +) + +type CryptoHelper struct { + client *mautrix.Client + mach *crypto.OlmMachine + log zerolog.Logger + lock sync.RWMutex + pickleKey []byte + + managedStateStore *sqlstatestore.SQLStateStore + unmanagedCryptoStore crypto.Store + dbForManagedStores *dbutil.Database + + DecryptErrorCallback func(*event.Event, error) + + MSC4190 bool + LoginAs *mautrix.ReqLogin + + ASEventProcessor crypto.ASEventProcessor + CustomPostDecrypt func(context.Context, *event.Event) + + DBAccountID string +} + +var _ mautrix.CryptoHelper = (*CryptoHelper)(nil) + +// NewCryptoHelper creates a struct that helps a mautrix client struct with Matrix e2ee operations. +// +// The client and pickle key are always required. Additionally, you must either: +// - Provide a crypto.Store here and set a StateStore in the client, or +// - Provide a dbutil.Database here to automatically create missing stores. +// - Provide a string here to use it as a path to a SQLite database, and then automatically create missing stores. +// +// The same database may be shared across multiple clients, but note that doing that will allow all clients access to +// decryption keys received by any one of the clients. For that reason, the pickle key must also be same for all clients +// using the same database. +func NewCryptoHelper(cli *mautrix.Client, pickleKey []byte, store any) (*CryptoHelper, error) { + if len(pickleKey) == 0 { + return nil, fmt.Errorf("pickle key must be provided") + } + _, isExtensible := cli.Syncer.(mautrix.ExtensibleSyncer) + if !cli.SetAppServiceDeviceID && !isExtensible { + return nil, fmt.Errorf("the client syncer must implement ExtensibleSyncer") + } + + var managedStateStore *sqlstatestore.SQLStateStore + var dbForManagedStores *dbutil.Database + var unmanagedCryptoStore crypto.Store + switch typedStore := store.(type) { + case crypto.Store: + if cli.StateStore == nil { + return nil, fmt.Errorf("when passing a crypto.Store to NewCryptoHelper, the client must have a state store set beforehand") + } else if _, isCryptoCompatible := cli.StateStore.(crypto.StateStore); !isCryptoCompatible { + return nil, fmt.Errorf("the client state store must implement crypto.StateStore") + } + unmanagedCryptoStore = typedStore + case string: + db, err := dbutil.NewWithDialect(fmt.Sprintf("file:%s?_txlock=immediate", typedStore), "sqlite3-fk-wal") + if err != nil { + return nil, err + } + dbForManagedStores = db + case *dbutil.Database: + dbForManagedStores = typedStore + default: + return nil, fmt.Errorf("you must pass a *dbutil.Database or *crypto.StateStore to NewCryptoHelper") + } + log := cli.Log.With().Str("component", "crypto").Logger() + if cli.StateStore == nil && dbForManagedStores != nil { + managedStateStore = sqlstatestore.NewSQLStateStore(dbForManagedStores, dbutil.ZeroLogger(log.With().Str("db_section", "matrix_state").Logger()), false) + cli.StateStore = managedStateStore + } else if _, isCryptoCompatible := cli.StateStore.(crypto.StateStore); !isCryptoCompatible { + return nil, fmt.Errorf("the client state store must implement crypto.StateStore") + } + + return &CryptoHelper{ + client: cli, + log: log, + pickleKey: pickleKey, + + unmanagedCryptoStore: unmanagedCryptoStore, + managedStateStore: managedStateStore, + dbForManagedStores: dbForManagedStores, + + DecryptErrorCallback: func(_ *event.Event, _ error) {}, + }, nil +} + +func (helper *CryptoHelper) Init(ctx context.Context) error { + if helper == nil { + return fmt.Errorf("crypto helper is nil") + } + syncer, ok := helper.client.Syncer.(mautrix.ExtensibleSyncer) + if !ok { + if !helper.client.SetAppServiceDeviceID { + return fmt.Errorf("the client syncer must implement ExtensibleSyncer") + } + } + + var stateStore crypto.StateStore + if helper.managedStateStore != nil { + err := helper.managedStateStore.Upgrade(ctx) + if err != nil { + return fmt.Errorf("failed to upgrade client state store: %w", err) + } + stateStore = helper.managedStateStore + } else { + stateStore = helper.client.StateStore.(crypto.StateStore) + } + var cryptoStore crypto.Store + if helper.unmanagedCryptoStore == nil { + managedCryptoStore := crypto.NewSQLCryptoStore(helper.dbForManagedStores, dbutil.ZeroLogger(helper.log.With().Str("db_section", "crypto").Logger()), helper.DBAccountID, helper.client.DeviceID, helper.pickleKey) + if helper.client.Store == nil { + helper.client.Store = managedCryptoStore + } else if _, isMemory := helper.client.Store.(*mautrix.MemorySyncStore); isMemory { + helper.client.Store = managedCryptoStore + } + err := managedCryptoStore.DB.Upgrade(ctx) + if err != nil { + return fmt.Errorf("failed to upgrade crypto state store: %w", err) + } + cryptoStore = managedCryptoStore + } else { + cryptoStore = helper.unmanagedCryptoStore + } + shouldFindDeviceID := helper.LoginAs != nil || helper.unmanagedCryptoStore == nil + if rawCryptoStore, ok := cryptoStore.(*crypto.SQLCryptoStore); ok && shouldFindDeviceID { + storedDeviceID, err := rawCryptoStore.FindDeviceID(ctx) + if err != nil { + return fmt.Errorf("failed to find existing device ID: %w", err) + } + if helper.MSC4190 { + helper.log.Debug().Msg("Creating bot device with MSC4190") + err = helper.client.CreateDeviceMSC4190(ctx, storedDeviceID, helper.LoginAs.InitialDeviceDisplayName) + if err != nil { + return fmt.Errorf("failed to create device for bot: %w", err) + } + rawCryptoStore.DeviceID = helper.client.DeviceID + } else if helper.LoginAs != nil && helper.LoginAs.Type == mautrix.AuthTypeAppservice && helper.client.SetAppServiceDeviceID { + if storedDeviceID == "" { + helper.log.Debug(). + Str("username", helper.LoginAs.Identifier.User). + Msg("Logging in with appservice") + var resp *mautrix.RespLogin + resp, err = helper.client.Login(ctx, helper.LoginAs) + if err != nil { + return err + } + helper.client.DeviceID = resp.DeviceID + } else { + helper.log.Debug(). + Str("username", helper.LoginAs.Identifier.User). + Stringer("device_id", storedDeviceID). + Msg("Using existing device") + helper.client.DeviceID = storedDeviceID + } + } else if helper.LoginAs != nil { + if storedDeviceID != "" { + helper.LoginAs.DeviceID = storedDeviceID + } + helper.LoginAs.StoreCredentials = true + helper.log.Debug(). + Str("username", helper.LoginAs.Identifier.User). + Str("device_id", helper.LoginAs.DeviceID.String()). + Msg("Logging in") + _, err = helper.client.Login(ctx, helper.LoginAs) + if err != nil { + return err + } + } else if storedDeviceID != "" && storedDeviceID != helper.client.DeviceID { + return fmt.Errorf("mismatching device ID in client and crypto store (%q != %q)", storedDeviceID, helper.client.DeviceID) + } + rawCryptoStore.DeviceID = helper.client.DeviceID + } else if helper.LoginAs != nil { + return fmt.Errorf("LoginAs can only be used with a managed crypto store") + } + if helper.client.DeviceID == "" || helper.client.UserID == "" { + return fmt.Errorf("the client must be logged in") + } + helper.mach = crypto.NewOlmMachine(helper.client, &helper.log, cryptoStore, stateStore) + err := helper.mach.Load(ctx) + if err != nil { + return fmt.Errorf("failed to load olm account: %w", err) + } else if err = helper.verifyDeviceKeysOnServer(ctx); err != nil { + return err + } + + if syncer != nil { + syncer.OnSync(helper.mach.ProcessSyncResponse) + syncer.OnEventType(event.StateMember, helper.mach.HandleMemberEvent) + if _, ok = helper.client.Syncer.(mautrix.DispatchableSyncer); ok { + syncer.OnEventType(event.EventEncrypted, helper.HandleEncrypted) + } else { + helper.log.Warn().Msg("Client syncer does not implement DispatchableSyncer. Events will not be decrypted automatically.") + } + if helper.managedStateStore != nil { + syncer.OnEvent(helper.client.StateStoreSyncHandler) + } + } else if helper.ASEventProcessor != nil { + helper.mach.AddAppserviceListener(helper.ASEventProcessor) + helper.ASEventProcessor.On(event.EventEncrypted, helper.HandleEncrypted) + } + + return nil +} + +func (helper *CryptoHelper) Close() error { + if helper != nil && helper.dbForManagedStores != nil { + err := helper.dbForManagedStores.Close() + if err != nil { + return err + } + } + return nil +} + +func (helper *CryptoHelper) Machine() *crypto.OlmMachine { + if helper == nil || helper.mach == nil { + panic("Machine() called before initing CryptoHelper") + } + return helper.mach +} + +func (helper *CryptoHelper) verifyDeviceKeysOnServer(ctx context.Context) error { + helper.log.Debug().Msg("Making sure our device has the expected keys on the server") + resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ + DeviceKeys: map[id.UserID]mautrix.DeviceIDList{ + helper.client.UserID: {helper.client.DeviceID}, + }, + }) + if err != nil { + return fmt.Errorf("failed to query own keys to make sure device is properly configured: %w", err) + } + ownID := helper.mach.OwnIdentity() + isShared := helper.mach.GetAccount().Shared + device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID] + if !ok || len(device.Keys) == 0 { + if isShared { + return fmt.Errorf("olm account is marked as shared, keys seem to have disappeared from the server") + } + helper.log.Debug().Msg("Olm account not shared and keys not on server, sharing initial keys") + err = helper.mach.ShareKeys(ctx, -1) + if err != nil { + return fmt.Errorf("failed to share keys: %w", err) + } + return nil + } else if !isShared { + return fmt.Errorf("olm account is not marked as shared, but there are keys on the server") + } else if ed := device.Keys.GetEd25519(helper.client.DeviceID); ownID.SigningKey != ed { + return fmt.Errorf("mismatching identity key on server (%q != %q)", ownID.SigningKey, ed) + } else { + helper.log.Debug().Msg("Olm account marked as shared and keys on server match, device is fine") + return nil + } +} + +var NoSessionFound = crypto.ErrNoSessionFound + +const initialSessionWaitTimeout = 3 * time.Second +const extendedSessionWaitTimeout = 22 * time.Second + +func (helper *CryptoHelper) HandleEncrypted(ctx context.Context, evt *event.Event) { + if helper == nil { + return + } + content := evt.Content.AsEncrypted() + // TODO use context log instead of helper? + log := helper.log.With(). + Str("event_id", evt.ID.String()). + Str("session_id", content.SessionID.String()). + Logger() + log.Debug().Msg("Decrypting received event") + ctx = log.WithContext(ctx) + + decrypted, err := helper.Decrypt(ctx, evt) + if errors.Is(err, NoSessionFound) && ctx.Value(mautrix.SyncTokenContextKey) != "" { + go helper.waitForSession(ctx, evt) + } else if err != nil { + log.Warn().Err(err).Msg("Failed to decrypt event") + helper.DecryptErrorCallback(evt, err) + } else { + helper.postDecrypt(ctx, decrypted) + } +} + +func (helper *CryptoHelper) postDecrypt(ctx context.Context, decrypted *event.Event) { + decrypted.Mautrix.EventSource |= event.SourceDecrypted + if helper.CustomPostDecrypt != nil { + helper.CustomPostDecrypt(ctx, decrypted) + } else if helper.ASEventProcessor != nil { + helper.ASEventProcessor.Dispatch(ctx, decrypted) + } else { + helper.client.Syncer.(mautrix.DispatchableSyncer).Dispatch(ctx, decrypted) + } +} + +func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) { + if helper == nil { + return + } + helper.lock.RLock() + defer helper.lock.RUnlock() + if deviceID == "" { + deviceID = "*" + } + // TODO get log from context + log := helper.log.With(). + Str("session_id", sessionID.String()). + Str("user_id", userID.String()). + Str("device_id", deviceID.String()). + Str("room_id", roomID.String()). + Logger() + err := helper.mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{ + userID: {deviceID}, + helper.client.UserID: {"*"}, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to send key request") + } else { + log.Debug().Msg("Sent key request") + } +} + +func (helper *CryptoHelper) waitForSession(ctx context.Context, evt *event.Event) { + log := zerolog.Ctx(ctx) + content := evt.Content.AsEncrypted() + + log.Debug(). + Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). + Msg("Couldn't find session, waiting for keys to arrive...") + if helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { + log.Debug().Msg("Got keys after waiting, trying to decrypt event again") + decrypted, err := helper.Decrypt(ctx, evt) + if err != nil { + log.Warn().Err(err).Msg("Failed to decrypt event") + helper.DecryptErrorCallback(evt, err) + } else { + helper.postDecrypt(ctx, decrypted) + } + } else { + go helper.waitLongerForSession(ctx, evt) + } +} + +func (helper *CryptoHelper) waitLongerForSession(ctx context.Context, evt *event.Event) { + log := zerolog.Ctx(ctx) + content := evt.Content.AsEncrypted() + log.Debug().Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())).Msg("Couldn't find session, requesting keys and waiting longer...") + + //lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank + go helper.RequestSession(context.TODO(), evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) + + if !helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) { + log.Debug().Msg("Didn't get session, giving up") + helper.DecryptErrorCallback(evt, NoSessionFound) + return + } + + log.Debug().Msg("Got keys after waiting longer, trying to decrypt event again") + decrypted, err := helper.Decrypt(ctx, evt) + if err != nil { + log.Error().Err(err).Msg("Failed to decrypt event") + helper.DecryptErrorCallback(evt, err) + return + } + + helper.postDecrypt(ctx, decrypted) +} + +func (helper *CryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool { + if helper == nil { + return false + } + helper.lock.RLock() + defer helper.lock.RUnlock() + return helper.mach.WaitForSession(ctx, roomID, senderKey, sessionID, timeout) +} + +func (helper *CryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) { + if helper == nil { + return nil, fmt.Errorf("crypto helper is nil") + } + return helper.mach.DecryptMegolmEvent(ctx, evt) +} + +func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (encrypted *event.EncryptedEventContent, err error) { + return helper.EncryptWithStateKey(ctx, roomID, evtType, nil, content) +} + +func (helper *CryptoHelper) EncryptWithStateKey(ctx context.Context, roomID id.RoomID, evtType event.Type, stateKey *string, content any) (encrypted *event.EncryptedEventContent, err error) { + if helper == nil { + return nil, fmt.Errorf("crypto helper is nil") + } + helper.lock.RLock() + defer helper.lock.RUnlock() + encrypted, err = helper.mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, stateKey, content) + if err != nil { + if !errors.Is(err, crypto.ErrSessionExpired) && err != crypto.ErrNoGroupSession && !errors.Is(err, crypto.ErrSessionNotShared) { + return + } + helper.log.Debug(). + Err(err). + Str("room_id", roomID.String()). + Msg("Got session error while encrypting event, sharing group session and trying again") + var users []id.UserID + users, err = helper.client.StateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID) + if err != nil { + err = fmt.Errorf("failed to get room member list: %w", err) + } else if err = helper.mach.ShareGroupSession(ctx, roomID, users); err != nil { + err = fmt.Errorf("failed to share group session: %w", err) + } else if encrypted, err = helper.mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, stateKey, content); err != nil { + err = fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err) + } + } + return +} diff --git a/mautrix-patched/crypto/decryptmegolm.go b/mautrix-patched/crypto/decryptmegolm.go new file mode 100644 index 00000000..c933f637 --- /dev/null +++ b/mautrix-patched/crypto/decryptmegolm.go @@ -0,0 +1,353 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/rs/zerolog" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ( + ErrIncorrectEncryptedContentType = errors.New("event content is not instance of *event.EncryptedEventContent") + ErrNoSessionFound = errors.New("failed to decrypt megolm event: no session with given ID found") + ErrDuplicateMessageIndex = errors.New("duplicate megolm message index") + ErrWrongRoom = errors.New("encrypted megolm event is not intended for this room") + ErrDeviceKeyMismatch = errors.New("device keys in event and verified device info do not match") + ErrRatchetError = errors.New("failed to ratchet session after use") + ErrCorruptedMegolmPayload = errors.New("corrupted megolm payload") +) + +// Deprecated: use variables prefixed with Err +var ( + IncorrectEncryptedContentType = ErrIncorrectEncryptedContentType + NoSessionFound = ErrNoSessionFound + DuplicateMessageIndex = ErrDuplicateMessageIndex + WrongRoom = ErrWrongRoom + DeviceKeyMismatch = ErrDeviceKeyMismatch + RatchetError = ErrRatchetError +) + +type megolmEvent struct { + RoomID id.RoomID `json:"room_id"` + Type event.Type `json:"type"` + StateKey *string `json:"state_key"` + Content event.Content `json:"content"` +} + +var ( + relatesToContentPath = exgjson.Path("m.relates_to") + relatesToTopLevelPath = exgjson.Path("content", "m.relates_to") +) + +const sessionIDLength = 43 + +func validateCiphertextCharacters(ciphertext []byte) bool { + for _, b := range ciphertext { + if (b < 'a' || b > 'z') && (b < 'A' || b > 'Z') && (b < '0' || b > '9') && b != '+' && b != '/' { + return false + } + } + return true +} + +// DecryptMegolmEvent decrypts an m.room.encrypted event where the algorithm is m.megolm.v1.aes-sha2 +func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event) (*event.Event, error) { + content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) + if !ok { + return nil, ErrIncorrectEncryptedContentType + } else if content.Algorithm != id.AlgorithmMegolmV1 { + return nil, ErrUnsupportedAlgorithm + } else if len(content.MegolmCiphertext) < 74 { + return nil, fmt.Errorf("%w: ciphertext too short (%d bytes)", ErrCorruptedMegolmPayload, len(content.MegolmCiphertext)) + } else if len(content.SessionID) != sessionIDLength { + return nil, fmt.Errorf("%w: invalid session ID length %d", ErrCorruptedMegolmPayload, len(content.SessionID)) + } else if !validateCiphertextCharacters(content.MegolmCiphertext) { + return nil, fmt.Errorf("%w: invalid characters in ciphertext", ErrCorruptedMegolmPayload) + } + log := mach.machOrContextLog(ctx).With(). + Str("action", "decrypt megolm event"). + Str("event_id", evt.ID.String()). + Str("sender", evt.Sender.String()). + Str("sender_key", content.SenderKey.String()). + Str("session_id", content.SessionID.String()). + Logger() + ctx = log.WithContext(ctx) + encryptionRoomID := evt.RoomID + // Allow the server to move encrypted events between rooms if both the real room and target room are on a non-federatable .local domain. + // The message index checks to prevent replay attacks still apply and aren't based on the room ID, + // so the event ID and timestamp must remain the same when the event is moved to a different room. + if origRoomID, ok := evt.Content.Raw["com.beeper.original_room_id"].(string); ok && strings.HasSuffix(origRoomID, ".local") && strings.HasSuffix(evt.RoomID.String(), ".local") && mach.AllowBeeperRoomReroute { + encryptionRoomID = id.RoomID(origRoomID) + } + sess, plaintext, messageIndex, err := mach.actuallyDecryptMegolmEvent(ctx, evt, encryptionRoomID, content) + if err != nil { + return nil, err + } + log = log.With().Uint("message_index", messageIndex).Logger() + + var trustLevel id.TrustState + var forwardedKeys bool + var device *id.Device + ownSigningKey, ownIdentityKey := mach.account.Keys() + if sess.KeySource == id.KeySourceBackup || sess.KeySource == id.KeySourceImport { + // Key backup is currently not trusted, so use a special trust level for it. + trustLevel = id.TrustStateBackup + } else if sess.SigningKey == ownSigningKey && sess.SenderKey == ownIdentityKey && sess.KeySource == id.KeySourceDirect && len(sess.ForwardingChains) == 0 { + trustLevel = id.TrustStateVerified + } else { + if mach.DisableDecryptKeyFetching || !mach.keyFetchAttempted.Add(userSenderKeyTuple{evt.Sender, sess.SenderKey}) { + device, err = mach.CryptoStore.FindDeviceByKey(ctx, evt.Sender, sess.SenderKey) + } else { + device, err = mach.GetOrFetchDeviceByKey(ctx, evt.Sender, sess.SenderKey) + } + if err != nil { + // We don't want to throw these errors as the message can still be decrypted. + log.Debug().Err(err).Msg("Failed to get device to verify session") + trustLevel = id.TrustStateUnknownDevice + } else if len(sess.ForwardingChains) == 0 || (len(sess.ForwardingChains) == 1 && sess.ForwardingChains[0] == sess.SenderKey.String()) { + if device == nil { + log.Debug(). + Str("session_sender_key", sess.SenderKey.String()). + Msg("Couldn't resolve trust level of session: sent by unknown device") + trustLevel = id.TrustStateUnknownDevice + } else if device.SigningKey != sess.SigningKey || device.IdentityKey != sess.SenderKey { + log.Debug(). + Stringer("session_sender_key", sess.SenderKey). + Stringer("device_sender_key", device.IdentityKey). + Stringer("session_signing_key", sess.SigningKey). + Stringer("device_signing_key", device.SigningKey). + Msg("Device keys don't match keys in session, marking as untrusted") + trustLevel = id.TrustStateDeviceKeyMismatch + } else { + trustLevel, err = mach.ResolveTrustContext(ctx, device) + if err != nil { + return nil, err + } + } + } else { + forwardedKeys = true + lastChainItem := sess.ForwardingChains[len(sess.ForwardingChains)-1] + device, _ = mach.CryptoStore.FindDeviceByKey(ctx, evt.Sender, id.IdentityKey(lastChainItem)) + if device != nil { + trustLevel, err = mach.ResolveTrustContext(ctx, device) + if err != nil { + return nil, err + } + } else { + log.Debug(). + Str("forward_last_sender_key", lastChainItem). + Msg("Couldn't resolve trust level of session: forwarding chain ends with unknown device") + trustLevel = id.TrustStateForwarded + } + } + } + + if content.RelatesTo != nil { + relation := gjson.GetBytes(evt.Content.VeryRaw, relatesToContentPath) + if relation.Exists() && !gjson.GetBytes(plaintext, relatesToTopLevelPath).IsObject() { + var raw []byte + if relation.Index > 0 { + raw = evt.Content.VeryRaw[relation.Index : relation.Index+len(relation.Raw)] + } else { + raw = []byte(relation.Raw) + } + updatedPlaintext, err := sjson.SetRawBytes(plaintext, relatesToTopLevelPath, raw) + if err != nil { + log.Warn().Msg("Failed to copy m.relates_to to decrypted payload") + } else if updatedPlaintext != nil { + plaintext = updatedPlaintext + } + } else if !relation.Exists() { + log.Warn().Msg("Failed to find m.relates_to in raw encrypted event even though it was present in parsed content") + } + } + + megolmEvt := &megolmEvent{} + err = json.Unmarshal(plaintext, &megolmEvt) + if err != nil { + return nil, fmt.Errorf("failed to parse megolm payload: %w", err) + } else if megolmEvt.RoomID != encryptionRoomID { + return nil, ErrWrongRoom + } + if evt.StateKey != nil && megolmEvt.StateKey != nil && mach.AllowEncryptedState { + megolmEvt.Type.Class = event.StateEventType + } else { + megolmEvt.Type.Class = evt.Type.Class + megolmEvt.StateKey = nil + } + log = log.With().Str("decrypted_event_type", megolmEvt.Type.Repr()).Logger() + err = megolmEvt.Content.ParseRaw(megolmEvt.Type) + if err != nil { + if errors.Is(err, event.ErrUnsupportedContentType) { + log.Warn().Msg("Unsupported event type in encrypted event") + } else if !mach.IgnorePostDecryptionParseErrors { + return nil, fmt.Errorf("failed to parse content of megolm payload event: %w", err) + } + } + log.Debug().Msg("Event decrypted successfully") + megolmEvt.Type.Class = evt.Type.Class + return &event.Event{ + Sender: evt.Sender, + Type: megolmEvt.Type, + StateKey: megolmEvt.StateKey, + Timestamp: evt.Timestamp, + ID: evt.ID, + RoomID: evt.RoomID, + Content: megolmEvt.Content, + Unsigned: evt.Unsigned, + Mautrix: event.MautrixInfo{ + TrustState: trustLevel, + TrustSource: device, + ForwardedKeys: forwardedKeys, + WasEncrypted: true, + EventSource: evt.Mautrix.EventSource | event.SourceDecrypted, + ReceivedAt: evt.Mautrix.ReceivedAt, + }, + }, nil +} + +func removeItem(slice []uint, item uint) ([]uint, bool) { + for i, s := range slice { + if s == item { + return append(slice[:i], slice[i+1:]...), true + } + } + return slice, false +} + +const missedIndexCutoff = 10 + +func (mach *OlmMachine) checkUndecryptableMessageIndexDuplication(ctx context.Context, sess *InboundGroupSession, evt *event.Event, content *event.EncryptedEventContent) (uint, error) { + log := *zerolog.Ctx(ctx) + messageIndex, decodeErr := ParseMegolmMessageIndex(content.MegolmCiphertext) + if decodeErr != nil { + log.Warn().Err(decodeErr).Msg("Failed to parse message index to check if it's a duplicate for message that failed to decrypt") + return 0, fmt.Errorf("%w (also failed to parse message index)", olm.ErrUnknownMessageIndex) + } + firstKnown := sess.Internal.FirstKnownIndex() + log = log.With().Uint("message_index", messageIndex).Uint32("first_known_index", firstKnown).Logger() + if ok, err := mach.CryptoStore.ValidateMessageIndex(ctx, sess.ID(), evt.ID, messageIndex, evt.Timestamp); err != nil { + log.Debug().Err(err).Msg("Failed to check if message index is duplicate") + return messageIndex, fmt.Errorf("%w (failed to check if index is duplicate; received: %d, earliest known: %d)", olm.ErrUnknownMessageIndex, messageIndex, firstKnown) + } else if !ok { + log.Debug().Msg("Failed to decrypt message due to unknown index and found duplicate") + return messageIndex, fmt.Errorf("%w %d (also failed to decrypt because earliest known index is %d)", ErrDuplicateMessageIndex, messageIndex, firstKnown) + } + log.Debug().Msg("Failed to decrypt message due to unknown index, but index is not duplicate") + return messageIndex, fmt.Errorf("%w (not duplicate index; received: %d, earliest known: %d)", olm.ErrUnknownMessageIndex, messageIndex, firstKnown) +} + +func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *event.Event, encryptionRoomID id.RoomID, content *event.EncryptedEventContent) (*InboundGroupSession, []byte, uint, error) { + mach.megolmDecryptLock.Lock() + defer mach.megolmDecryptLock.Unlock() + + sess, err := mach.CryptoStore.GetGroupSession(ctx, encryptionRoomID, content.SessionID) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to get group session: %w", err) + } else if sess == nil { + return nil, nil, 0, fmt.Errorf("%w (ID %s)", ErrNoSessionFound, content.SessionID) + } + plaintext, messageIndex, err := sess.Internal.Decrypt(content.MegolmCiphertext) + if err != nil { + if errors.Is(err, olm.ErrUnknownMessageIndex) && mach.RatchetKeysOnDecrypt { + messageIndex, err = mach.checkUndecryptableMessageIndexDuplication(ctx, sess, evt, content) + return sess, nil, messageIndex, fmt.Errorf("failed to decrypt megolm event: %w", err) + } + return sess, nil, 0, fmt.Errorf("failed to decrypt megolm event: %w", err) + } else if ok, err := mach.CryptoStore.ValidateMessageIndex(ctx, sess.ID(), evt.ID, messageIndex, evt.Timestamp); err != nil { + return sess, nil, messageIndex, fmt.Errorf("failed to check if message index is duplicate: %w", err) + } else if !ok { + return sess, nil, messageIndex, fmt.Errorf("%w %d", ErrDuplicateMessageIndex, messageIndex) + } + + // Normal clients don't care about tracking the ratchet state, so let them bypass the rest of the function + if mach.DisableRatchetTracking { + return sess, plaintext, messageIndex, nil + } + + expectedMessageIndex := sess.RatchetSafety.NextIndex + didModify := false + switch { + case messageIndex > expectedMessageIndex: + // When the index jumps, add indices in between to the missed indices list. + for i := expectedMessageIndex; i < messageIndex; i++ { + sess.RatchetSafety.MissedIndices = append(sess.RatchetSafety.MissedIndices, i) + } + fallthrough + case messageIndex == expectedMessageIndex: + // When the index moves forward (to the next one or jumping ahead), update the last received index. + sess.RatchetSafety.NextIndex = messageIndex + 1 + didModify = true + default: + sess.RatchetSafety.MissedIndices, didModify = removeItem(sess.RatchetSafety.MissedIndices, messageIndex) + } + // Use presence of ReceivedAt as a sign that this is a recent megolm session, + // and therefore it's safe to drop missed indices entirely. + if !sess.ReceivedAt.IsZero() && len(sess.RatchetSafety.MissedIndices) > 0 && int(sess.RatchetSafety.MissedIndices[0]) < int(sess.RatchetSafety.NextIndex)-missedIndexCutoff { + limit := sess.RatchetSafety.NextIndex - missedIndexCutoff + var cutoff int + for ; cutoff < len(sess.RatchetSafety.MissedIndices) && sess.RatchetSafety.MissedIndices[cutoff] < limit; cutoff++ { + } + sess.RatchetSafety.LostIndices = append(sess.RatchetSafety.LostIndices, sess.RatchetSafety.MissedIndices[:cutoff]...) + sess.RatchetSafety.MissedIndices = sess.RatchetSafety.MissedIndices[cutoff:] + didModify = true + } + ratchetTargetIndex := uint32(sess.RatchetSafety.NextIndex) + if len(sess.RatchetSafety.MissedIndices) > 0 { + ratchetTargetIndex = uint32(sess.RatchetSafety.MissedIndices[0]) + } + ratchetCurrentIndex := sess.Internal.FirstKnownIndex() + log := zerolog.Ctx(ctx).With(). + Uint32("prev_ratchet_index", ratchetCurrentIndex). + Uint32("new_ratchet_index", ratchetTargetIndex). + Uint("next_new_index", sess.RatchetSafety.NextIndex). + Uints("missed_indices", sess.RatchetSafety.MissedIndices). + Uints("lost_indices", sess.RatchetSafety.LostIndices). + Int("max_messages", sess.MaxMessages). + Logger() + if sess.MaxMessages > 0 && int(ratchetTargetIndex) >= sess.MaxMessages && len(sess.RatchetSafety.MissedIndices) == 0 && mach.DeleteFullyUsedKeysOnDecrypt { + err = mach.CryptoStore.RedactGroupSession(ctx, sess.RoomID, sess.ID(), "maximum messages reached") + if err != nil { + log.Err(err).Msg("Failed to delete fully used session") + return sess, plaintext, messageIndex, ErrRatchetError + } else { + log.Info().Msg("Deleted fully used session") + } + } else if ratchetCurrentIndex < ratchetTargetIndex && mach.RatchetKeysOnDecrypt { + if err = sess.RatchetTo(ratchetTargetIndex); err != nil { + log.Err(err).Msg("Failed to ratchet session") + return sess, plaintext, messageIndex, ErrRatchetError + } else if err = mach.CryptoStore.PutGroupSession(ctx, sess); err != nil { + log.Err(err).Msg("Failed to store ratcheted session") + return sess, plaintext, messageIndex, ErrRatchetError + } else { + log.Info().Msg("Ratcheted session forward") + } + } else if didModify { + if err = mach.CryptoStore.PutGroupSession(ctx, sess); err != nil { + log.Err(err).Msg("Failed to store updated ratchet safety data") + return sess, plaintext, messageIndex, ErrRatchetError + } else { + log.Debug().Msg("Ratchet safety data changed (ratchet state didn't change)") + } + } else { + log.Debug().Msg("Ratchet safety data didn't change") + } + return sess, plaintext, messageIndex, nil +} diff --git a/mautrix-patched/crypto/decryptolm.go b/mautrix-patched/crypto/decryptolm.go new file mode 100644 index 00000000..b5c27dc0 --- /dev/null +++ b/mautrix-patched/crypto/decryptolm.go @@ -0,0 +1,438 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "slices" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/goolm/account" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ( + ErrUnsupportedAlgorithm = errors.New("unsupported event encryption algorithm") + ErrNotEncryptedForMe = errors.New("olm event doesn't contain ciphertext for this device") + ErrUnsupportedOlmMessageType = errors.New("unsupported olm message type") + ErrDecryptionFailedWithMatchingSession = errors.New("decryption failed with matching session") + ErrDecryptionFailedForNormalMessage = errors.New("decryption failed for normal message") + ErrSenderMismatch = errors.New("mismatched sender in olm payload") + ErrSenderKeyMismatch = errors.New("mismatched sender key in olm payload") + ErrSigningKeyMismatch = errors.New("mismatched signing key in olm payload and device info") + ErrRecipientMismatch = errors.New("mismatched recipient in olm payload") + ErrRecipientKeyMismatch = errors.New("mismatched recipient key in olm payload") + ErrDuplicateMessage = errors.New("duplicate olm message") +) + +// Deprecated: use variables prefixed with Err +var ( + UnsupportedAlgorithm = ErrUnsupportedAlgorithm + NotEncryptedForMe = ErrNotEncryptedForMe + UnsupportedOlmMessageType = ErrUnsupportedOlmMessageType + DecryptionFailedWithMatchingSession = ErrDecryptionFailedWithMatchingSession + DecryptionFailedForNormalMessage = ErrDecryptionFailedForNormalMessage + SenderMismatch = ErrSenderMismatch + RecipientMismatch = ErrRecipientMismatch + RecipientKeyMismatch = ErrRecipientKeyMismatch +) + +// DecryptedOlmEvent represents an event that was decrypted from an event encrypted with the m.olm.v1.curve25519-aes-sha2 algorithm. +type DecryptedOlmEvent struct { + Source *event.Event `json:"-"` + + SenderKey id.SenderKey `json:"-"` + SenderDevice *id.Device `json:"-"` + + Sender id.UserID `json:"sender"` + SenderDeviceKeys *mautrix.DeviceKeys `json:"sender_device_keys,omitempty"` + Keys OlmEventKeys `json:"keys"` + Recipient id.UserID `json:"recipient"` + RecipientKeys OlmEventKeys `json:"recipient_keys"` + + // Deprecated: not a real field, do not use + SenderDeviceID id.DeviceID `json:"sender_device"` + + Type event.Type `json:"type"` + Content event.Content `json:"content"` +} + +func (mach *OlmMachine) decryptOlmEvent(ctx context.Context, evt *event.Event) (*DecryptedOlmEvent, error) { + content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) + if !ok { + return nil, ErrIncorrectEncryptedContentType + } else if content.Algorithm != id.AlgorithmOlmV1 { + return nil, ErrUnsupportedAlgorithm + } + ownContent, ok := content.OlmCiphertext[mach.account.IdentityKey()] + if !ok { + return nil, ErrNotEncryptedForMe + } + decrypted, err := mach.decryptAndParseOlmCiphertext(ctx, evt, content.SenderKey, ownContent.Type, ownContent.Body) + if err != nil { + return nil, err + } + decrypted.Source = evt + return decrypted, nil +} + +type OlmEventKeys struct { + Ed25519 id.Ed25519 `json:"ed25519"` +} + +type userSenderKeyTuple struct { + UserID id.UserID + SenderKey id.SenderKey +} + +func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *event.Event, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string) (*DecryptedOlmEvent, error) { + if olmType != id.OlmMsgTypePreKey && olmType != id.OlmMsgTypeMsg { + return nil, ErrUnsupportedOlmMessageType + } + + log := mach.machOrContextLog(ctx).With(). + Stringer("sender_key", senderKey). + Int("olm_msg_type", int(olmType)). + Logger() + ctx = log.WithContext(ctx) + endTimeTrace := mach.timeTrace(ctx, "decrypting olm ciphertext", 5*time.Second) + plaintext, err := mach.tryDecryptOlmCiphertext(ctx, evt.Sender, senderKey, olmType, ciphertext) + endTimeTrace() + if err != nil { + return nil, err + } + + defer mach.timeTrace(ctx, "parsing decrypted olm event", time.Second)() + + var olmEvt DecryptedOlmEvent + err = json.Unmarshal(plaintext, &olmEvt) + if err != nil { + return nil, fmt.Errorf("failed to parse olm payload: %w", err) + } + olmEvt.Type.Class = evt.Type.Class + if evt.Sender != olmEvt.Sender { + return nil, ErrSenderMismatch + } else if mach.Client.UserID != olmEvt.Recipient { + return nil, ErrRecipientMismatch + } else if mach.account.SigningKey() != olmEvt.RecipientKeys.Ed25519 { + return nil, ErrRecipientKeyMismatch + } + device, err := mach.CryptoStore.FindDeviceByKey(ctx, evt.Sender, senderKey) + if err != nil { + return nil, fmt.Errorf("failed to find device for sender key: %w", err) + } + if olmEvt.SenderDeviceKeys != nil { + if olmEvt.SenderDeviceKeys.UserID != olmEvt.Sender { + return nil, fmt.Errorf("sender_device_keys: %w", ErrSenderMismatch) + } else if olmEvt.SenderDeviceKeys.Keys.GetCurve25519(olmEvt.SenderDeviceKeys.DeviceID) != senderKey { + return nil, fmt.Errorf("sender_device_keys: %w", ErrSenderKeyMismatch) + } else if device, err = mach.validateDevice(evt.Sender, olmEvt.SenderDeviceKeys.DeviceID, *olmEvt.SenderDeviceKeys, device); err != nil { + return nil, fmt.Errorf("sender_device_keys: %w", err) + } + } else if device == nil && !mach.DisableDecryptKeyFetching && mach.keyFetchAttempted.Add(userSenderKeyTuple{evt.Sender, senderKey}) { + log.Warn().Msg("Olm sender device not found in store or event, fetching from server") + devices := mach.LoadDevices(ctx, evt.Sender) + for _, d := range devices { + if d.IdentityKey == senderKey { + device = d + break + } + } + } + if device == nil { + // TODO: drop these events in the future (allowed temporarily until all clients send sender_device_keys) + log.Warn().Msg("Olm sender device not found in store or event, but fetching is disabled or already attempted") + } + if device != nil && device.SigningKey != olmEvt.Keys.Ed25519 { + return nil, ErrSigningKeyMismatch + } + + if len(olmEvt.Content.VeryRaw) > 0 { + err = olmEvt.Content.ParseRaw(olmEvt.Type) + if err != nil && !errors.Is(err, event.ErrUnsupportedContentType) { + return nil, fmt.Errorf("failed to parse content of olm payload event: %w", err) + } + } + + olmEvt.SenderDevice = device + olmEvt.SenderKey = senderKey + + return &olmEvt, nil +} + +func olmMessageHash(ciphertext string) ([32]byte, error) { + if ciphertext == "" { + return [32]byte{}, fmt.Errorf("empty ciphertext") + } + ciphertextBytes, err := base64.RawStdEncoding.DecodeString(ciphertext) + return sha256.Sum256(ciphertextBytes), err +} + +func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.UserID, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string) ([]byte, error) { + ciphertextHash, err := olmMessageHash(ciphertext) + if err != nil { + return nil, fmt.Errorf("failed to hash olm ciphertext: %w", err) + } + + log := *zerolog.Ctx(ctx) + endTimeTrace := mach.timeTrace(ctx, "waiting for olm lock", 5*time.Second) + mach.olmLock.Lock() + endTimeTrace() + defer mach.olmLock.Unlock() + + duplicateTS, err := mach.CryptoStore.GetOlmHash(ctx, ciphertextHash) + if err != nil { + log.Warn().Err(err).Msg("Failed to check for duplicate olm message") + } else if !duplicateTS.IsZero() { + log.Warn(). + Hex("ciphertext_hash", ciphertextHash[:]). + Time("duplicate_ts", duplicateTS). + Msg("Ignoring duplicate olm message") + return nil, ErrDuplicateMessage + } + + plaintext, err := mach.tryDecryptOlmCiphertextWithExistingSession(ctx, senderKey, olmType, ciphertext, ciphertextHash) + if err != nil { + if err == ErrDecryptionFailedWithMatchingSession { + log.Warn().Msg("Found matching session, but decryption failed") + go mach.unwedgeDevice(log, sender, senderKey) + } + return nil, fmt.Errorf("failed to decrypt olm event: %w", err) + } + + if plaintext != nil { + // Decryption successful + return plaintext, nil + } + + // Decryption failed with every known session or no known sessions, let's try to create a new session. + // + // New sessions can only be created if it's a prekey message, we can't decrypt the message + // if it isn't one at this point in time anymore, so return early. + if olmType != id.OlmMsgTypePreKey { + go mach.unwedgeDevice(log, sender, senderKey) + return nil, ErrDecryptionFailedForNormalMessage + } + + accountBackup, _ := mach.account.Internal.Pickle([]byte("tmp")) + log.Trace().Msg("Trying to create inbound session") + endTimeTrace = mach.timeTrace(ctx, "creating inbound olm session", time.Second) + session, err := mach.account.NewInboundSessionFrom(senderKey, ciphertext) + endTimeTrace() + if err != nil { + go mach.unwedgeDevice(log, sender, senderKey) + return nil, fmt.Errorf("failed to create new session from prekey message: %w", err) + } + log = log.With().Str("new_olm_session_id", session.ID().String()).Logger() + log.Debug(). + Hex("ciphertext_hash", ciphertextHash[:]). + Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). + Str("olm_session_description", session.Describe()). + Msg("Created inbound olm session") + ctx = log.WithContext(ctx) + + endTimeTrace = mach.timeTrace(ctx, "decrypting prekey olm message", time.Second) + plaintext, err = session.Decrypt(ciphertext, olmType) + endTimeTrace() + if err != nil { + log.Debug(). + Hex("ciphertext_hash", ciphertextHash[:]). + Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). + Str("ciphertext", ciphertext). + Str("olm_session_description", session.Describe()). + Msg("DEBUG: Failed to decrypt prekey olm message with newly created session") + err2 := mach.goolmRetryHack(ctx, senderKey, ciphertext, accountBackup) + if err2 != nil { + log.Debug().Err(err2).Msg("Goolm confirmed decryption failure") + } else { + log.Warn().Msg("Goolm decryption was successful after libolm failure?") + } + + go mach.unwedgeDevice(log, sender, senderKey) + return nil, fmt.Errorf("failed to decrypt olm event with session created from prekey message: %w", err) + } + + endTimeTrace = mach.timeTrace(ctx, "saving new session in database", time.Second) + err = mach.CryptoStore.PutOlmHash(ctx, ciphertextHash, time.Now()) + if err != nil { + log.Warn().Err(err).Msg("Failed to store olm message hash after decrypting") + } + err = mach.CryptoStore.AddSession(ctx, senderKey, session) + if err != nil { + log.Warn().Err(err).Msg("Failed to save new olm session in crypto store after decrypting") + } else if err = mach.account.Internal.RemoveOneTimeKeys(session.Internal); err != nil { + log.Warn().Err(err).Msg("Failed to remove one time keys from olm account after creating new session") + } else { + mach.saveAccount(ctx) + } + endTimeTrace() + return plaintext, nil +} + +func (mach *OlmMachine) goolmRetryHack(ctx context.Context, senderKey id.SenderKey, ciphertext string, accountBackup []byte) error { + acc, err := account.AccountFromPickled(accountBackup, []byte("tmp")) + if err != nil { + return fmt.Errorf("failed to unpickle olm account: %w", err) + } + sess, err := acc.NewInboundSessionFrom(&senderKey, ciphertext) + if err != nil { + return fmt.Errorf("failed to create inbound session: %w", err) + } + _, err = sess.Decrypt(ciphertext, id.OlmMsgTypePreKey) + if err != nil { + // This is the expected result if libolm failed + return fmt.Errorf("failed to decrypt with new session: %w", err) + } + return nil +} + +const MaxOlmSessionsPerDevice = 5 + +func (mach *OlmMachine) tryDecryptOlmCiphertextWithExistingSession( + ctx context.Context, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string, ciphertextHash [32]byte, +) ([]byte, error) { + log := *zerolog.Ctx(ctx) + endTimeTrace := mach.timeTrace(ctx, "getting sessions with sender key", time.Second) + sessions, err := mach.CryptoStore.GetSessions(ctx, senderKey) + endTimeTrace() + if err != nil { + return nil, fmt.Errorf("failed to get session for %s: %w", senderKey, err) + } + if len(sessions) > MaxOlmSessionsPerDevice*2 { + // SQL store sorts sessions, but other implementations may not, so re-sort just in case + slices.SortFunc(sessions, func(a, b *OlmSession) int { + return b.LastDecryptedTime.Compare(a.LastDecryptedTime) + }) + log.Warn(). + Int("session_count", len(sessions)). + Time("newest_last_decrypted_at", sessions[0].LastDecryptedTime). + Time("oldest_last_decrypted_at", sessions[len(sessions)-1].LastDecryptedTime). + Msg("Too many sessions, deleting old ones") + for i := MaxOlmSessionsPerDevice; i < len(sessions); i++ { + err = mach.CryptoStore.DeleteSession(ctx, senderKey, sessions[i]) + if err != nil { + log.Warn().Err(err). + Stringer("olm_session_id", sessions[i].ID()). + Time("last_decrypt", sessions[i].LastDecryptedTime). + Msg("Failed to delete olm session") + } else { + log.Debug(). + Stringer("olm_session_id", sessions[i].ID()). + Time("last_decrypt", sessions[i].LastDecryptedTime). + Msg("Deleted olm session") + } + } + sessions = sessions[:MaxOlmSessionsPerDevice] + } + + for _, session := range sessions { + log := log.With().Str("olm_session_id", session.ID().String()).Logger() + ctx := log.WithContext(ctx) + if olmType == id.OlmMsgTypePreKey { + endTimeTrace = mach.timeTrace(ctx, "checking if prekey olm message matches session", time.Second) + matches, err := session.Internal.MatchesInboundSession(ciphertext) + endTimeTrace() + if err != nil { + return nil, fmt.Errorf("failed to check if ciphertext matches inbound session: %w", err) + } else if !matches { + continue + } + } + endTimeTrace = mach.timeTrace(ctx, "decrypting olm message", time.Second) + plaintext, err := session.Decrypt(ciphertext, olmType) + endTimeTrace() + if err != nil { + log.Warn().Err(err). + Hex("ciphertext_hash", ciphertextHash[:]). + Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). + Str("session_description", session.Describe()). + Msg("Failed to decrypt olm message") + if olmType == id.OlmMsgTypePreKey { + return nil, ErrDecryptionFailedWithMatchingSession + } + } else { + endTimeTrace = mach.timeTrace(ctx, "updating session in database", time.Second) + err = mach.CryptoStore.PutOlmHash(ctx, ciphertextHash, time.Now()) + if err != nil { + log.Warn().Err(err).Msg("Failed to store olm message hash after decrypting") + } + err = mach.CryptoStore.UpdateSession(ctx, senderKey, session) + endTimeTrace() + if err != nil { + log.Warn().Err(err).Msg("Failed to update olm session in crypto store after decrypting") + } + log.Debug(). + Hex("ciphertext_hash", ciphertextHash[:]). + Str("session_description", session.Describe()). + Msg("Decrypted olm message") + return plaintext, nil + } + } + return nil, nil +} + +const MinUnwedgeInterval = 1 * time.Hour + +func (mach *OlmMachine) unwedgeDevice(log zerolog.Logger, sender id.UserID, senderKey id.SenderKey) { + log = log.With().Str("action", "unwedge olm session").Logger() + ctx := log.WithContext(mach.backgroundCtx) + mach.recentlyUnwedgedLock.Lock() + prevUnwedge, ok := mach.recentlyUnwedged[senderKey] + delta := time.Since(prevUnwedge) + if ok && delta < MinUnwedgeInterval { + log.Debug(). + Str("previous_recreation", delta.String()). + Msg("Not creating new Olm session as it was already recreated recently") + mach.recentlyUnwedgedLock.Unlock() + return + } + mach.recentlyUnwedged[senderKey] = time.Now() + mach.recentlyUnwedgedLock.Unlock() + + lastCreatedAt, err := mach.CryptoStore.GetNewestSessionCreationTS(ctx, senderKey) + if err != nil { + log.Warn().Err(err).Msg("Failed to get newest session creation timestamp") + return + } else if time.Since(lastCreatedAt) < MinUnwedgeInterval { + log.Debug(). + Time("last_created_at", lastCreatedAt). + Msg("Not creating new Olm session as it was already recreated recently") + return + } + + deviceIdentity, err := mach.GetOrFetchDeviceByKey(ctx, sender, senderKey) + if err != nil { + log.Error().Err(err).Msg("Failed to find device info by identity key") + return + } else if deviceIdentity == nil { + log.Warn().Msg("Didn't find identity for device") + return + } + + log.Debug(). + Time("last_created", lastCreatedAt). + Stringer("device_id", deviceIdentity.DeviceID). + Msg("Creating new Olm session") + mach.devicesToUnwedgeLock.Lock() + mach.devicesToUnwedge[senderKey] = true + mach.devicesToUnwedgeLock.Unlock() + err = mach.SendEncryptedToDevice(ctx, deviceIdentity, event.ToDeviceDummy, event.Content{}) + if err != nil { + log.Error().Err(err).Msg("Failed to send dummy event to unwedge session") + } +} diff --git a/mautrix-patched/crypto/devicelist.go b/mautrix-patched/crypto/devicelist.go new file mode 100644 index 00000000..6e6c4c76 --- /dev/null +++ b/mautrix-patched/crypto/devicelist.go @@ -0,0 +1,386 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/rs/zerolog" + "go.mau.fi/util/exzerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/id" +) + +var ( + ErrMismatchingDeviceID = errors.New("mismatching device ID in parameter and keys object") + ErrMismatchingUserID = errors.New("mismatching user ID in parameter and keys object") + ErrMismatchingSigningKey = errors.New("received update for device with different signing key") + ErrNoSigningKeyFound = errors.New("didn't find ed25519 signing key") + ErrNoIdentityKeyFound = errors.New("didn't find curve25519 identity key") + ErrInvalidKeySignature = errors.New("invalid signature on device keys") + ErrUserNotTracked = errors.New("user is not tracked") +) + +// Deprecated: use variables prefixed with Err +var ( + MismatchingDeviceID = ErrMismatchingDeviceID + MismatchingUserID = ErrMismatchingUserID + MismatchingSigningKey = ErrMismatchingSigningKey + NoSigningKeyFound = ErrNoSigningKeyFound + NoIdentityKeyFound = ErrNoIdentityKeyFound + InvalidKeySignature = ErrInvalidKeySignature +) + +func (mach *OlmMachine) LoadDevices(ctx context.Context, user id.UserID) (keys map[id.DeviceID]*id.Device) { + log := zerolog.Ctx(ctx) + + if keys, err := mach.FetchKeys(ctx, []id.UserID{user}, true); err != nil { + log.Err(err).Msg("Failed to load devices") + } else if keys != nil { + return keys[user] + } + + return nil +} + +type CachedDevices struct { + Devices []*id.Device + MasterKey *id.CrossSigningKey + HasValidSelfSigningKey bool + MasterKeySignedByUs bool +} + +func (mach *OlmMachine) GetCachedDevices(ctx context.Context, userID id.UserID) (*CachedDevices, error) { + userIDs, err := mach.CryptoStore.FilterTrackedUsers(ctx, []id.UserID{userID}) + if err != nil { + return nil, fmt.Errorf("failed to check if user's devices are tracked: %w", err) + } else if len(userIDs) == 0 { + return nil, ErrUserNotTracked + } + ownKeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } + var ownUserSigningKey id.Ed25519 + if ownKeys != nil { + ownUserSigningKey = ownKeys.UserSigningKey + } + var resp CachedDevices + csKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) + theirMasterKey := csKeys[id.XSUsageMaster] + theirSelfSignKey := csKeys[id.XSUsageSelfSigning] + if err != nil { + return nil, fmt.Errorf("failed to get cross-signing keys: %w", err) + } else if csKeys != nil && theirMasterKey.Key != "" { + resp.MasterKey = &theirMasterKey + if theirSelfSignKey.Key != "" { + resp.HasValidSelfSigningKey, err = mach.CryptoStore.IsKeySignedBy(ctx, userID, theirSelfSignKey.Key, userID, theirMasterKey.Key) + if err != nil { + return nil, fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err) + } + } + } + devices, err := mach.CryptoStore.GetDevices(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get devices: %w", err) + } + if userID == mach.Client.UserID { + if ownKeys != nil && ownKeys.MasterKey == theirMasterKey.Key { + resp.MasterKeySignedByUs, _, _, err = mach.GetOwnCrossSigningVerificationStatus(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check if own cross-signing keys are trusted: %w", err) + } + } + } else if ownUserSigningKey != "" && theirMasterKey.Key != "" { + resp.MasterKeySignedByUs, err = mach.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, mach.Client.UserID, ownUserSigningKey) + if resp.MasterKeySignedByUs { + ownMKTrusted, _, ownUSKTrusted, err := mach.GetOwnCrossSigningVerificationStatus(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check if own cross-signing keys are trusted: %w", err) + } else if !ownMKTrusted || !ownUSKTrusted { + // TODO log warning? + resp.MasterKeySignedByUs = false + } + } + } + if err != nil { + return nil, fmt.Errorf("failed to check if user is trusted: %w", err) + } + resp.Devices = make([]*id.Device, len(devices)) + i := 0 + for _, device := range devices { + resp.Devices[i] = device + if resp.HasValidSelfSigningKey && device.Trust == id.TrustStateUnset { + signed, err := mach.CryptoStore.IsKeySignedBy(ctx, device.UserID, device.SigningKey, device.UserID, theirSelfSignKey.Key) + if err != nil { + return nil, fmt.Errorf("failed to check if device %s is signed by self-signing key: %w", device.DeviceID, err) + } else if signed { + if resp.MasterKeySignedByUs { + device.Trust = id.TrustStateCrossSignedVerified + } else if theirMasterKey.Key == theirMasterKey.First { + device.Trust = id.TrustStateCrossSignedTOFU + } else { + device.Trust = id.TrustStateCrossSignedUntrusted + } + } + } + i++ + } + slices.SortFunc(resp.Devices, func(a, b *id.Device) int { + return strings.Compare(a.DeviceID.String(), b.DeviceID.String()) + }) + return &resp, nil +} + +func (mach *OlmMachine) storeDeviceSelfSignatures(ctx context.Context, userID id.UserID, deviceID id.DeviceID, resp *mautrix.RespQueryKeys) { + log := zerolog.Ctx(ctx) + deviceKeys := resp.DeviceKeys[userID][deviceID] + for signerUserID, signerKeys := range deviceKeys.Signatures { + for signerKey, signature := range signerKeys { + // verify and save self-signing key signature for each device + if selfSignKeys, ok := resp.SelfSigningKeys[signerUserID]; ok { + for _, pubKey := range selfSignKeys.Keys { + if selfSigs, ok := deviceKeys.Signatures[signerUserID]; !ok { + continue + } else if _, ok := selfSigs[id.NewKeyID(id.KeyAlgorithmEd25519, pubKey.String())]; !ok { + continue + } + if verified, err := signatures.VerifySignatureJSON(deviceKeys, signerUserID, pubKey.String(), pubKey); verified { + if signKey, ok := deviceKeys.Keys[id.DeviceKeyID(signerKey)]; ok { + signature := deviceKeys.Signatures[signerUserID][id.NewKeyID(id.KeyAlgorithmEd25519, pubKey.String())] + log.Trace().Err(err). + Str("signer_user_id", signerUserID.String()). + Str("signed_device_id", deviceID.String()). + Str("signature", signature). + Msg("Verified self-signing signature") + err = mach.CryptoStore.PutSignature(ctx, userID, id.Ed25519(signKey), signerUserID, pubKey, signature) + if err != nil { + log.Warn().Err(err). + Str("signer_user_id", signerUserID.String()). + Str("signed_device_id", deviceID.String()). + Msg("Failed to store self-signing signature") + } + } + } else { + if err == nil { + err = errors.New("invalid signature") + } + log.Warn().Err(err). + Str("signer_user_id", signerUserID.String()). + Str("signed_device_id", deviceID.String()). + Msg("Failed to verify self-signing signature") + } + } + } + // save signature of device made by its own device signing key + if signKey, ok := deviceKeys.Keys[id.DeviceKeyID(signerKey)]; ok { + err := mach.CryptoStore.PutSignature(ctx, userID, id.Ed25519(signKey), signerUserID, id.Ed25519(signKey), signature) + if err != nil { + log.Warn().Err(err). + Str("signer_user_id", signerUserID.String()). + Str("signer_key", signKey). + Msg("Failed to store self-signing signature") + } + } + } + } +} + +// FetchKeys fetches the devices of a list of other users. If includeUntracked +// is set to false, then the users are filtered to to only include user IDs +// whose device lists have been stored with the PutDevices function on the +// [Store]. See the FilterTrackedUsers function on [Store] for details. +func (mach *OlmMachine) FetchKeys(ctx context.Context, users []id.UserID, includeUntracked bool) (data map[id.UserID]map[id.DeviceID]*id.Device, err error) { + req := &mautrix.ReqQueryKeys{ + DeviceKeys: mautrix.DeviceKeysRequest{}, + Timeout: 10 * 1000, + } + log := mach.machOrContextLog(ctx) + if !includeUntracked { + users, err = mach.CryptoStore.FilterTrackedUsers(ctx, users) + if err != nil { + return nil, fmt.Errorf("failed to filter tracked user list: %w", err) + } + } + if len(users) == 0 { + return + } + for _, userID := range users { + req.DeviceKeys[userID] = mautrix.DeviceIDList{} + } + log.Debug().Array("users", exzerolog.ArrayOfStrs(users)).Msg("Querying keys for users") + resp, err := mach.Client.QueryKeys(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to query keys: %w", err) + } + for server, err := range resp.Failures { + log.Warn().Interface("query_error", err).Str("server", server).Msg("Query keys failure for server") + } + log.Trace().Int("user_count", len(resp.DeviceKeys)).Msg("Query key result received") + data = make(map[id.UserID]map[id.DeviceID]*id.Device) + for userID, devices := range resp.DeviceKeys { + log := log.With().Stringer("user_id", userID).Logger() + delete(req.DeviceKeys, userID) + + newDevices := make(map[id.DeviceID]*id.Device) + existingDevices, err := mach.CryptoStore.GetDevices(ctx, userID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get existing devices for user") + existingDevices = make(map[id.DeviceID]*id.Device) + } + + log.Debug(). + Int("new_device_count", len(devices)). + Int("old_device_count", len(existingDevices)). + Msg("Updating devices in store") + changed := false + for deviceID, deviceKeys := range devices { + log := log.With().Stringer("device_id", deviceID).Logger() + existing, existed := existingDevices[deviceID] + log.Trace().Msg("Validating device") + newDevice, err := mach.validateDevice(userID, deviceID, deviceKeys, existing) + if err != nil { + log.Error().Err(err).Msg("Failed to validate device") + } else if newDevice != nil { + newDevices[deviceID] = newDevice + mach.storeDeviceSelfSignatures(ctx, userID, deviceID, resp) + if !existed { + // New device, always mark as changed so megolm keys are reset even if + // the total count stays the same (e.g. one device added and one removed). + changed = true + } + } + } + log.Trace().Int("new_device_count", len(newDevices)).Msg("Storing new device list") + err = mach.CryptoStore.PutDevices(ctx, userID, newDevices) + if err != nil { + log.Warn().Err(err).Msg("Failed to update device list") + } + data[userID] = newDevices + + changed = changed || len(newDevices) != len(existingDevices) + if changed { + if mach.DeleteKeysOnDeviceDelete { + for deviceID := range newDevices { + delete(existingDevices, deviceID) + } + for _, device := range existingDevices { + log := log.With(). + Str("device_id", device.DeviceID.String()). + Str("identity_key", device.IdentityKey.String()). + Str("signing_key", device.SigningKey.String()). + Logger() + sessionIDs, err := mach.CryptoStore.RedactGroupSessions(ctx, "", device.IdentityKey, "device removed") + if err != nil { + log.Err(err).Msg("Failed to redact megolm sessions from deleted device") + } else { + log.Info(). + Array("session_ids", exzerolog.ArrayOfStrs(sessionIDs)). + Msg("Redacted megolm sessions from deleted device") + } + } + } + mach.OnDevicesChanged(ctx, userID) + } + } + for userID := range req.DeviceKeys { + log.Warn().Stringer("user_id", userID).Msg("Didn't get any keys for user") + } + + mach.storeCrossSigningKeys(ctx, resp.MasterKeys, resp.DeviceKeys) + mach.storeCrossSigningKeys(ctx, resp.SelfSigningKeys, resp.DeviceKeys) + mach.storeCrossSigningKeys(ctx, resp.UserSigningKeys, resp.DeviceKeys) + + return data, nil +} + +// OnDevicesChanged finds all shared rooms with the given user and invalidates outbound sessions in those rooms. +// +// This is called automatically whenever a device list change is noticed in ProcessSyncResponse and usually does +// not need to be called manually. +func (mach *OlmMachine) OnDevicesChanged(ctx context.Context, userID id.UserID) { + if mach.DisableDeviceChangeKeyRotation { + return + } + rooms, err := mach.StateStore.FindSharedRooms(ctx, userID) + if err != nil { + mach.machOrContextLog(ctx).Err(err). + Stringer("with_user_id", userID). + Msg("Failed to find shared rooms to invalidate group sessions") + return + } + for _, roomID := range rooms { + mach.machOrContextLog(ctx).Debug(). + Str("user_id", userID.String()). + Str("room_id", roomID.String()). + Msg("Invalidating group session in room due to device change notification") + err = mach.CryptoStore.RemoveOutboundGroupSession(ctx, roomID) + if err != nil { + mach.machOrContextLog(ctx).Err(err). + Str("user_id", userID.String()). + Str("room_id", roomID.String()). + Msg("Failed to invalidate outbound group session") + } + } +} + +func (mach *OlmMachine) validateDevice(userID id.UserID, deviceID id.DeviceID, deviceKeys mautrix.DeviceKeys, existing *id.Device) (*id.Device, error) { + if deviceID != deviceKeys.DeviceID { + return nil, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingDeviceID, deviceID, deviceKeys.DeviceID) + } else if userID != deviceKeys.UserID { + return nil, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingUserID, userID, deviceKeys.UserID) + } + + signingKey := deviceKeys.Keys.GetEd25519(deviceID) + identityKey := deviceKeys.Keys.GetCurve25519(deviceID) + if signingKey == "" { + return nil, ErrNoSigningKeyFound + } else if identityKey == "" { + return nil, ErrNoIdentityKeyFound + } + + if existing != nil && existing.SigningKey != signingKey { + return existing, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingSigningKey, existing.SigningKey, signingKey) + } + + ok, err := signatures.VerifySignatureJSON(deviceKeys, userID, deviceID.String(), signingKey) + if err != nil { + return existing, fmt.Errorf("failed to verify signature: %w", err) + } else if !ok { + return existing, ErrInvalidKeySignature + } + + name, ok := deviceKeys.Unsigned["device_display_name"].(string) + if !ok { + if deviceKeys.Dehydrated { + name = "Dehydrated device" + } else { + name = string(deviceID) + } + } + + // This trust state doesn't matter much as most trust happens through cross-signing, but preserve it anyway. + trust := id.TrustStateUnset + if existing != nil { + trust = existing.Trust + } + return &id.Device{ + UserID: userID, + DeviceID: deviceID, + IdentityKey: identityKey, + SigningKey: signingKey, + Trust: trust, + Name: name, + Deleted: false, + }, nil +} diff --git a/mautrix-patched/crypto/ed25519/ed25519.go b/mautrix-patched/crypto/ed25519/ed25519.go new file mode 100644 index 00000000..327cbb3c --- /dev/null +++ b/mautrix-patched/crypto/ed25519/ed25519.go @@ -0,0 +1,302 @@ +// Copyright 2024 Sumner Evans. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package ed25519 implements the Ed25519 signature algorithm. See +// https://ed25519.cr.yp.to/. +// +// This package stores the private key in the NaCl format, which is a different +// format than that used by the [crypto/ed25519] package in the standard +// library. +// +// This picture will help with the rest of the explanation: +// https://blog.mozilla.org/warner/files/2011/11/key-formats.png +// +// The private key in the [crypto/ed25519] package is a 64-byte value where the +// first 32-bytes are the seed and the last 32-bytes are the public key. +// +// The private key in this package is stored in the NaCl format. That is, the +// left 32-bytes are the private scalar A and the right 32-bytes are the right +// half of the SHA512 result. +// +// The contents of this package are mostly copied from the standard library, +// and as such the source code is licensed under the BSD license of the +// standard library implementation. +// +// Other notable changes from the standard library include: +// +// - The Seed function of the standard library is not implemented in this +// package because there is no way to recover the seed after hashing it. +package ed25519 + +import ( + "crypto" + "crypto/ed25519" + cryptorand "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "errors" + "io" + "strconv" + + "filippo.io/edwards25519" +) + +const ( + // PublicKeySize is the size, in bytes, of public keys as used in this package. + PublicKeySize = 32 + // PrivateKeySize is the size, in bytes, of private keys as used in this package. + PrivateKeySize = 64 + // SignatureSize is the size, in bytes, of signatures generated and verified by this package. + SignatureSize = 64 + // SeedSize is the size, in bytes, of private key seeds. These are the private key representations used by RFC 8032. + SeedSize = 32 +) + +// PublicKey is the type of Ed25519 public keys. +type PublicKey []byte + +// Any methods implemented on PublicKey might need to also be implemented on +// PrivateKey, as the latter embeds the former and will expose its methods. + +// Equal reports whether pub and x have the same value. +func (pub PublicKey) Equal(x crypto.PublicKey) bool { + switch x := x.(type) { + case PublicKey: + return subtle.ConstantTimeCompare(pub, x) == 1 + case ed25519.PublicKey: + return subtle.ConstantTimeCompare(pub, x) == 1 + default: + return false + } +} + +// PrivateKey is the type of Ed25519 private keys. It implements [crypto.Signer]. +type PrivateKey []byte + +// Public returns the [PublicKey] corresponding to priv. +// +// This method differs from the standard library because it calculates the +// public key instead of returning the right half of the private key (which +// contains the public key in the standard library). +func (priv PrivateKey) Public() crypto.PublicKey { + s, err := edwards25519.NewScalar().SetBytesWithClamping(priv[:32]) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + return (&edwards25519.Point{}).ScalarBaseMult(s).Bytes() +} + +// Equal reports whether priv and x have the same value. +func (priv PrivateKey) Equal(x crypto.PrivateKey) bool { + // TODO do we have any need to check equality with standard library ed25519 + // private keys? + xx, ok := x.(PrivateKey) + if !ok { + return false + } + return subtle.ConstantTimeCompare(priv, xx) == 1 +} + +// Sign signs the given message with priv. rand is ignored and can be nil. +// +// If opts.HashFunc() is [crypto.SHA512], the pre-hashed variant Ed25519ph is used +// and message is expected to be a SHA-512 hash, otherwise opts.HashFunc() must +// be [crypto.Hash](0) and the message must not be hashed, as Ed25519 performs two +// passes over messages to be signed. +// +// A value of type [Options] can be used as opts, or crypto.Hash(0) or +// crypto.SHA512 directly to select plain Ed25519 or Ed25519ph, respectively. +func (priv PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerOpts) (signature []byte, err error) { + hash := opts.HashFunc() + context := "" + if opts, ok := opts.(*Options); ok { + context = opts.Context + } + switch { + case hash == crypto.SHA512: // Ed25519ph + if l := len(message); l != sha512.Size { + return nil, errors.New("ed25519: bad Ed25519ph message hash length: " + strconv.Itoa(l)) + } + if l := len(context); l > 255 { + return nil, errors.New("ed25519: bad Ed25519ph context length: " + strconv.Itoa(l)) + } + signature := make([]byte, SignatureSize) + sign(signature, priv, message, domPrefixPh, context) + return signature, nil + case hash == crypto.Hash(0) && context != "": // Ed25519ctx + if l := len(context); l > 255 { + return nil, errors.New("ed25519: bad Ed25519ctx context length: " + strconv.Itoa(l)) + } + signature := make([]byte, SignatureSize) + sign(signature, priv, message, domPrefixCtx, context) + return signature, nil + case hash == crypto.Hash(0): // Ed25519 + return Sign(priv, message), nil + default: + return nil, errors.New("ed25519: expected opts.HashFunc() zero (unhashed message, for standard Ed25519) or SHA-512 (for Ed25519ph)") + } +} + +// Options can be used with [PrivateKey.Sign] or [VerifyWithOptions] +// to select Ed25519 variants. +type Options struct { + // Hash can be zero for regular Ed25519, or crypto.SHA512 for Ed25519ph. + Hash crypto.Hash + + // Context, if not empty, selects Ed25519ctx or provides the context string + // for Ed25519ph. It can be at most 255 bytes in length. + Context string +} + +// HashFunc returns o.Hash. +func (o *Options) HashFunc() crypto.Hash { return o.Hash } + +// GenerateKey generates a public/private key pair using entropy from rand. +// If rand is nil, [crypto/rand.Reader] will be used. +// +// The output of this function is deterministic, and equivalent to reading +// [SeedSize] bytes from rand, and passing them to [NewKeyFromSeed]. +func GenerateKey(rand io.Reader) (PublicKey, PrivateKey, error) { + if rand == nil { + rand = cryptorand.Reader + } + + seed := make([]byte, SeedSize) + if _, err := io.ReadFull(rand, seed); err != nil { + return nil, nil, err + } + + privateKey := NewKeyFromSeed(seed) + return PublicKey(privateKey.Public().([]byte)), privateKey, nil +} + +// NewKeyFromSeed calculates a private key from a seed. It will panic if +// len(seed) is not [SeedSize]. This function is provided for interoperability +// with RFC 8032. RFC 8032's private keys correspond to seeds in this +// package. +func NewKeyFromSeed(seed []byte) PrivateKey { + // Outline the function body so that the returned key can be stack-allocated. + privateKey := make([]byte, PrivateKeySize) + newKeyFromSeed(privateKey, seed) + return privateKey +} + +func newKeyFromSeed(privateKey, seed []byte) { + if l := len(seed); l != SeedSize { + panic("ed25519: bad seed length: " + strconv.Itoa(l)) + } + + h := sha512.Sum512(seed) + + // Apply clamping to get A in the left half, and leave the right half + // as-is. This gets the private key into the NaCl format. + h[0] &= 248 + h[31] &= 63 + h[31] |= 64 + copy(privateKey, h[:]) +} + +// Sign signs the message with privateKey and returns a signature. It will +// panic if len(privateKey) is not [PrivateKeySize]. +func Sign(privateKey PrivateKey, message []byte) []byte { + // Outline the function body so that the returned signature can be + // stack-allocated. + signature := make([]byte, SignatureSize) + sign(signature, privateKey, message, domPrefixPure, "") + return signature +} + +// Domain separation prefixes used to disambiguate Ed25519/Ed25519ph/Ed25519ctx. +// See RFC 8032, Section 2 and Section 5.1. +const ( + // domPrefixPure is empty for pure Ed25519. + domPrefixPure = "" + // domPrefixPh is dom2(phflag=1) for Ed25519ph. It must be followed by the + // uint8-length prefixed context. + domPrefixPh = "SigEd25519 no Ed25519 collisions\x01" + // domPrefixCtx is dom2(phflag=0) for Ed25519ctx. It must be followed by the + // uint8-length prefixed context. + domPrefixCtx = "SigEd25519 no Ed25519 collisions\x00" +) + +func sign(signature []byte, privateKey PrivateKey, message []byte, domPrefix, context string) { + if l := len(privateKey); l != PrivateKeySize { + panic("ed25519: bad private key length: " + strconv.Itoa(l)) + } + // We have to extract the public key from the private key. + publicKey := privateKey.Public().([]byte) + // The private key is already the hashed value of the seed. + h := privateKey + + s, err := edwards25519.NewScalar().SetBytesWithClamping(h[:32]) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + prefix := h[32:] + + mh := sha512.New() + if domPrefix != domPrefixPure { + mh.Write([]byte(domPrefix)) + mh.Write([]byte{byte(len(context))}) + mh.Write([]byte(context)) + } + mh.Write(prefix) + mh.Write(message) + messageDigest := make([]byte, 0, sha512.Size) + messageDigest = mh.Sum(messageDigest) + r, err := edwards25519.NewScalar().SetUniformBytes(messageDigest) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + + R := (&edwards25519.Point{}).ScalarBaseMult(r) + + kh := sha512.New() + if domPrefix != domPrefixPure { + kh.Write([]byte(domPrefix)) + kh.Write([]byte{byte(len(context))}) + kh.Write([]byte(context)) + } + kh.Write(R.Bytes()) + kh.Write(publicKey) + kh.Write(message) + hramDigest := make([]byte, 0, sha512.Size) + hramDigest = kh.Sum(hramDigest) + k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + + S := edwards25519.NewScalar().MultiplyAdd(k, s, r) + + copy(signature[:32], R.Bytes()) + copy(signature[32:], S.Bytes()) +} + +// Verify reports whether sig is a valid signature of message by publicKey. It +// will panic if len(publicKey) is not [PublicKeySize]. +// +// This is just a wrapper around [ed25519.Verify] from the standard library. +func Verify(publicKey PublicKey, message, sig []byte) bool { + return ed25519.Verify(ed25519.PublicKey(publicKey), message, sig) +} + +// VerifyWithOptions reports whether sig is a valid signature of message by +// publicKey. A valid signature is indicated by returning a nil error. It will +// panic if len(publicKey) is not [PublicKeySize]. +// +// If opts.Hash is [crypto.SHA512], the pre-hashed variant Ed25519ph is used and +// message is expected to be a SHA-512 hash, otherwise opts.Hash must be +// [crypto.Hash](0) and the message must not be hashed, as Ed25519 performs two +// passes over messages to be signed. +// +// This is just a wrapper around [ed25519.VerifyWithOptions] from the standard +// library. +func VerifyWithOptions(publicKey PublicKey, message, sig []byte, opts *Options) error { + return ed25519.VerifyWithOptions(ed25519.PublicKey(publicKey), message, sig, &ed25519.Options{ + Hash: opts.Hash, + Context: opts.Context, + }) +} diff --git a/mautrix-patched/crypto/ed25519/ed25519_test.go b/mautrix-patched/crypto/ed25519/ed25519_test.go new file mode 100644 index 00000000..931c06f6 --- /dev/null +++ b/mautrix-patched/crypto/ed25519/ed25519_test.go @@ -0,0 +1,20 @@ +package ed25519_test + +import ( + stdlibed25519 "crypto/ed25519" + "testing" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/crypto/ed25519" +) + +func TestPubkeyEqual(t *testing.T) { + pubkeyBytes := random.Bytes(32) + pubkey := ed25519.PublicKey(pubkeyBytes) + pubkey2 := ed25519.PublicKey(pubkeyBytes) + stdlibPubkey := stdlibed25519.PublicKey(pubkeyBytes) + assert.True(t, pubkey.Equal(pubkey2)) + assert.True(t, pubkey.Equal(stdlibPubkey)) +} diff --git a/mautrix-patched/crypto/encryptmegolm.go b/mautrix-patched/crypto/encryptmegolm.go new file mode 100644 index 00000000..88f9c8d4 --- /dev/null +++ b/mautrix-patched/crypto/encryptmegolm.go @@ -0,0 +1,463 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + + "github.com/rs/zerolog" + "github.com/tidwall/gjson" + "go.mau.fi/util/exgjson" + "go.mau.fi/util/exzerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ( + ErrNoGroupSession = errors.New("no group session created") +) + +// Deprecated: use variables prefixed with Err +var ( + NoGroupSession = ErrNoGroupSession +) + +func getRawJSON[T any](content json.RawMessage, path ...string) *T { + value := gjson.GetBytes(content, exgjson.Path(path...)) + if !value.IsObject() { + return nil + } + var result T + err := json.Unmarshal([]byte(value.Raw), &result) + if err != nil { + return nil + } + return &result +} + +func getRelatesTo(content any, plaintext json.RawMessage) *event.RelatesTo { + contentJSON, ok := content.(json.RawMessage) + if ok { + return getRawJSON[event.RelatesTo](contentJSON, "m.relates_to") + } + contentStruct, ok := content.(*event.Content) + if ok { + content = contentStruct.Parsed + } + relatable, ok := content.(event.Relatable) + if ok { + return relatable.OptionalGetRelatesTo() + } + return getRawJSON[event.RelatesTo](plaintext, "content", "m.relates_to") +} + +func getMentions(content any) *event.Mentions { + contentJSON, ok := content.(json.RawMessage) + if ok { + return getRawJSON[event.Mentions](contentJSON, "m.mentions") + } + contentStruct, ok := content.(*event.Content) + if ok { + content = contentStruct.Parsed + } + message, ok := content.(*event.MessageEventContent) + if ok { + return message.Mentions + } + return nil +} + +type rawMegolmEvent struct { + RoomID id.RoomID `json:"room_id"` + Type event.Type `json:"type"` + StateKey *string `json:"state_key,omitempty"` + Content interface{} `json:"content"` +} + +// IsShareError returns true if the error is caused by the lack of an outgoing megolm session and can be solved with OlmMachine.ShareGroupSession +func IsShareError(err error) bool { + return err == ErrSessionExpired || err == ErrSessionNotShared || err == ErrNoGroupSession +} + +func ParseMegolmMessageIndex(ciphertext []byte) (uint, error) { + if len(ciphertext) == 0 { + return 0, fmt.Errorf("empty ciphertext") + } + decoded := make([]byte, base64.RawStdEncoding.DecodedLen(len(ciphertext))) + var err error + _, err = base64.RawStdEncoding.Decode(decoded, ciphertext) + if err != nil { + return 0, err + } else if len(decoded) < 2+binary.MaxVarintLen64 { + return 0, fmt.Errorf("decoded ciphertext too short: %d bytes", len(decoded)) + } else if decoded[0] != 3 || decoded[1] != 8 { + return 0, fmt.Errorf("unexpected initial bytes %d and %d", decoded[0], decoded[1]) + } + index, read := binary.Uvarint(decoded[2 : 2+binary.MaxVarintLen64]) + if read <= 0 { + return 0, fmt.Errorf("failed to decode varint, read value %d", read) + } + return uint(index), nil +} + +// EncryptMegolmEvent encrypts data with the m.megolm.v1.aes-sha2 algorithm. +// +// If you use the event.Content struct, make sure you pass a pointer to the struct, +// as JSON serialization will not work correctly otherwise. +func (mach *OlmMachine) EncryptMegolmEvent(ctx context.Context, roomID id.RoomID, evtType event.Type, content interface{}) (*event.EncryptedEventContent, error) { + return mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, nil, content) +} + +// EncryptMegolmEventWithStateKey encrypts data with the m.megolm.v1.aes-sha2 algorithm. +// +// If you use the event.Content struct, make sure you pass a pointer to the struct, +// as JSON serialization will not work correctly otherwise. +func (mach *OlmMachine) EncryptMegolmEventWithStateKey(ctx context.Context, roomID id.RoomID, evtType event.Type, stateKey *string, content interface{}) (*event.EncryptedEventContent, error) { + mach.megolmEncryptLock.Lock() + defer mach.megolmEncryptLock.Unlock() + session, err := mach.CryptoStore.GetOutboundGroupSession(ctx, roomID) + if err != nil { + return nil, fmt.Errorf("failed to get outbound group session: %w", err) + } else if session == nil { + return nil, ErrNoGroupSession + } + plaintext, err := json.Marshal(&rawMegolmEvent{ + RoomID: roomID, + Type: evtType, + StateKey: stateKey, + Content: content, + }) + if err != nil { + return nil, err + } + log := mach.machOrContextLog(ctx).With(). + Str("event_type", evtType.Type). + Any("state_key", stateKey). + Str("room_id", roomID.String()). + Str("session_id", session.ID().String()). + Uint("expected_index", session.Internal.MessageIndex()). + Logger() + log.Trace().Msg("Encrypting event...") + ciphertext, err := session.Encrypt(plaintext) + if err != nil { + return nil, err + } + idx, err := ParseMegolmMessageIndex(ciphertext) + if err != nil { + log.Warn().Err(err).Msg("Failed to get megolm message index of encrypted event") + } else { + log = log.With().Uint("message_index", idx).Logger() + } + log.Debug().Msg("Encrypted event successfully") + err = mach.CryptoStore.UpdateOutboundGroupSession(ctx, session) + if err != nil { + return nil, fmt.Errorf("failed to update outbound group session after encrypting: %w", err) + } + encrypted := &event.EncryptedEventContent{ + Algorithm: id.AlgorithmMegolmV1, + SessionID: session.ID(), + MegolmCiphertext: ciphertext, + RelatesTo: getRelatesTo(content, plaintext), + + // These are deprecated + SenderKey: mach.account.IdentityKey(), + DeviceID: mach.Client.DeviceID, + } + if mach.MSC4392Relations && encrypted.RelatesTo != nil { + // When MSC4392 mode is enabled, reply and reaction metadata is stripped from the unencrypted content. + // Other relations like threads are still left unencrypted. + encrypted.RelatesTo.InReplyTo = nil + encrypted.RelatesTo.IsFallingBack = false + if evtType == event.EventReaction || encrypted.RelatesTo.Type == "" { + encrypted.RelatesTo = nil + } + } + if mach.PlaintextMentions { + encrypted.Mentions = getMentions(content) + } + return encrypted, nil +} + +func (mach *OlmMachine) newOutboundGroupSession(ctx context.Context, roomID id.RoomID) (*OutboundGroupSession, error) { + encryptionEvent, err := mach.StateStore.GetEncryptionEvent(ctx, roomID) + if err != nil { + mach.machOrContextLog(ctx).Err(err). + Stringer("room_id", roomID). + Msg("Failed to get encryption event in room") + return nil, fmt.Errorf("failed to get encryption event in room %s: %w", roomID, err) + } + session, err := NewOutboundGroupSession(roomID, encryptionEvent) + if err != nil { + return nil, err + } + if !mach.DontStoreOutboundKeys { + signingKey, idKey := mach.account.Keys() + err := mach.createGroupSession(ctx, idKey, signingKey, roomID, session.ID(), session.Internal.Key(), session.MaxAge, session.MaxMessages, false) + if err != nil { + return nil, err + } + } + return session, err +} + +type deviceSessionWrapper struct { + session *OlmSession + identity *id.Device +} + +// ShareGroupSession shares a group session for a specific room with all the devices of the given user list. +// +// For devices with TrustStateBlacklisted, a m.room_key.withheld event with code=m.blacklisted is sent. +// If AllowUnverifiedDevices is false, a similar event with code=m.unverified is sent to devices with TrustStateUnset +func (mach *OlmMachine) ShareGroupSession(ctx context.Context, roomID id.RoomID, users []id.UserID) error { + mach.megolmEncryptLock.Lock() + defer mach.megolmEncryptLock.Unlock() + session, err := mach.CryptoStore.GetOutboundGroupSession(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to get previous outbound group session: %w", err) + } else if session != nil && session.Shared && !session.Expired() { + mach.machOrContextLog(ctx).Debug().Stringer("room_id", roomID).Msg("Not re-sharing group session, already shared") + return nil + } + log := mach.machOrContextLog(ctx).With(). + Str("room_id", roomID.String()). + Str("action", "share megolm session"). + Logger() + ctx = log.WithContext(ctx) + if session == nil || session.Expired() { + if session, err = mach.newOutboundGroupSession(ctx, roomID); err != nil { + return err + } + } + log = log.With().Str("session_id", session.ID().String()).Logger() + ctx = log.WithContext(ctx) + log.Debug().Array("users", exzerolog.ArrayOfStrs(users)).Msg("Sharing group session for room") + + withheldCount := 0 + toDeviceWithheld := &mautrix.ReqSendToDevice{Messages: make(map[id.UserID]map[id.DeviceID]*event.Content)} + olmSessions := make(map[id.UserID]map[id.DeviceID]deviceSessionWrapper) + missingSessions := make(map[id.UserID]map[id.DeviceID]*id.Device) + missingUserSessions := make(map[id.DeviceID]*id.Device) + var fetchKeysForUsers []id.UserID + + for _, userID := range users { + log := log.With().Stringer("target_user_id", userID).Logger() + devices, err := mach.CryptoStore.GetDevices(ctx, userID) + if err != nil { + log.Err(err).Msg("Failed to get devices of user") + return fmt.Errorf("failed to get devices of user %s: %w", userID, err) + } else if devices == nil { + log.Debug().Msg("GetDevices returned nil, will fetch keys and retry") + fetchKeysForUsers = append(fetchKeysForUsers, userID) + } else if len(devices) == 0 { + log.Trace().Msg("User has no devices, skipping") + } else { + log.Trace().Msg("Trying to find olm session to encrypt megolm session for user") + toDeviceWithheld.Messages[userID] = make(map[id.DeviceID]*event.Content) + olmSessions[userID] = make(map[id.DeviceID]deviceSessionWrapper) + mach.findOlmSessionsForUser(ctx, session, userID, devices, olmSessions[userID], toDeviceWithheld.Messages[userID], missingUserSessions) + log.Debug(). + Int("olm_session_count", len(olmSessions[userID])). + Int("withheld_count", len(toDeviceWithheld.Messages[userID])). + Int("missing_count", len(missingUserSessions)). + Msg("Completed first pass of finding olm sessions") + withheldCount += len(toDeviceWithheld.Messages[userID]) + if len(missingUserSessions) > 0 { + missingSessions[userID] = missingUserSessions + missingUserSessions = make(map[id.DeviceID]*id.Device) + } + if len(toDeviceWithheld.Messages[userID]) == 0 { + delete(toDeviceWithheld.Messages, userID) + } + } + } + + if len(fetchKeysForUsers) > 0 { + log.Debug().Array("users", exzerolog.ArrayOfStrs(fetchKeysForUsers)).Msg("Fetching missing keys") + keys, err := mach.FetchKeys(ctx, fetchKeysForUsers, true) + if err != nil { + log.Err(err).Array("users", exzerolog.ArrayOfStrs(fetchKeysForUsers)).Msg("Failed to fetch missing keys") + return fmt.Errorf("failed to fetch missing keys: %w", err) + } + for userID, devices := range keys { + log.Debug(). + Int("device_count", len(devices)). + Str("target_user_id", userID.String()). + Msg("Got device keys for user") + missingSessions[userID] = devices + } + } + + if len(missingSessions) > 0 { + log.Debug().Msg("Creating missing olm sessions") + err = mach.createOutboundSessions(ctx, missingSessions) + if err != nil { + log.Err(err).Msg("Failed to create missing olm sessions") + return fmt.Errorf("failed to create missing olm sessions: %w", err) + } + } + + for userID, devices := range missingSessions { + if len(devices) == 0 { + // No missing sessions + continue + } + output, ok := olmSessions[userID] + if !ok { + output = make(map[id.DeviceID]deviceSessionWrapper) + olmSessions[userID] = output + } + withheld, ok := toDeviceWithheld.Messages[userID] + if !ok { + withheld = make(map[id.DeviceID]*event.Content) + toDeviceWithheld.Messages[userID] = withheld + } + + log := log.With().Stringer("target_user_id", userID).Logger() + log.Trace().Msg("Trying to find olm session to encrypt megolm session for user (post-fetch retry)") + mach.findOlmSessionsForUser(ctx, session, userID, devices, output, withheld, nil) + log.Debug(). + Int("olm_session_count", len(output)). + Int("withheld_count", len(withheld)). + Msg("Completed post-fetch retry of finding olm sessions") + withheldCount += len(toDeviceWithheld.Messages[userID]) + if len(toDeviceWithheld.Messages[userID]) == 0 { + delete(toDeviceWithheld.Messages, userID) + } + } + + err = mach.encryptAndSendGroupSession(ctx, session, olmSessions) + if err != nil { + return fmt.Errorf("failed to share group session: %w", err) + } + + if len(toDeviceWithheld.Messages) > 0 { + log.Debug(). + Int("device_count", withheldCount). + Int("user_count", len(toDeviceWithheld.Messages)). + Msg("Sending to-device messages to report withheld key") + // TODO remove the next 4 lines once clients support m.room_key.withheld + _, err = mach.Client.SendToDevice(ctx, event.ToDeviceOrgMatrixRoomKeyWithheld, toDeviceWithheld) + if err != nil { + log.Warn().Err(err).Msg("Failed to report withheld keys (legacy event type)") + } + _, err = mach.Client.SendToDevice(ctx, event.ToDeviceRoomKeyWithheld, toDeviceWithheld) + if err != nil { + log.Warn().Err(err).Msg("Failed to report withheld keys") + } + } + + log.Debug().Msg("Group session successfully shared") + session.Shared = true + return mach.CryptoStore.AddOutboundGroupSession(ctx, session) +} + +func (mach *OlmMachine) encryptAndSendGroupSession(ctx context.Context, session *OutboundGroupSession, olmSessions map[id.UserID]map[id.DeviceID]deviceSessionWrapper) error { + mach.olmLock.Lock() + defer mach.olmLock.Unlock() + log := zerolog.Ctx(ctx) + log.Trace().Msg("Encrypting group session for all found devices") + deviceCount := 0 + toDevice := &mautrix.ReqSendToDevice{Messages: make(map[id.UserID]map[id.DeviceID]*event.Content)} + logUsers := zerolog.Dict() + for userID, sessions := range olmSessions { + if len(sessions) == 0 { + continue + } + logDevices := zerolog.Dict() + output := make(map[id.DeviceID]*event.Content) + toDevice.Messages[userID] = output + for deviceID, device := range sessions { + content := mach.encryptOlmEvent(ctx, device.session, device.identity, event.ToDeviceRoomKey, session.ShareContent()) + output[deviceID] = &event.Content{Parsed: content} + logDevices.Str(string(deviceID), string(device.identity.IdentityKey)) + deviceCount++ + if !mach.DisableSharedGroupSessionTracking { + err := mach.CryptoStore.MarkOutboundGroupSessionShared(ctx, userID, device.identity.IdentityKey, session.id) + if err != nil { + log.Warn(). + Err(err). + Stringer("target_user_id", userID). + Stringer("target_device_id", deviceID). + Stringer("target_identity_key", device.identity.IdentityKey). + Stringer("target_session_id", session.id). + Msg("Failed to mark outbound group session shared") + } + } + } + logUsers.Dict(string(userID), logDevices) + } + + log.Debug(). + Int("device_count", deviceCount). + Int("user_count", len(toDevice.Messages)). + Dict("destination_map", logUsers). + Msg("Sending to-device messages to share group session") + _, err := mach.Client.SendToDevice(ctx, event.ToDeviceEncrypted, toDevice) + return err +} + +func (mach *OlmMachine) findOlmSessionsForUser(ctx context.Context, session *OutboundGroupSession, userID id.UserID, devices map[id.DeviceID]*id.Device, output map[id.DeviceID]deviceSessionWrapper, withheld map[id.DeviceID]*event.Content, missingOutput map[id.DeviceID]*id.Device) { + for deviceID, device := range devices { + log := zerolog.Ctx(ctx).With(). + Stringer("target_user_id", userID). + Stringer("target_device_id", deviceID). + Stringer("target_identity_key", device.IdentityKey). + Logger() + userKey := UserDevice{UserID: userID, DeviceID: deviceID} + if state := session.Users[userKey]; state != OGSNotShared { + continue + } else if userID == mach.Client.UserID && deviceID == mach.Client.DeviceID { + session.Users[userKey] = OGSIgnored + } else if device.Trust == id.TrustStateBlacklisted { + log.Debug().Msg("Not encrypting group session for device: device is blacklisted") + withheld[deviceID] = &event.Content{Parsed: &event.RoomKeyWithheldEventContent{ + RoomID: session.RoomID, + Algorithm: id.AlgorithmMegolmV1, + SessionID: session.ID(), + SenderKey: mach.account.IdentityKey(), + Code: event.RoomKeyWithheldBlacklisted, + Reason: "Device is blacklisted", + }} + session.Users[userKey] = OGSIgnored + } else if trustState, _ := mach.ResolveTrustContext(ctx, device); trustState < mach.SendKeysMinTrust { + log.Debug(). + Str("min_trust", mach.SendKeysMinTrust.String()). + Str("device_trust", trustState.String()). + Msg("Not encrypting group session for device: device is not trusted") + withheld[deviceID] = &event.Content{Parsed: &event.RoomKeyWithheldEventContent{ + RoomID: session.RoomID, + Algorithm: id.AlgorithmMegolmV1, + SessionID: session.ID(), + SenderKey: mach.account.IdentityKey(), + Code: event.RoomKeyWithheldUnverified, + Reason: "This device does not encrypt messages for unverified devices", + }} + session.Users[userKey] = OGSIgnored + } else if deviceSession, err := mach.CryptoStore.GetLatestSession(ctx, device.IdentityKey); err != nil { + log.Error().Err(err).Msg("Failed to get olm session to encrypt group session") + } else if deviceSession == nil { + log.Warn().Err(err).Msg("Didn't find olm session to encrypt group session") + if missingOutput != nil { + missingOutput[deviceID] = device + } + } else { + output[deviceID] = deviceSessionWrapper{ + session: deviceSession, + identity: device, + } + session.Users[userKey] = OGSAlreadyShared + } + } +} diff --git a/mautrix-patched/crypto/encryptolm.go b/mautrix-patched/crypto/encryptolm.go new file mode 100644 index 00000000..c752460d --- /dev/null +++ b/mautrix-patched/crypto/encryptolm.go @@ -0,0 +1,202 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "encoding/json" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (mach *OlmMachine) EncryptToDevices(ctx context.Context, eventType event.Type, req *mautrix.ReqSendToDevice) (*mautrix.ReqSendToDevice, error) { + devicesToCreateSessions := make(map[id.UserID]map[id.DeviceID]*id.Device) + for userID, devices := range req.Messages { + for deviceID := range devices { + device, err := mach.GetOrFetchDevice(ctx, userID, deviceID) + if err != nil { + return nil, fmt.Errorf("failed to get device %s of user %s: %w", deviceID, userID, err) + } + + if _, ok := devicesToCreateSessions[userID]; !ok { + devicesToCreateSessions[userID] = make(map[id.DeviceID]*id.Device) + } + devicesToCreateSessions[userID][deviceID] = device + } + } + if err := mach.createOutboundSessions(ctx, devicesToCreateSessions); err != nil { + return nil, fmt.Errorf("failed to create outbound sessions: %w", err) + } + + mach.olmLock.Lock() + defer mach.olmLock.Unlock() + + encryptedReq := &mautrix.ReqSendToDevice{ + Messages: make(map[id.UserID]map[id.DeviceID]*event.Content), + } + + log := mach.machOrContextLog(ctx) + + for userID, devices := range req.Messages { + encryptedReq.Messages[userID] = make(map[id.DeviceID]*event.Content) + + for deviceID, content := range devices { + device := devicesToCreateSessions[userID][deviceID] + + olmSess, err := mach.CryptoStore.GetLatestSession(ctx, device.IdentityKey) + if err != nil { + return nil, fmt.Errorf("failed to get latest session for device %s of %s: %w", deviceID, userID, err) + } else if olmSess == nil { + log.Warn(). + Str("target_user_id", userID.String()). + Str("target_device_id", deviceID.String()). + Str("identity_key", device.IdentityKey.String()). + Msg("No outbound session found for device") + continue + } + + encrypted := mach.encryptOlmEvent(ctx, olmSess, device, eventType, *content) + encryptedContent := &event.Content{Parsed: &encrypted} + + log.Debug(). + Str("decrypted_type", eventType.Type). + Str("target_user_id", userID.String()). + Str("target_device_id", deviceID.String()). + Str("target_identity_key", device.IdentityKey.String()). + Str("olm_session_id", olmSess.ID().String()). + Msg("Encrypted to-device event") + + encryptedReq.Messages[userID][deviceID] = encryptedContent + } + } + + return encryptedReq, nil +} + +func (mach *OlmMachine) encryptOlmEvent(ctx context.Context, session *OlmSession, recipient *id.Device, evtType event.Type, content event.Content) *event.EncryptedEventContent { + evt := &DecryptedOlmEvent{ + Sender: mach.Client.UserID, + SenderDeviceID: mach.Client.DeviceID, + SenderDeviceKeys: mach.getKeysForOlmMessage(ctx), + Keys: OlmEventKeys{Ed25519: mach.account.SigningKey()}, + Recipient: recipient.UserID, + RecipientKeys: OlmEventKeys{Ed25519: recipient.SigningKey}, + Type: evtType, + Content: content, + } + plaintext, err := json.Marshal(evt) + if err != nil { + panic(err) + } + log := mach.machOrContextLog(ctx) + msgType, ciphertext, err := session.Encrypt(plaintext) + if err != nil { + panic(err) + } + ciphertextStr := string(ciphertext) + ciphertextHash, _ := olmMessageHash(ciphertextStr) + log.Debug(). + Stringer("event_type", evtType). + Str("recipient_identity_key", recipient.IdentityKey.String()). + Str("olm_session_id", session.ID().String()). + Str("olm_session_description", session.Describe()). + Hex("ciphertext_hash", ciphertextHash[:]). + Msg("Encrypted olm message") + err = mach.CryptoStore.UpdateSession(ctx, recipient.IdentityKey, session) + if err != nil { + log.Error().Err(err).Msg("Failed to update olm session in crypto store after encrypting") + } + return &event.EncryptedEventContent{ + Algorithm: id.AlgorithmOlmV1, + SenderKey: mach.account.IdentityKey(), + OlmCiphertext: event.OlmCiphertexts{ + recipient.IdentityKey: { + Type: msgType, + Body: ciphertextStr, + }, + }, + } +} + +func (mach *OlmMachine) shouldCreateNewSession(ctx context.Context, identityKey id.IdentityKey) bool { + if !mach.CryptoStore.HasSession(ctx, identityKey) { + return true + } + mach.devicesToUnwedgeLock.Lock() + _, shouldUnwedge := mach.devicesToUnwedge[identityKey] + if shouldUnwedge { + delete(mach.devicesToUnwedge, identityKey) + } + mach.devicesToUnwedgeLock.Unlock() + return shouldUnwedge +} + +func (mach *OlmMachine) createOutboundSessions(ctx context.Context, input map[id.UserID]map[id.DeviceID]*id.Device) error { + request := make(mautrix.OneTimeKeysRequest) + for userID, devices := range input { + request[userID] = make(map[id.DeviceID]id.KeyAlgorithm) + for deviceID, identity := range devices { + if mach.shouldCreateNewSession(ctx, identity.IdentityKey) { + request[userID][deviceID] = id.KeyAlgorithmSignedCurve25519 + } + } + if len(request[userID]) == 0 { + delete(request, userID) + } + } + if len(request) == 0 { + return nil + } + resp, err := mach.Client.ClaimKeys(ctx, &mautrix.ReqClaimKeys{ + OneTimeKeys: request, + Timeout: 10 * 1000, + }) + if err != nil { + return fmt.Errorf("failed to claim keys: %w", err) + } + log := mach.machOrContextLog(ctx) + for userID, user := range resp.OneTimeKeys { + for deviceID, oneTimeKeys := range user { + var oneTimeKey mautrix.OneTimeKey + var keyID id.KeyID + for keyID, oneTimeKey = range oneTimeKeys { + break + } + log := log.With(). + Str("peer_user_id", userID.String()). + Str("peer_device_id", deviceID.String()). + Str("peer_otk_id", keyID.String()). + Logger() + keyAlg, _ := keyID.Parse() + if keyAlg != id.KeyAlgorithmSignedCurve25519 { + log.Warn().Msg("Unexpected key ID algorithm in one-time key response") + continue + } + identity := input[userID][deviceID] + if ok, err := signatures.VerifySignatureJSON(oneTimeKey.RawData, userID, deviceID.String(), identity.SigningKey); err != nil { + log.Error().Err(err).Msg("Failed to verify signature of one-time key") + } else if !ok { + log.Warn().Msg("One-time key has invalid signature from device") + } else if sess, err := mach.account.Internal.NewOutboundSession(identity.IdentityKey, oneTimeKey.Key); err != nil { + log.Error().Err(err).Msg("Failed to create outbound session with claimed one-time key") + } else { + wrapped := wrapSession(sess) + err = mach.CryptoStore.AddSession(ctx, identity.IdentityKey, wrapped) + if err != nil { + log.Error().Err(err).Msg("Failed to store created outbound session") + } else { + log.Debug().Msg("Created new Olm session") + } + } + } + } + return nil +} diff --git a/mautrix-patched/crypto/goolm/README.md b/mautrix-patched/crypto/goolm/README.md new file mode 100644 index 00000000..c5eaa0af --- /dev/null +++ b/mautrix-patched/crypto/goolm/README.md @@ -0,0 +1,5 @@ +# go-olm +This is a fork of [DerLukas's goolm](https://codeberg.org/DerLukas/goolm), +a pure Go implementation of libolm. + +The original project is licensed under the MIT license. diff --git a/mautrix-patched/crypto/goolm/account/account.go b/mautrix-patched/crypto/goolm/account/account.go new file mode 100644 index 00000000..754015d8 --- /dev/null +++ b/mautrix-patched/crypto/goolm/account/account.go @@ -0,0 +1,423 @@ +// account packages an account which stores the identity, one time keys and fallback keys. +package account + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "maunium.net/go/mautrix/id" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + accountPickleVersionLibOLM uint32 = 4 + MaxOneTimeKeys int = 100 //maximum number of stored one time keys per Account +) + +// Account stores an account for end to end encrypted messaging via the olm protocol. +// An Account can not be used to en/decrypt messages. However it can be used to contruct new olm sessions, which in turn do the en/decryption. +// There is no tracking of sessions in an account. +type Account struct { + IdKeys struct { + Ed25519 crypto.Ed25519KeyPair `json:"ed25519,omitempty"` + Curve25519 crypto.Curve25519KeyPair `json:"curve25519,omitempty"` + } `json:"identity_keys"` + OTKeys []crypto.OneTimeKey `json:"one_time_keys"` + CurrentFallbackKey crypto.OneTimeKey `json:"current_fallback_key,omitempty"` + PrevFallbackKey crypto.OneTimeKey `json:"prev_fallback_key,omitempty"` + NextOneTimeKeyID uint32 `json:"next_one_time_key_id,omitempty"` + NumFallbackKeys uint8 `json:"number_fallback_keys"` +} + +// Ensure that Account adheres to the olm.Account interface. +var _ olm.Account = (*Account)(nil) + +// AccountFromPickled loads the Account details from a pickled base64 string. The input is decrypted with the supplied key. +func AccountFromPickled(pickled, key []byte) (*Account, error) { + if len(pickled) == 0 { + return nil, fmt.Errorf("accountFromPickled: %w", olm.ErrEmptyInput) + } + a := &Account{} + return a, a.Unpickle(pickled, key) +} + +// NewAccount creates a new Account. +func NewAccount() (*Account, error) { + a := &Account{} + kPEd25519, err := crypto.Ed25519GenerateKey() + if err != nil { + return nil, err + } + a.IdKeys.Ed25519 = kPEd25519 + kPCurve25519, err := crypto.Curve25519GenerateKey() + if err != nil { + return nil, err + } + a.IdKeys.Curve25519 = kPCurve25519 + return a, nil +} + +// IdentityKeysJSON returns the public parts of the identity keys for the Account in a JSON string. +func (a *Account) IdentityKeysJSON() ([]byte, error) { + res := struct { + Ed25519 string `json:"ed25519"` + Curve25519 string `json:"curve25519"` + }{} + ed25519, curve25519, err := a.IdentityKeys() + if err != nil { + return nil, err + } + res.Ed25519 = string(ed25519) + res.Curve25519 = string(curve25519) + return json.Marshal(res) +} + +// IdentityKeys returns the public parts of the Ed25519 and Curve25519 identity keys for the Account. +func (a *Account) IdentityKeys() (id.Ed25519, id.Curve25519, error) { + ed25519 := id.Ed25519(base64.RawStdEncoding.EncodeToString(a.IdKeys.Ed25519.PublicKey)) + curve25519 := id.Curve25519(base64.RawStdEncoding.EncodeToString(a.IdKeys.Curve25519.PublicKey)) + return ed25519, curve25519, nil +} + +// Sign returns the base64-encoded signature of a message using the Ed25519 key +// for this Account. +func (a *Account) Sign(message []byte) ([]byte, error) { + if len(message) == 0 { + return nil, fmt.Errorf("sign: %w", olm.ErrEmptyInput) + } else if signature, err := a.IdKeys.Ed25519.Sign(message); err != nil { + return nil, err + } else { + return []byte(base64.RawStdEncoding.EncodeToString(signature)), nil + } +} + +// OneTimeKeys returns the public parts of the unpublished one time keys of the Account. +// +// The returned data is a map with the mapping of key id to base64-encoded Curve25519 key. +func (a *Account) OneTimeKeys() (map[string]id.Curve25519, error) { + oneTimeKeys := make(map[string]id.Curve25519) + for _, curKey := range a.OTKeys { + if !curKey.Published { + oneTimeKeys[curKey.KeyIDEncoded()] = curKey.Key.PublicKey.B64Encoded() + } + } + return oneTimeKeys, nil +} + +// MarkKeysAsPublished marks the current set of one time keys and the fallback key as being +// published. +func (a *Account) MarkKeysAsPublished() { + for keyIndex := range a.OTKeys { + if !a.OTKeys[keyIndex].Published { + a.OTKeys[keyIndex].Published = true + } + } + a.CurrentFallbackKey.Published = true +} + +// GenOneTimeKeys generates a number of new one time keys. If the total number +// of keys stored by this Account exceeds MaxOneTimeKeys then the older +// keys are discarded. +func (a *Account) GenOneTimeKeys(num uint) error { + for i := uint(0); i < num; i++ { + key := crypto.OneTimeKey{ + Published: false, + ID: a.NextOneTimeKeyID, + } + newKP, err := crypto.Curve25519GenerateKey() + if err != nil { + return err + } + key.Key = newKP + a.NextOneTimeKeyID++ + a.OTKeys = append([]crypto.OneTimeKey{key}, a.OTKeys...) + } + if len(a.OTKeys) > MaxOneTimeKeys { + a.OTKeys = a.OTKeys[:MaxOneTimeKeys] + } + return nil +} + +// NewOutboundSession creates a new outbound session to a +// given curve25519 identity Key and one time key. +func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (olm.Session, error) { + if len(theirIdentityKey) == 0 || len(theirOneTimeKey) == 0 { + return nil, fmt.Errorf("outbound session: %w", olm.ErrEmptyInput) + } + theirIdentityKeyDecoded, err := base64.RawStdEncoding.DecodeString(string(theirIdentityKey)) + if err != nil { + return nil, err + } + theirOneTimeKeyDecoded, err := base64.RawStdEncoding.DecodeString(string(theirOneTimeKey)) + if err != nil { + return nil, err + } + return session.NewOutboundOlmSession(a.IdKeys.Curve25519, theirIdentityKeyDecoded, theirOneTimeKeyDecoded) +} + +// NewInboundSession creates a new in-bound session for sending/receiving +// messages from an incoming PRE_KEY message. Returns error on failure. +func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { + return a.NewInboundSessionFrom(nil, oneTimeKeyMsg) +} + +// NewInboundSessionFrom creates a new inbound session from an incoming PRE_KEY message. +func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (olm.Session, error) { + if len(oneTimeKeyMsg) == 0 { + return nil, fmt.Errorf("inbound session: %w", olm.ErrEmptyInput) + } + var theirIdentityKeyDecoded *crypto.Curve25519PublicKey + if theirIdentityKey != nil { + theirIdentityKeyDecodedByte, err := base64.RawStdEncoding.DecodeString(string(*theirIdentityKey)) + if err != nil { + return nil, err + } + theirIdentityKeyCurve := crypto.Curve25519PublicKey(theirIdentityKeyDecodedByte) + theirIdentityKeyDecoded = &theirIdentityKeyCurve + } + + return session.NewInboundOlmSession(theirIdentityKeyDecoded, []byte(oneTimeKeyMsg), a.searchOTKForOur, a.IdKeys.Curve25519) +} + +func (a *Account) searchOTKForOur(toFind crypto.Curve25519PublicKey) *crypto.OneTimeKey { + for curIndex := range a.OTKeys { + if a.OTKeys[curIndex].Key.PublicKey.Equal(toFind) { + return &a.OTKeys[curIndex] + } + } + if a.NumFallbackKeys >= 1 && a.CurrentFallbackKey.Key.PublicKey.Equal(toFind) { + return &a.CurrentFallbackKey + } + if a.NumFallbackKeys >= 2 && a.PrevFallbackKey.Key.PublicKey.Equal(toFind) { + return &a.PrevFallbackKey + } + return nil +} + +// RemoveOneTimeKeys removes the one time key in this Account which matches the one time key in the session s. +func (a *Account) RemoveOneTimeKeys(s olm.Session) error { + toFind := s.(*session.OlmSession).BobOneTimeKey + for curIndex := range a.OTKeys { + if a.OTKeys[curIndex].Key.PublicKey.Equal(toFind) { + //Remove and return + a.OTKeys[curIndex] = a.OTKeys[len(a.OTKeys)-1] + a.OTKeys = a.OTKeys[:len(a.OTKeys)-1] + return nil + } + } + return nil + //if the key is a fallback or prevFallback, don't remove it +} + +// GenFallbackKey generates a new fallback key. The old fallback key is stored +// in a.PrevFallbackKey overwriting any previous PrevFallbackKey. +func (a *Account) GenFallbackKey() error { + a.PrevFallbackKey = a.CurrentFallbackKey + key := crypto.OneTimeKey{ + Published: false, + ID: a.NextOneTimeKeyID, + } + newKP, err := crypto.Curve25519GenerateKey() + if err != nil { + return err + } + key.Key = newKP + a.NextOneTimeKeyID++ + if a.NumFallbackKeys < 2 { + a.NumFallbackKeys++ + } + a.CurrentFallbackKey = key + return nil +} + +// FallbackKey returns the public part of the current fallback key of the Account. +// The returned data is a map with the mapping of key id to base64-encoded Curve25519 key. +func (a *Account) FallbackKey() map[string]id.Curve25519 { + keys := make(map[string]id.Curve25519) + if a.NumFallbackKeys >= 1 { + keys[a.CurrentFallbackKey.KeyIDEncoded()] = a.CurrentFallbackKey.Key.PublicKey.B64Encoded() + } + return keys +} + +//FallbackKeyJSON returns the public part of the current fallback key of the Account as a JSON string. +// +//The returned JSON is of format: +/* + { + curve25519: { + "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo" + } + } +*/ +func (a *Account) FallbackKeyJSON() ([]byte, error) { + res := make(map[string]map[string]id.Curve25519) + fbk := a.FallbackKey() + res["curve25519"] = fbk + return json.Marshal(res) +} + +// FallbackKeyUnpublished returns the public part of the current fallback key of the Account only if it is unpublished. +// The returned data is a map with the mapping of key id to base64-encoded Curve25519 key. +func (a *Account) FallbackKeyUnpublished() map[string]id.Curve25519 { + keys := make(map[string]id.Curve25519) + if a.NumFallbackKeys >= 1 && !a.CurrentFallbackKey.Published { + keys[a.CurrentFallbackKey.KeyIDEncoded()] = a.CurrentFallbackKey.Key.PublicKey.B64Encoded() + } + return keys +} + +//FallbackKeyUnpublishedJSON returns the public part of the current fallback key, only if it is unpublished, of the Account as a JSON string. +// +//The returned JSON is of format: +/* + { + curve25519: { + "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo" + } + } +*/ +func (a *Account) FallbackKeyUnpublishedJSON() ([]byte, error) { + res := make(map[string]map[string]id.Curve25519) + fbk := a.FallbackKeyUnpublished() + res["curve25519"] = fbk + return json.Marshal(res) +} + +// ForgetOldFallbackKey resets the previous fallback key in the account. +func (a *Account) ForgetOldFallbackKey() { + if a.NumFallbackKeys >= 2 { + a.NumFallbackKeys = 1 + a.PrevFallbackKey = crypto.OneTimeKey{} + } +} + +// Unpickle decodes the base64 encoded string and decrypts the result with the key. +// The decrypted value is then passed to UnpickleLibOlm. +func (a *Account) Unpickle(pickled, key []byte) error { + decrypted, err := libolmpickle.Unpickle(key, pickled) + if err != nil { + return err + } + return a.UnpickleLibOlm(decrypted) +} + +// UnpickleLibOlm unpickles the unencryted value and populates the [Account] accordingly. +func (a *Account) UnpickleLibOlm(buf []byte) error { + decoder := libolmpickle.NewDecoder(buf) + pickledVersion, err := decoder.ReadUInt32() + if err != nil { + return err + } else if pickledVersion != accountPickleVersionLibOLM && pickledVersion != 3 && pickledVersion != 2 { + return fmt.Errorf("unpickle account: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) + } else if err = a.IdKeys.Ed25519.UnpickleLibOlm(decoder); err != nil { // read the ed25519 key pair + return err + } else if err = a.IdKeys.Curve25519.UnpickleLibOlm(decoder); err != nil { // read curve25519 key pair + return err + } + + otkCount, err := decoder.ReadUInt32() + if err != nil { + return err + } + + a.OTKeys = make([]crypto.OneTimeKey, otkCount) + for i := uint32(0); i < otkCount; i++ { + if err := a.OTKeys[i].UnpickleLibOlm(decoder); err != nil { + return err + } + } + + if pickledVersion <= 2 { + // version 2 did not have fallback keys + a.NumFallbackKeys = 0 + } else if pickledVersion == 3 { + // version 3 used the published flag to indicate how many fallback keys + // were present (we'll have to assume that the keys were published) + if err = a.CurrentFallbackKey.UnpickleLibOlm(decoder); err != nil { + return err + } else if err = a.PrevFallbackKey.UnpickleLibOlm(decoder); err != nil { + return err + } + if a.CurrentFallbackKey.Published { + if a.PrevFallbackKey.Published { + a.NumFallbackKeys = 2 + } else { + a.NumFallbackKeys = 1 + } + } else { + a.NumFallbackKeys = 0 + } + } else { + // Read number of fallback keys + a.NumFallbackKeys, err = decoder.ReadUInt8() + if err != nil { + return err + } + for i := 0; i < int(a.NumFallbackKeys); i++ { + switch i { + case 0: + if err = a.CurrentFallbackKey.UnpickleLibOlm(decoder); err != nil { + return err + } + case 1: + if err = a.PrevFallbackKey.UnpickleLibOlm(decoder); err != nil { + return err + } + default: + // Just drain any remaining fallback keys + if err = (&crypto.OneTimeKey{}).UnpickleLibOlm(decoder); err != nil { + return err + } + } + } + } + + //Read next onetime key ID + a.NextOneTimeKeyID, err = decoder.ReadUInt32() + return err +} + +// Pickle returns a base64 encoded and with key encrypted pickled account using PickleLibOlm(). +func (a *Account) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + return libolmpickle.Pickle(key, a.PickleLibOlm()) +} + +// PickleLibOlm pickles the [Account] and returns the raw bytes. +func (a *Account) PickleLibOlm() []byte { + encoder := libolmpickle.NewEncoder() + encoder.WriteUInt32(accountPickleVersionLibOLM) + a.IdKeys.Ed25519.PickleLibOlm(encoder) + a.IdKeys.Curve25519.PickleLibOlm(encoder) + + // One-Time Keys + encoder.WriteUInt32(uint32(len(a.OTKeys))) + for _, curOTKey := range a.OTKeys { + curOTKey.PickleLibOlm(encoder) + } + + // Fallback Keys + encoder.WriteUInt8(a.NumFallbackKeys) + if a.NumFallbackKeys >= 1 { + a.CurrentFallbackKey.PickleLibOlm(encoder) + if a.NumFallbackKeys >= 2 { + a.PrevFallbackKey.PickleLibOlm(encoder) + } + } + encoder.WriteUInt32(a.NextOneTimeKeyID) + return encoder.Bytes() +} + +// MaxNumberOfOneTimeKeys returns the largest number of one time keys this +// Account can store. +func (a *Account) MaxNumberOfOneTimeKeys() uint { + return uint(MaxOneTimeKeys) +} diff --git a/mautrix-patched/crypto/goolm/account/account_data_test.go b/mautrix-patched/crypto/goolm/account/account_data_test.go new file mode 100644 index 00000000..739421ff --- /dev/null +++ b/mautrix-patched/crypto/goolm/account/account_data_test.go @@ -0,0 +1,267 @@ +package account_test + +import "maunium.net/go/mautrix/crypto/goolm/crypto" + +var pickledDataFromLibOlm = []byte("b3jGWBenkTv6DJt90OX+H1ecoXQwihBjhdJHkAft49wS7ubT3Z0ta46p9PCnfKs+fOHeKhJzgfFcD5yCoatcpRzMHRri6V1dG/wMIu8nYvPPMZ8Dy5YlMBRGz0cpnOAhVoUzo/HtvyN8kgoYnZLzorVYepIqQcsLZAiG6qlztXepEflwNG619Rrk/zWYae5RBtxz9Cl0KCTj8cjY5J/SEKU+SCnj4n16wa+RfYXuLK/kBlE30uSWqQBInlLLYiSqOGjr8M0x+3A0eG0gYA+Aohwl5MbjQnDniTbQeg1gh3VwWZ6kJCgRpLnT0j6oc6V4HjP0JjseHe0rBr6W9o88sl6wGmVEr2ZjlvcD6hoCK21A98UZF0GTwHrX0zV7OQtn5cmys3A1xdgcBAo/GXte1d2HzBXSmgrnXExK3Ij+BkZoQSuEFWSUCLjCUFQohK8TfraLZ5+9sOaV/5KaUxqdBTi6HUqoYymCHxzG7olo3hlh+GJ+iOy9tnofqDirISDIIL7KJ2zNJxYHWNZrAVNxHF3rPSrBw9Zl6M2Scm9PdDqnPgGZ+MSCrCnT6UrrfmWurPahnXwdvPED9rykLtcy3aKFsB+RIezeoNdq/4d8sYVwFWd0w9HTXEYG1YY/Km2LiK/exaC98agPN4GXakCUHVZSfz59IZ3bH8jQdtZw2BPkVfNCUoTuUFzIk0LS3AtudCTiUaFdul9/Phj7TlvyvIKH8GUFRiV46fxMHJ9U0HGg6VAKtDR5qkB/nB1X8SWTmmZblR+jGOQvE6VCXSEoCdSyjYK+xtwlZsMHoFoci+NN/uxnrfoMd0D+TpNOyNFwdjn9hoS8kmqvMEyhae0Q5N4O7YwHiH/jZ9ruFTCMK10TeyFN3yxKiRKhiJkgd9bGnmHz25dm9EDkR4i1pXUFuSZCO7WPUX4aLiNMwcltW01EiTdvE7e2jgaoRguXI8gimvOZ/d8kKZ8QIgKURZZHXmud93MOXL3sAy/aBU8dBMt+E5mVeoGM2fns8o5D9Yx3gZ6CgkzmzWinfj82qyc459OcyeyV/gugEt3FI28UBMRfghIV0juOGTAjkh6G3wIyZVk2G4rG0mYONrQQhmgKf06szNQXFBHQ2Pju4pY+QEAng3D2CfXFV6S2bUVeXN0fk46afsV84WPwYg77DWTuR81Ck2arbIcKGsSpMrETMY65rtEoMAcXLzmWgPsIXdo7k+aR4mWmcxjW5a10Wxc1knOi39x0M6gnYGbhmj6IxmalzVFOjG1ZtkFL5fs59nK42aP/JZ0SdtTJjJA0PkbEFL3YOmVtRUmizVtZk63JYuyCgw36XLscTb3VWVynLONYa1RPyRLaz8L5FkTVySCFb8gP9KtDipBpPdGIeD0MGRijAPLweB4iDkM6zv9Yu6dMijZgSR0g6LmjQZPcm1YfI9AK2ht86oKJfvpj+UdYkK+wKKNzJKjKN08+mIKYbsumpbgKqx13d8sawKC4EfGAJHXsadat77Kp/ECCvhh7/i6gqWBHD0+I2LGiuQTr/Vd6OxAGmtyFOzdSGsfWm0cq78Lc6og7HTg3n7TbnEfJMaQktAI7vQYqnsvZV/KnfZ1elfPubFaFiHmCJzfkuk4X4y6r5A6FxpuEltvrHRtecQ6FHLHsBSZrUg7Dei9urMonphUfEj4rsVOMlB3ZKiQ0unmWmacmFKkHm+WhpQtzLS57/iuGCdKiL8qWBiCz80bCQXQp1iwdScZ+pZQ5pwVABH0sr9YfQEz6+oMh3Kp5LiXJAy7kNEs20h4oMP1bc/gN+F5cRubHrz3sXHWpAXF/pNw862Lj7rL0PPOZdomgHSpmKybaQyJxemlxOP9eFw2r2aym/6jc4nQoR+1Mu1ijaroJ8MgZSwTKmru+xgJXnwLx8i76iRlze2F00iNOMg/pRtFQmWh/zLsKukFtIi2PgPKo8xNRQgYvB76x0jLaX3cllpu/pL4LIM2q0p+V9+FPBPCeinkDA7jQzy397vlOSbdECuPYaj2JmkH4zKbxdDlZffgfjWCLDFkqPc0Ixz8O1k24yfkqwG792anEValGM/Hnhdh3a24y4dTV28eo1SoJ6pD1yrjP2RNvgeqs2xEbKOxPywmmOjq0zc805cXBTjDOVyeSFiiY3yJ2GN33KXv67svXw/Ky0Nl9Epbk6xbSB5b76HPAjJ1gZXEUE2zeRTVVOAWzrCERUUjcccz1ozde//rOjzEt+3fxdrq/oglOMW5Ge7ddo/a5lDRs10G3KHreT1sZz9NmA0U7VOQDwwosmt1AYB1LPg3HM2mwi8ahbZf62K1o/W450RWuG072u1unrCYBNYYArN4lnE11L5LvxctFT8qwbBPD1rHAs+Pr4GKQkTWOmUM7wS59GGWE3UEDrntj1Our5NNto7bjK05h33GRF+Vge1+8EfZ+eL2aikjeq/5dU/cN2aw5v3FCkrXzXbIX4YQMw0nu+MbbqXzmPAa7ibS5z0puTYiVs/iMC0ElMSbp9l65iosfVE+kjlF5QDVJaZoW1rTJ8ACo3zOIH6tv515OPvcxDl08nqw/eH37g/bridClsFlJu8aS8Up6Pxzx7hIjNukoDG/wm9qwN/tGgOhZaotzSQEmJTHy7rWc/tamPEJvzZ0Ev99FAlu816Q/HSSk//y8ZriufU5kvYgz0jVeotTShi5LQK30EDTZtjSxE2eJjpRwAmyD6iQwQQiQkVj5inBB6FbF9u1iVhgVsoQ52YEN5id809NSFasmjJ+szJm0E0e6WMfU3tX1j4nPiIdqV7XF8E01pTIZRBmJVUIeiq7XBb5fARjFBveFwwC/Ck3XykYz7CVQJsGOfk6VqlOzzcKhDOivDsVUPbtWJZgP3qC3sJp0dZV1c1BcDHUVbi8HC81F4zobEQGUOyTFsfeiiLUOHvBsveLt87EYpYe2rwjdnEVN5IU3NG2spMc0C7DeEiovv6GCdWgpoHHwPknS42Yv1ltjoDSrlgVF6nXyFXfVWN9CkZoOsEvoUEXGrHnLMT2RPJIWwZFGWXnHs+WKkEfFJbXJvUfTMlPM+hPOIaurwkKmJqh3cCzIGezzWFfSqM7Lv9Fth9FR1QiUcyK2eg1yc7F3lpicxf86RdcbNZD8Wep1uLA0/5zQYmiHA2ZUBFgpN0KanmykSWMHirsBXF4ixwsduRvo4YqqgIMLDrQInJMt5xvHTwHhKMgSOdpReDwM8zYYBV7ukon3cUEo8pwKuXCLs++DNU40bIyP7bpB4rL2Bm0ojNxsTsQwHO/vqXIDPPsyUEtd6J/JuL364XSP9yE5lnk2g5j3rA7uaq/DpA7m1PO+dE0nZcduFH+5OMC/tMZK1PK0Uc3z7LwIaaOzLBraqm16PdJjLCo5YcQgL5GqXN20sznKrRApqq17gdNayaZoEqG1cO0HzmDcr39Ombh/KrbtYFPkBlNbs/9tjgSOgZ5/aWb/gA6lYzspsKdJN0JAD88uYYBY2+GqdJRB86OP+DRHVRrpHOuLkcWNKILmVN5WFljoQXBvtER2IML4GHZytus+E6o2HCq+5Sh5dJlCBKRHIbzs9XPlEWbcE6Z6inuR5zbVYb97VcOhaM83ZjZZGVfdyqX9QagQ7k7eP2Ifju7vZkhz+HHa1v94HeYRGEKZvPRv2nPuzD1bOauwmhu0WKzYXXoSL40XdvSMNhGlKI65uxh6O1DoIJ8fiB3cFZjK2Li1gYMBzw7Mt8R2sSks3iKuiePX0JRTl0cBoiX7RmZuOlJet1HnUhbliS9vncRGZ/aRAL6VpQ5066/FoOq9ahWK+aSmxidcuRHqyIHhqJtUJrP/44vwDWBdZljgu4ysQvv3Fd+reJ1T4BZlY2jWJK1YCtMQMR2TeEaD7c6a7FtoexX3wuWDEe7er55nE2dW8uRjrptSdpAHFXHnmHmjzhP5Mr+MMAbDcK+3YrnsKYnmSdQP1EYPQ8dyIuc07BpypOZLWFiZpRcLwx2mAp6wFULrPptCWfPCcOgzghBa9l8eVDm/zsfZcP1FP6/J8uSbAI5YUz61gcR8VG1DKCX2e5kIk2xoPB0DlRfltx8NlT2VjLz5xKyyyaq0GpE0EfUXu0q4s2Q9tpk") +var expectedEd25519KeyPairPickleLibOLM = crypto.Ed25519KeyPair{ + PublicKey: []byte{237, 217, 234, 95, 181, 217, 229, 96, 41, 51, 153, 83, 191, 158, 47, 242, 100, 163, 120, 171, 15, 117, 176, 58, 70, 181, 5, 53, 64, 26, 99, 55}, + PrivateKey: []byte{232, 245, 108, 122, 156, 40, 107, 206, 71, 27, 156, 60, 52, 126, 39, 215, 255, 217, 81, 248, 206, 228, 153, 244, 31, 114, 88, 127, 207, 250, 255, 122, 196, 207, 3, 96, 142, 227, 172, 88, 13, 230, 140, 125, 200, 220, 19, 127, 144, 79, 32, 249, 135, 238, 3, 205, 227, 73, 250, 219, 223, 248, 175, 20}, +} +var expectedCurve25519KeyPairPickleLibOLM = crypto.Curve25519KeyPair{ + PublicKey: []byte{56, 193, 217, 134, 124, 49, 9, 185, 241, 26, 246, 132, 245, 34, 222, 189, 199, 201, 136, 80, 185, 153, 132, 240, 194, 48, 30, 157, 74, 1, 243, 0}, + PrivateKey: []byte{80, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, +} +var expectedOTKeysPickleLibOLM = []crypto.OneTimeKey{ + {ID: 42, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{41, 72, 49, 87, 49, 27, 143, 250, 203, 35, 151, 49, 248, 200, 99, 225, 101, 68, 203, 251, 132, 115, 253, 59, 21, 61, 111, 58, 252, 200, 85, 61}, + PrivateKey: []byte{80, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43}, + }, + }, + {ID: 41, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{123, 42, 55, 123, 233, 87, 88, 76, 17, 249, 112, 97, 226, 213, 73, 239, 49, 217, 168, 220, 180, 182, 176, 231, 77, 138, 92, 58, 62, 185, 250, 12}, + PrivateKey: []byte{80, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42}, + }, + }, + {ID: 40, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{139, 80, 115, 105, 78, 90, 82, 35, 21, 248, 232, 10, 8, 237, 95, 201, 73, 219, 244, 105, 35, 184, 225, 56, 164, 142, 79, 59, 178, 51, 150, 69}, + PrivateKey: []byte{80, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41}, + }, + }, + {ID: 39, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{176, 111, 229, 19, 195, 233, 77, 12, 228, 241, 254, 193, 139, 127, 150, 20, 182, 36, 103, 30, 207, 5, 35, 93, 60, 81, 53, 133, 216, 4, 81, 94}, + PrivateKey: []byte{80, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40}, + }, + }, + {ID: 38, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{137, 106, 140, 51, 49, 76, 42, 164, 198, 184, 58, 9, 246, 119, 84, 88, 196, 199, 189, 145, 145, 141, 209, 29, 68, 64, 171, 23, 126, 11, 220, 122}, + PrivateKey: []byte{80, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39}, + }, + }, + {ID: 37, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{38, 99, 240, 40, 17, 97, 91, 79, 105, 102, 81, 153, 12, 175, 81, 4, 132, 171, 246, 96, 10, 162, 71, 175, 241, 23, 22, 129, 38, 15, 230, 67}, + PrivateKey: []byte{80, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38}, + }, + }, + {ID: 36, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{205, 28, 163, 27, 148, 116, 82, 169, 230, 7, 184, 192, 76, 177, 196, 129, 62, 32, 76, 145, 247, 56, 220, 180, 74, 193, 205, 178, 158, 209, 168, 123}, + PrivateKey: []byte{80, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37}, + }, + }, + {ID: 35, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{195, 125, 80, 132, 106, 120, 250, 0, 145, 191, 116, 179, 167, 91, 65, 10, 121, 19, 12, 51, 78, 229, 170, 110, 37, 37, 109, 65, 221, 126, 168, 5}, + PrivateKey: []byte{80, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36}, + }, + }, + {ID: 34, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{93, 0, 88, 212, 33, 219, 129, 18, 103, 142, 90, 217, 6, 84, 99, 224, 41, 78, 245, 65, 65, 70, 116, 194, 23, 28, 21, 40, 220, 202, 139, 8}, + PrivateKey: []byte{80, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35}, + }, + }, + {ID: 33, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{158, 245, 92, 234, 230, 162, 236, 226, 172, 246, 255, 113, 231, 162, 211, 19, 141, 244, 36, 127, 235, 47, 38, 209, 7, 107, 245, 147, 161, 89, 246, 53}, + PrivateKey: []byte{80, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34}, + }, + }, + {ID: 32, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{69, 20, 138, 120, 68, 160, 34, 99, 205, 177, 138, 147, 96, 118, 36, 239, 206, 11, 118, 75, 170, 216, 193, 108, 24, 65, 0, 131, 226, 73, 22, 18}, + PrivateKey: []byte{80, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33}, + }, + }, + {ID: 31, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{201, 153, 194, 8, 6, 146, 167, 134, 209, 163, 215, 61, 114, 191, 150, 68, 205, 106, 37, 144, 32, 216, 19, 210, 139, 169, 221, 28, 160, 193, 196, 71}, + PrivateKey: []byte{80, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}, + }, + }, + {ID: 30, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{211, 29, 161, 172, 192, 112, 209, 226, 113, 120, 177, 145, 108, 134, 92, 21, 31, 29, 162, 237, 77, 179, 96, 247, 123, 246, 47, 40, 238, 242, 206, 53}, + PrivateKey: []byte{80, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31}, + }, + }, + {ID: 29, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{197, 144, 16, 124, 25, 208, 46, 163, 33, 56, 116, 172, 53, 106, 42, 217, 240, 152, 165, 10, 82, 218, 96, 237, 211, 254, 229, 209, 5, 154, 52, 21}, + PrivateKey: []byte{80, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, + }, + }, + {ID: 28, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{42, 188, 228, 224, 227, 132, 230, 252, 175, 213, 113, 132, 226, 151, 138, 166, 213, 151, 235, 1, 4, 81, 45, 80, 27, 140, 195, 234, 136, 163, 245, 96}, + PrivateKey: []byte{80, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29}, + }, + }, + {ID: 27, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{153, 0, 67, 133, 177, 241, 105, 219, 32, 58, 135, 239, 145, 124, 122, 32, 137, 109, 40, 177, 54, 85, 46, 69, 231, 253, 146, 150, 228, 172, 9, 66}, + PrivateKey: []byte{80, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, + }, + }, + {ID: 26, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{3, 79, 232, 39, 90, 120, 71, 216, 193, 102, 132, 48, 91, 225, 8, 229, 99, 206, 128, 110, 9, 161, 75, 204, 86, 250, 54, 185, 152, 163, 144, 124}, + PrivateKey: []byte{80, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27}, + }, + }, + {ID: 25, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{96, 21, 62, 175, 244, 249, 33, 134, 162, 32, 142, 56, 215, 27, 12, 30, 229, 118, 63, 40, 45, 120, 204, 134, 111, 95, 21, 150, 112, 60, 187, 111}, + PrivateKey: []byte{80, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26}, + }, + }, + {ID: 24, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{103, 239, 218, 49, 88, 55, 161, 63, 238, 39, 114, 106, 175, 158, 59, 43, 39, 112, 239, 175, 29, 174, 75, 172, 9, 84, 230, 109, 214, 77, 170, 124}, + PrivateKey: []byte{80, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25}, + }, + }, + {ID: 23, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{35, 148, 228, 98, 0, 124, 196, 15, 5, 63, 73, 127, 52, 126, 165, 175, 186, 35, 196, 89, 94, 233, 56, 60, 103, 125, 67, 47, 29, 132, 206, 13}, + PrivateKey: []byte{80, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24}, + }, + }, + {ID: 22, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{94, 143, 132, 227, 112, 122, 177, 213, 30, 87, 21, 85, 0, 193, 221, 87, 111, 100, 99, 15, 50, 68, 92, 146, 222, 179, 182, 58, 136, 235, 74, 44}, + PrivateKey: []byte{80, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23}, + }, + }, + {ID: 21, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{232, 20, 27, 90, 55, 105, 146, 28, 107, 129, 73, 107, 1, 35, 70, 190, 227, 54, 169, 214, 160, 99, 150, 180, 37, 109, 115, 211, 84, 115, 91, 73}, + PrivateKey: []byte{80, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22}, + }, + }, + {ID: 20, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{245, 105, 178, 42, 165, 43, 232, 76, 48, 163, 5, 3, 42, 123, 59, 208, 74, 227, 36, 112, 77, 212, 203, 152, 81, 228, 226, 69, 45, 101, 182, 65}, + PrivateKey: []byte{80, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21}, + }, + }, + {ID: 19, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{16, 18, 85, 33, 104, 88, 95, 252, 135, 25, 55, 255, 240, 198, 30, 251, 163, 44, 150, 111, 155, 150, 143, 163, 242, 186, 142, 145, 59, 14, 161, 50}, + PrivateKey: []byte{80, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20}, + }, + }, + {ID: 18, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{32, 138, 232, 106, 32, 165, 39, 122, 146, 194, 126, 235, 84, 72, 127, 106, 83, 32, 219, 45, 201, 36, 226, 133, 201, 67, 168, 199, 112, 73, 166, 68}, + PrivateKey: []byte{80, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19}, + }, + }, + {ID: 17, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{10, 231, 214, 54, 36, 71, 42, 193, 204, 235, 148, 182, 60, 82, 228, 215, 61, 218, 146, 65, 227, 136, 233, 11, 223, 88, 95, 113, 47, 84, 169, 53}, + PrivateKey: []byte{80, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18}, + }, + }, + {ID: 16, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{226, 60, 255, 91, 122, 150, 74, 95, 227, 250, 237, 107, 205, 242, 56, 123, 52, 25, 65, 125, 69, 255, 101, 60, 201, 140, 196, 213, 196, 75, 109, 92}, + PrivateKey: []byte{80, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17}, + }, + }, + {ID: 15, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{171, 229, 71, 9, 133, 66, 150, 143, 73, 156, 11, 216, 148, 7, 153, 129, 237, 207, 228, 193, 55, 183, 156, 178, 132, 85, 154, 43, 19, 29, 170, 127}, + PrivateKey: []byte{80, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, + }, + }, + {ID: 14, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{53, 241, 2, 154, 223, 221, 222, 131, 114, 196, 111, 189, 26, 210, 20, 48, 39, 57, 199, 192, 2, 239, 213, 135, 232, 160, 92, 214, 18, 18, 205, 93}, + PrivateKey: []byte{80, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15}, + }, + }, + {ID: 13, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{92, 15, 157, 2, 49, 70, 253, 32, 39, 210, 54, 167, 55, 95, 255, 118, 76, 52, 184, 76, 185, 217, 31, 84, 7, 118, 1, 117, 53, 78, 216, 91}, + PrivateKey: []byte{80, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14}, + }, + }, + {ID: 12, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{67, 94, 86, 147, 61, 140, 71, 173, 0, 97, 202, 174, 242, 37, 198, 173, 214, 104, 89, 37, 204, 136, 32, 62, 166, 165, 56, 194, 242, 26, 79, 12}, + PrivateKey: []byte{80, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13}, + }, + }, + {ID: 11, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{147, 197, 91, 58, 183, 17, 72, 41, 244, 222, 191, 70, 195, 238, 110, 223, 135, 107, 108, 43, 154, 144, 50, 20, 222, 69, 42, 214, 69, 181, 0, 82}, + PrivateKey: []byte{80, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12}, + }, + }, + {ID: 10, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{116, 144, 19, 88, 33, 120, 92, 138, 174, 218, 192, 222, 96, 249, 46, 250, 4, 197, 250, 196, 243, 68, 183, 210, 218, 107, 206, 138, 121, 226, 189, 104}, + PrivateKey: []byte{80, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11}, + }, + }, + {ID: 9, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{140, 220, 222, 205, 238, 56, 126, 139, 40, 172, 222, 189, 235, 73, 50, 238, 125, 114, 73, 193, 80, 87, 86, 82, 205, 247, 206, 222, 164, 151, 1, 110}, + PrivateKey: []byte{80, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10}, + }, + }, + {ID: 8, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{129, 168, 225, 128, 194, 202, 63, 189, 162, 243, 79, 88, 251, 222, 173, 19, 132, 217, 193, 192, 171, 149, 159, 128, 244, 136, 216, 28, 2, 175, 141, 7}, + PrivateKey: []byte{80, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}, + }, + }, + {ID: 7, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{44, 161, 77, 24, 61, 118, 178, 112, 31, 10, 14, 217, 0, 66, 161, 88, 134, 88, 53, 74, 93, 62, 211, 217, 87, 203, 122, 143, 239, 1, 24, 121}, + PrivateKey: []byte{80, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8}, + }, + }, + {ID: 6, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{167, 86, 75, 53, 54, 151, 106, 235, 48, 47, 54, 144, 180, 160, 209, 24, 78, 99, 57, 76, 109, 162, 233, 213, 170, 121, 37, 203, 178, 212, 130, 0}, + PrivateKey: []byte{80, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7}, + }, + }, + {ID: 5, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{194, 48, 135, 21, 239, 220, 32, 235, 254, 154, 245, 120, 129, 44, 108, 62, 246, 57, 62, 197, 170, 228, 107, 136, 155, 186, 29, 25, 57, 65, 172, 88}, + PrivateKey: []byte{80, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6}, + }, + }, + {ID: 4, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{85, 201, 136, 56, 1, 248, 140, 74, 234, 124, 137, 178, 244, 178, 37, 163, 73, 220, 116, 243, 236, 92, 198, 246, 111, 99, 227, 90, 106, 115, 9, 70}, + PrivateKey: []byte{80, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}, + }, + }, + {ID: 3, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{38, 244, 146, 200, 126, 125, 48, 184, 222, 106, 254, 236, 231, 113, 26, 128, 84, 137, 162, 163, 97, 54, 213, 96, 254, 23, 55, 178, 114, 105, 93, 83}, + PrivateKey: []byte{80, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4}, + }, + }, + {ID: 2, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{149, 218, 194, 83, 219, 185, 224, 51, 177, 226, 224, 190, 219, 150, 131, 5, 183, 52, 226, 205, 114, 116, 219, 156, 227, 175, 66, 165, 132, 8, 24, 82}, + PrivateKey: []byte{80, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, + }, + }, + {ID: 1, + Key: crypto.Curve25519KeyPair{ + PublicKey: []byte{215, 133, 170, 227, 69, 234, 37, 45, 63, 251, 88, 239, 181, 64, 54, 203, 166, 87, 83, 33, 234, 207, 136, 145, 71, 153, 36, 239, 125, 151, 69, 106}, + PrivateKey: []byte{80, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, + }, + }, +} diff --git a/mautrix-patched/crypto/goolm/account/account_test.go b/mautrix-patched/crypto/goolm/account/account_test.go new file mode 100644 index 00000000..a593ffa7 --- /dev/null +++ b/mautrix-patched/crypto/goolm/account/account_test.go @@ -0,0 +1,366 @@ +package account_test + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/id" + + "maunium.net/go/mautrix/crypto/goolm/account" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/signatures" +) + +func TestAccount(t *testing.T) { + firstAccount, err := account.NewAccount() + assert.NoError(t, err) + err = firstAccount.GenFallbackKey() + assert.NoError(t, err) + err = firstAccount.GenOneTimeKeys(2) + assert.NoError(t, err) + encryptionKey := []byte("testkey") + + //now pickle account in JSON format + pickled, err := firstAccount.Pickle(encryptionKey) + assert.NoError(t, err) + + //now unpickle into new Account + unpickledAccount, err := account.AccountFromPickled(pickled, encryptionKey) + assert.NoError(t, err) + + //check if accounts are the same + assert.Equal(t, firstAccount.NextOneTimeKeyID, unpickledAccount.NextOneTimeKeyID) + assert.Equal(t, firstAccount.CurrentFallbackKey, unpickledAccount.CurrentFallbackKey) + assert.Equal(t, firstAccount.PrevFallbackKey, unpickledAccount.PrevFallbackKey) + assert.Equal(t, firstAccount.OTKeys, unpickledAccount.OTKeys) + assert.Equal(t, firstAccount.IdKeys, unpickledAccount.IdKeys) + + // Ensure that all of the keys are unpublished right now + otks, err := firstAccount.OneTimeKeys() + assert.NoError(t, err) + assert.Len(t, otks, 2) + assert.Len(t, firstAccount.FallbackKeyUnpublished(), 1) + + // Now, publish the key and make sure that they are published + firstAccount.MarkKeysAsPublished() + + assert.Len(t, firstAccount.FallbackKeyUnpublished(), 0) + assert.Len(t, firstAccount.FallbackKey(), 1) + otks, err = firstAccount.OneTimeKeys() + assert.NoError(t, err) + assert.Len(t, otks, 0) +} + +func TestAccountPickleJSON(t *testing.T) { + key := []byte("test key") + + // Generating new values when struct changed + /* + newAccount, _ := account.NewAccount() + pickled, _ := newAccount.Pickle(key) + fmt.Println(string(pickled)) + jsonDataNew, _ := newAccount.IdentityKeysJSON() + fmt.Println(string(jsonDataNew)) + */ + + pickledData := []byte("RLlGsxqzjwMQDVS/ZUXHUwXVSdPXB5SE82erFmwvUN0appArMAIqgsscMhE2yRULGe16vlGvP9xrSOI+fjWi69PzoQAv5VhoZj/3PRgPOTLRX1f+covGNZCMEzkcfjszi1zWvryk5UGQS2Nw3M7b8uYuTKJ+2wG4YO7EjpF3aPlS/RjHjnQT+jefjjM4GzcZddP6xgrKiCmJKuJcTkJKFK5VcyiT7Tt7EiUsViJnWgdwM3RWiDVk3w") + account, err := account.AccountFromPickled(pickledData, key) + assert.NoError(t, err) + expectedJSON := `{"ed25519":"QtURQr6XjRqmpwscm107OIcVvLrZY8CXUnt+O6DJR38","curve25519":"n2cLWJYguFz/xBaHTWcpY8WrEByVTcns/OoxaKOMgT0"}` + jsonData, err := account.IdentityKeysJSON() + assert.NoError(t, err) + assert.Equal(t, expectedJSON, string(jsonData)) +} + +func TestSessions(t *testing.T) { + aliceAccount, err := account.NewAccount() + assert.NoError(t, err) + err = aliceAccount.GenOneTimeKeys(5) + assert.NoError(t, err) + bobAccount, err := account.NewAccount() + assert.NoError(t, err) + err = bobAccount.GenOneTimeKeys(5) + assert.NoError(t, err) + aliceSession, err := aliceAccount.NewOutboundSession(bobAccount.IdKeys.Curve25519.B64Encoded(), bobAccount.OTKeys[2].Key.B64Encoded()) + assert.NoError(t, err) + plaintext := []byte("test message") + msgType, crypttext, err := aliceSession.Encrypt(plaintext) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + + bobSession, err := bobAccount.NewInboundSession(string(crypttext)) + assert.NoError(t, err) + decodedText, err := bobSession.Decrypt(string(crypttext), msgType) + assert.NoError(t, err) + assert.Equal(t, plaintext, decodedText) +} + +func TestAccountPickle(t *testing.T) { + pickleKey := []byte("secret_key") + account, err := account.AccountFromPickled(pickledDataFromLibOlm, pickleKey) + assert.NoError(t, err) + assert.Equal(t, expectedEd25519KeyPairPickleLibOLM, account.IdKeys.Ed25519) + assert.Equal(t, expectedCurve25519KeyPairPickleLibOLM, account.IdKeys.Curve25519) + assert.EqualValues(t, 42, account.NextOneTimeKeyID) + assert.Equal(t, account.OTKeys, expectedOTKeysPickleLibOLM) + assert.EqualValues(t, 0, account.NumFallbackKeys) + + targetPickled, err := account.Pickle(pickleKey) + assert.NoError(t, err) + assert.Equal(t, pickledDataFromLibOlm, targetPickled) +} + +func TestOldAccountPickle(t *testing.T) { + // this uses the old pickle format, which did not use enough space + // for the Ed25519 key. We should reject it. + pickled := []byte("x3h9er86ygvq56pM1yesdAxZou4ResPQC9Rszk/fhEL9JY/umtZ2N/foL/SUgVXS" + + "v0IxHHZTafYjDdzJU9xr8dQeBoOTGfV9E/lCqDGBnIlu7SZndqjEKXtzGyQr4sP4" + + "K/A/8TOu9iK2hDFszy6xETiousHnHgh2ZGbRUh4pQx+YMm8ZdNZeRnwFGLnrWyf9" + + "O5TmXua1FcU") + pickleKey := []byte("") + account, err := account.NewAccount() + assert.NoError(t, err) + err = account.Unpickle(pickled, pickleKey) + assert.ErrorIs(t, err, olm.ErrUnknownOlmPickleVersion) +} + +func TestLoopback(t *testing.T) { + accountA, err := account.NewAccount() + assert.NoError(t, err) + + accountB, err := account.NewAccount() + assert.NoError(t, err) + err = accountB.GenOneTimeKeys(42) + assert.NoError(t, err) + + aliceSession, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), accountB.OTKeys[0].Key.B64Encoded()) + assert.NoError(t, err) + + plainText := []byte("Hello, World") + msgType, message1, err := aliceSession.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + + bobSession, err := accountB.NewInboundSession(string(message1)) + assert.NoError(t, err) + // Check that the inbound session matches the message it was created from. + sessionIsOK, err := bobSession.MatchesInboundSessionFrom("", string(message1)) + assert.NoError(t, err) + assert.True(t, sessionIsOK, "session was not detected to be valid") + + // Check that the inbound session matches the key this message is supposed to be from. + aIDKey := accountA.IdKeys.Curve25519.PublicKey.B64Encoded() + sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(aIDKey), string(message1)) + assert.NoError(t, err) + assert.True(t, sessionIsOK, "session is sad to be not from a but it should") + + // Check that the inbound session isn't from a different user. + bIDKey := accountB.IdKeys.Curve25519.PublicKey.B64Encoded() + sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(bIDKey), string(message1)) + assert.NoError(t, err) + assert.False(t, sessionIsOK, "session is sad to be from b but is from a") + + // Check that we can decrypt the message. + decryptedMessage, err := bobSession.Decrypt(string(message1), msgType) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage) + + msgTyp2, message2, err := bobSession.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgTyp2) + + decryptedMessage2, err := aliceSession.Decrypt(string(message2), msgTyp2) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage2) + + //decrypting again should fail, as the chain moved on + _, err = aliceSession.Decrypt(string(message2), msgTyp2) + assert.Error(t, err) + assert.ErrorIs(t, err, olm.ErrMessageKeyNotFound) + + //compare sessionIDs + assert.Equal(t, aliceSession.ID(), bobSession.ID()) +} + +func TestMoreMessages(t *testing.T) { + accountA, err := account.NewAccount() + assert.NoError(t, err) + + accountB, err := account.NewAccount() + assert.NoError(t, err) + err = accountB.GenOneTimeKeys(42) + assert.NoError(t, err) + + aliceSession, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), accountB.OTKeys[0].Key.B64Encoded()) + assert.NoError(t, err) + + plainText := []byte("Hello, World") + msgType, message1, err := aliceSession.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + + bobSession, err := accountB.NewInboundSession(string(message1)) + assert.NoError(t, err) + decryptedMessage, err := bobSession.Decrypt(string(message1), msgType) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage) + + for i := 0; i < 8; i++ { + //alice sends, bob reveices + msgType, message, err := aliceSession.Encrypt(plainText) + assert.NoError(t, err) + if i == 0 { + //The first time should still be a preKeyMessage as bob has not yet send a message to alice + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + } else { + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + } + + decryptedMessage, err := bobSession.Decrypt(string(message), msgType) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage) + + //now bob sends, alice receives + msgType, message, err = bobSession.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + + decryptedMessage, err = aliceSession.Decrypt(string(message), msgType) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage) + } +} + +func TestFallbackKey(t *testing.T) { + accountA, err := account.NewAccount() + assert.NoError(t, err) + + accountB, err := account.NewAccount() + assert.NoError(t, err) + err = accountB.GenFallbackKey() + assert.NoError(t, err) + fallBackKeys := accountB.FallbackKeyUnpublished() + var fallbackKey id.Curve25519 + for _, fbKey := range fallBackKeys { + fallbackKey = fbKey + } + aliceSession, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), fallbackKey) + assert.NoError(t, err) + + plainText := []byte("Hello, World") + msgType, message1, err := aliceSession.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + + bobSession, err := accountB.NewInboundSession(string(message1)) + assert.NoError(t, err) + // Check that the inbound session matches the message it was created from. + sessionIsOK, err := bobSession.MatchesInboundSessionFrom("", string(message1)) + assert.NoError(t, err) + assert.True(t, sessionIsOK, "session was not detected to be valid") + + // Check that the inbound session matches the key this message is supposed to be from. + aIDKey := accountA.IdKeys.Curve25519.PublicKey.B64Encoded() + sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(aIDKey), string(message1)) + assert.NoError(t, err) + assert.True(t, sessionIsOK, "session is sad to be not from a but it should") + + // Check that the inbound session isn't from a different user. + bIDKey := accountB.IdKeys.Curve25519.PublicKey.B64Encoded() + sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(bIDKey), string(message1)) + assert.NoError(t, err) + assert.False(t, sessionIsOK, "session is sad to be from b but is from a") + + // Check that we can decrypt the message. + decryptedMessage, err := bobSession.Decrypt(string(message1), msgType) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage) + + // create a new fallback key for B (the old fallback should still be usable) + err = accountB.GenFallbackKey() + assert.NoError(t, err) + // start another session and encrypt a message + aliceSession2, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), fallbackKey) + assert.NoError(t, err) + + msgType2, message2, err := aliceSession2.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType2) + + // bobSession should not be valid for the message2 + // Check that the inbound session matches the message it was created from. + sessionIsOK, err = bobSession.MatchesInboundSessionFrom("", string(message2)) + assert.NoError(t, err) + assert.False(t, sessionIsOK, "session was detected to be valid but should not") + + bobSession2, err := accountB.NewInboundSession(string(message2)) + assert.NoError(t, err) + // Check that the inbound session matches the message it was created from. + sessionIsOK, err = bobSession2.MatchesInboundSessionFrom("", string(message2)) + assert.NoError(t, err) + assert.True(t, sessionIsOK, "session was not detected to be valid") + + // Check that the inbound session matches the key this message is supposed to be from. + sessionIsOK, err = bobSession2.MatchesInboundSessionFrom(string(aIDKey), string(message2)) + assert.NoError(t, err) + assert.True(t, sessionIsOK, "session is sad to be not from a but it should") + + // Check that the inbound session isn't from a different user. + sessionIsOK, err = bobSession2.MatchesInboundSessionFrom(string(bIDKey), string(message2)) + assert.NoError(t, err) + assert.False(t, sessionIsOK, "session is sad to be from b but is from a") + + // Check that we can decrypt the message. + decryptedMessage2, err := bobSession2.Decrypt(string(message2), msgType2) + assert.NoError(t, err) + assert.Equal(t, plainText, decryptedMessage2) + + //Forget the old fallback key -- creating a new session should fail now + accountB.ForgetOldFallbackKey() + // start another session and encrypt a message + aliceSession3, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), fallbackKey) + assert.NoError(t, err) + msgType3, message3, err := aliceSession3.Encrypt(plainText) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType3) + _, err = accountB.NewInboundSession(string(message3)) + assert.ErrorIs(t, err, olm.ErrBadMessageKeyID) +} + +func TestOldV3AccountPickle(t *testing.T) { + pickledData := []byte("0mSqVn3duHffbhaTbFgW+4JPlcRoqT7z0x4mQ72N+g+eSAk5sgcWSoDzKpMazgcB" + + "46ItEpChthVHTGRA6PD3dly0dUs4ji7VtWTa+1tUv1UbxP92uYf1Ae3fomX0yAoH" + + "OjSrz1+RmuXr+At8jsmsf260sKvhB6LnI3qYsrw6AAtpgk5d5xZd66sLxvvYUuai" + + "+SmmcmT0bHosLTuDiiB9amBvPKkUKtKZmaEAl5ULrgnJygp1/FnwzVfSrw6PBSX6" + + "ZaUEZHZGX1iI6/WjbHqlTQeOQjtaSsPaL5XXpteS9dFsuaANAj+8ks7Ut2Hwg/JP" + + "Ih/ERYBwiMh9Mt3zSAG0NkvgUkcdipKxoSNZ6t+TkqZrN6jG6VCbx+4YpJO24iJb" + + "ShZy8n79aePIgIsxX94ycsTq1ic38sCRSkWGVbCSRkPloHW7ZssLHA") + pickleKey := []byte("") + expectedFallbackJSON := []byte("{\"curve25519\":{\"AAAAAQ\":\"dr98y6VOWt6lJaQgFVZeWY2ky76mga9MEMbdItJTdng\"}}") + expectedUnpublishedFallbackJSON := []byte("{\"curve25519\":{}}") + + account, err := account.AccountFromPickled(pickledData, pickleKey) + assert.NoError(t, err) + fallbackJSON, err := account.FallbackKeyJSON() + assert.NoError(t, err) + assert.Equal(t, expectedFallbackJSON, fallbackJSON) + fallbackJSONUnpublished, err := account.FallbackKeyUnpublishedJSON() + assert.NoError(t, err) + assert.Equal(t, expectedUnpublishedFallbackJSON, fallbackJSONUnpublished) +} + +func TestAccountSign(t *testing.T) { + accountA, err := account.NewAccount() + assert.NoError(t, err) + plainText := []byte("Hello, World") + signatureB64, err := accountA.Sign(plainText) + assert.NoError(t, err) + signature, err := base64.RawStdEncoding.DecodeString(string(signatureB64)) + assert.NoError(t, err) + + verified, err := signatures.VerifySignature(plainText, accountA.IdKeys.Ed25519.B64Encoded(), signature) + assert.NoError(t, err) + assert.True(t, verified) +} diff --git a/mautrix-patched/crypto/goolm/account/register.go b/mautrix-patched/crypto/goolm/account/register.go new file mode 100644 index 00000000..ec392d7e --- /dev/null +++ b/mautrix-patched/crypto/goolm/account/register.go @@ -0,0 +1,23 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package account + +import ( + "maunium.net/go/mautrix/crypto/olm" +) + +func Register() { + olm.InitNewAccount = func() (olm.Account, error) { + return NewAccount() + } + olm.InitBlankAccount = func() olm.Account { + return &Account{} + } + olm.InitNewAccountFromPickled = func(pickled, key []byte) (olm.Account, error) { + return AccountFromPickled(pickled, key) + } +} diff --git a/mautrix-patched/crypto/goolm/aessha2/aessha2.go b/mautrix-patched/crypto/goolm/aessha2/aessha2.go new file mode 100644 index 00000000..f1b7ba75 --- /dev/null +++ b/mautrix-patched/crypto/goolm/aessha2/aessha2.go @@ -0,0 +1,63 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package aessha2 implements the m.megolm.v1.aes-sha2 encryption algorithm +// described in [Section 10.12.4.3] in the Spec +// +// [Section 10.12.4.3]: https://spec.matrix.org/v1.12/client-server-api/#mmegolmv1aes-sha2 +package aessha2 + +import ( + "crypto/hmac" + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" + + "maunium.net/go/mautrix/crypto/aescbc" +) + +type AESSHA2 struct { + aesKey, hmacKey, iv []byte +} + +func NewAESSHA2(secret, info []byte) (AESSHA2, error) { + kdf := hkdf.New(sha256.New, secret, nil, info) + keymatter := make([]byte, 80) + _, err := io.ReadFull(kdf, keymatter) + return AESSHA2{ + keymatter[:32], // AES Key + keymatter[32:64], // HMAC Key + keymatter[64:], // IV + }, err +} + +func (a *AESSHA2) Encrypt(plaintext []byte) ([]byte, error) { + return aescbc.Encrypt(a.aesKey, a.iv, plaintext) +} + +func (a *AESSHA2) Decrypt(ciphertext []byte) ([]byte, error) { + return aescbc.Decrypt(a.aesKey, a.iv, ciphertext) +} + +func (a *AESSHA2) MAC(ciphertext []byte) ([]byte, error) { + hash := hmac.New(sha256.New, a.hmacKey) + _, err := hash.Write(ciphertext) + return hash.Sum(nil), err +} + +func (a *AESSHA2) VerifyMAC(ciphertext, theirMAC []byte, macLength int) (bool, error) { + if macLength > 32 { + panic(fmt.Sprintf("invalid mac length: %d", macLength)) + } else if len(theirMAC) != macLength { + return false, fmt.Errorf("unexpected input MAC length: %d != %d", len(theirMAC), macLength) + } else if mac, err := a.MAC(ciphertext); err != nil { + return false, err + } else { + return hmac.Equal(mac[:len(theirMAC)], theirMAC), nil + } +} diff --git a/mautrix-patched/crypto/goolm/aessha2/aessha2_test.go b/mautrix-patched/crypto/goolm/aessha2/aessha2_test.go new file mode 100644 index 00000000..b7bd70b7 --- /dev/null +++ b/mautrix-patched/crypto/goolm/aessha2/aessha2_test.go @@ -0,0 +1,36 @@ +package aessha2_test + +import ( + "crypto/aes" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" +) + +func TestCipherAESSha256(t *testing.T) { + key := []byte("test key") + cipher, err := aessha2.NewAESSHA2(key, []byte("testKDFinfo")) + assert.NoError(t, err) + message := []byte("this is a random message for testing the implementation") + //increase to next block size + for len(message)%aes.BlockSize != 0 { + message = append(message, []byte("-")...) + } + encrypted, err := cipher.Encrypt([]byte(message)) + assert.NoError(t, err) + mac, err := cipher.MAC(encrypted) + assert.NoError(t, err) + + _, err = cipher.VerifyMAC(encrypted, mac[:4], 8) + assert.Error(t, err) + + verified, err := cipher.VerifyMAC(encrypted, mac[:8], 8) + assert.NoError(t, err) + assert.True(t, verified, "signature verification failed") + + resultPlainText, err := cipher.Decrypt(encrypted) + assert.NoError(t, err) + assert.Equal(t, message, resultPlainText) +} diff --git a/mautrix-patched/crypto/goolm/crypto/curve25519.go b/mautrix-patched/crypto/goolm/crypto/curve25519.go new file mode 100644 index 00000000..e6f4d765 --- /dev/null +++ b/mautrix-patched/crypto/goolm/crypto/curve25519.go @@ -0,0 +1,127 @@ +package crypto + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + + "golang.org/x/crypto/curve25519" + + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/id" +) + +const ( + Curve25519PrivateKeyLength = curve25519.ScalarSize //The length of the private key. + Curve25519PublicKeyLength = 32 +) + +// Curve25519KeyPair stores both parts of a curve25519 key. +type Curve25519KeyPair struct { + PrivateKey Curve25519PrivateKey `json:"private,omitempty"` + PublicKey Curve25519PublicKey `json:"public,omitempty"` +} + +// Curve25519GenerateKey creates a new curve25519 key pair. +func Curve25519GenerateKey() (Curve25519KeyPair, error) { + privateKeyByte := make([]byte, Curve25519PrivateKeyLength) + if _, err := rand.Read(privateKeyByte); err != nil { + return Curve25519KeyPair{}, err + } + + privateKey := Curve25519PrivateKey(privateKeyByte) + publicKey, err := privateKey.PubKey() + return Curve25519KeyPair{ + PrivateKey: Curve25519PrivateKey(privateKey), + PublicKey: Curve25519PublicKey(publicKey), + }, err +} + +// Curve25519GenerateFromPrivate creates a new curve25519 key pair with the private key given. +func Curve25519GenerateFromPrivate(private Curve25519PrivateKey) (Curve25519KeyPair, error) { + publicKey, err := private.PubKey() + return Curve25519KeyPair{ + PrivateKey: private, + PublicKey: Curve25519PublicKey(publicKey), + }, err +} + +// B64Encoded returns a base64 encoded string of the public key. +func (c Curve25519KeyPair) B64Encoded() id.Curve25519 { + return c.PublicKey.B64Encoded() +} + +// SharedSecret returns the shared secret between the key pair and the given public key. +func (c Curve25519KeyPair) SharedSecret(pubKey Curve25519PublicKey) ([]byte, error) { + // Note: the standard library checks that the output is non-zero + return c.PrivateKey.SharedSecret(pubKey) +} + +// PickleLibOlm pickles the key pair into the encoder. +func (c Curve25519KeyPair) PickleLibOlm(encoder *libolmpickle.Encoder) { + c.PublicKey.PickleLibOlm(encoder) + if len(c.PrivateKey) == Curve25519PrivateKeyLength { + encoder.Write(c.PrivateKey) + } else { + encoder.WriteEmptyBytes(Curve25519PrivateKeyLength) + } +} + +// UnpickleLibOlm decodes the unencryted value and populates the key pair accordingly. It returns the number of bytes read. +func (c *Curve25519KeyPair) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + if err := c.PublicKey.UnpickleLibOlm(decoder); err != nil { + return err + } else if privKey, err := decoder.ReadBytesOrNil(Curve25519PrivateKeyLength); err != nil { + return err + } else { + c.PrivateKey = privKey + return nil + } +} + +// Curve25519PrivateKey represents the private key for curve25519 usage +type Curve25519PrivateKey []byte + +// Equal compares the private key to the given private key. +func (c Curve25519PrivateKey) Equal(x Curve25519PrivateKey) bool { + return subtle.ConstantTimeCompare(c, x) == 1 +} + +// PubKey returns the public key derived from the private key. +func (c Curve25519PrivateKey) PubKey() (Curve25519PublicKey, error) { + return curve25519.X25519(c, curve25519.Basepoint) +} + +// SharedSecret returns the shared secret between the private key and the given public key. +func (c Curve25519PrivateKey) SharedSecret(pubKey Curve25519PublicKey) ([]byte, error) { + return curve25519.X25519(c, pubKey) +} + +// Curve25519PublicKey represents the public key for curve25519 usage +type Curve25519PublicKey []byte + +// Equal compares the public key to the given public key. +func (c Curve25519PublicKey) Equal(x Curve25519PublicKey) bool { + return subtle.ConstantTimeCompare(c, x) == 1 +} + +// B64Encoded returns a base64 encoded string of the public key. +func (c Curve25519PublicKey) B64Encoded() id.Curve25519 { + return id.Curve25519(base64.RawStdEncoding.EncodeToString(c)) +} + +// PickleLibOlm pickles the public key into the encoder. +func (c Curve25519PublicKey) PickleLibOlm(encoder *libolmpickle.Encoder) { + if len(c) == Curve25519PublicKeyLength { + encoder.Write(c) + } else { + encoder.WriteEmptyBytes(Curve25519PublicKeyLength) + } +} + +// UnpickleLibOlm decodes the unencryted value and populates the public key accordingly. It returns the number of bytes read. +func (c *Curve25519PublicKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + pubkey, err := decoder.ReadBytesOrNil(Curve25519PublicKeyLength) + *c = pubkey + return err +} diff --git a/mautrix-patched/crypto/goolm/crypto/curve25519_test.go b/mautrix-patched/crypto/goolm/crypto/curve25519_test.go new file mode 100644 index 00000000..2550f15e --- /dev/null +++ b/mautrix-patched/crypto/goolm/crypto/curve25519_test.go @@ -0,0 +1,125 @@ +package crypto_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +const curve25519KeyPairPickleLength = crypto.Curve25519PublicKeyLength + // Public Key + crypto.Curve25519PrivateKeyLength // Private Key + +func TestCurve25519(t *testing.T) { + firstKeypair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + secondKeypair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + sharedSecretFromFirst, err := firstKeypair.SharedSecret(secondKeypair.PublicKey) + assert.NoError(t, err) + sharedSecretFromSecond, err := secondKeypair.SharedSecret(firstKeypair.PublicKey) + assert.NoError(t, err) + assert.Equal(t, sharedSecretFromFirst, sharedSecretFromSecond, "shared secret not equal") + fromPrivate, err := crypto.Curve25519GenerateFromPrivate(firstKeypair.PrivateKey) + assert.NoError(t, err) + assert.Equal(t, fromPrivate, firstKeypair) + _, err = secondKeypair.SharedSecret(make([]byte, crypto.Curve25519PublicKeyLength)) + assert.Error(t, err) +} + +func TestCurve25519Case1(t *testing.T) { + alicePrivate := []byte{ + 0x77, 0x07, 0x6D, 0x0A, 0x73, 0x18, 0xA5, 0x7D, + 0x3C, 0x16, 0xC1, 0x72, 0x51, 0xB2, 0x66, 0x45, + 0xDF, 0x4C, 0x2F, 0x87, 0xEB, 0xC0, 0x99, 0x2A, + 0xB1, 0x77, 0xFB, 0xA5, 0x1D, 0xB9, 0x2C, 0x2A, + } + alicePublic := []byte{ + 0x85, 0x20, 0xF0, 0x09, 0x89, 0x30, 0xA7, 0x54, + 0x74, 0x8B, 0x7D, 0xDC, 0xB4, 0x3E, 0xF7, 0x5A, + 0x0D, 0xBF, 0x3A, 0x0D, 0x26, 0x38, 0x1A, 0xF4, + 0xEB, 0xA4, 0xA9, 0x8E, 0xAA, 0x9B, 0x4E, 0x6A, + } + bobPrivate := []byte{ + 0x5D, 0xAB, 0x08, 0x7E, 0x62, 0x4A, 0x8A, 0x4B, + 0x79, 0xE1, 0x7F, 0x8B, 0x83, 0x80, 0x0E, 0xE6, + 0x6F, 0x3B, 0xB1, 0x29, 0x26, 0x18, 0xB6, 0xFD, + 0x1C, 0x2F, 0x8B, 0x27, 0xFF, 0x88, 0xE0, 0xEB, + } + bobPublic := []byte{ + 0xDE, 0x9E, 0xDB, 0x7D, 0x7B, 0x7D, 0xC1, 0xB4, + 0xD3, 0x5B, 0x61, 0xC2, 0xEC, 0xE4, 0x35, 0x37, + 0x3F, 0x83, 0x43, 0xC8, 0x5B, 0x78, 0x67, 0x4D, + 0xAD, 0xFC, 0x7E, 0x14, 0x6F, 0x88, 0x2B, 0x4F, + } + expectedAgreement := []byte{ + 0x4A, 0x5D, 0x9D, 0x5B, 0xA4, 0xCE, 0x2D, 0xE1, + 0x72, 0x8E, 0x3B, 0xF4, 0x80, 0x35, 0x0F, 0x25, + 0xE0, 0x7E, 0x21, 0xC9, 0x47, 0xD1, 0x9E, 0x33, + 0x76, 0xF0, 0x9B, 0x3C, 0x1E, 0x16, 0x17, 0x42, + } + aliceKeyPair := crypto.Curve25519KeyPair{ + PrivateKey: alicePrivate, + PublicKey: alicePublic, + } + bobKeyPair := crypto.Curve25519KeyPair{ + PrivateKey: bobPrivate, + PublicKey: bobPublic, + } + agreementFromAlice, err := aliceKeyPair.SharedSecret(bobKeyPair.PublicKey) + assert.NoError(t, err) + assert.Equal(t, expectedAgreement, agreementFromAlice, "expected agreement does not match agreement from Alice's view") + agreementFromBob, err := bobKeyPair.SharedSecret(aliceKeyPair.PublicKey) + assert.NoError(t, err) + assert.Equal(t, expectedAgreement, agreementFromBob, "expected agreement does not match agreement from Bob's view") +} + +func TestCurve25519Pickle(t *testing.T) { + //create keypair + keyPair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + + encoder := libolmpickle.NewEncoder() + keyPair.PickleLibOlm(encoder) + assert.Len(t, encoder.Bytes(), curve25519KeyPairPickleLength) + + unpickledKeyPair := crypto.Curve25519KeyPair{} + err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) + assert.NoError(t, err) + assert.Equal(t, keyPair, unpickledKeyPair) +} + +func TestCurve25519PicklePubKeyOnly(t *testing.T) { + //create keypair + keyPair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + + //Remove privateKey + keyPair.PrivateKey = nil + + encoder := libolmpickle.NewEncoder() + keyPair.PickleLibOlm(encoder) + assert.Len(t, encoder.Bytes(), curve25519KeyPairPickleLength) + + unpickledKeyPair := crypto.Curve25519KeyPair{} + err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) + assert.NoError(t, err) + assert.Equal(t, keyPair, unpickledKeyPair) +} + +func TestCurve25519PicklePrivKeyOnly(t *testing.T) { + //create keypair + keyPair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + //Remove public + keyPair.PublicKey = nil + encoder := libolmpickle.NewEncoder() + keyPair.PickleLibOlm(encoder) + assert.Len(t, encoder.Bytes(), curve25519KeyPairPickleLength) + unpickledKeyPair := crypto.Curve25519KeyPair{} + err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) + assert.NoError(t, err) + assert.Equal(t, keyPair, unpickledKeyPair) +} diff --git a/mautrix-patched/crypto/goolm/crypto/doc.go b/mautrix-patched/crypto/goolm/crypto/doc.go new file mode 100644 index 00000000..5bdb01d8 --- /dev/null +++ b/mautrix-patched/crypto/goolm/crypto/doc.go @@ -0,0 +1,2 @@ +// Package crpyto provides the nessesary encryption methods for olm/megolm +package crypto diff --git a/mautrix-patched/crypto/goolm/crypto/ed25519.go b/mautrix-patched/crypto/goolm/crypto/ed25519.go new file mode 100644 index 00000000..c0e7505b --- /dev/null +++ b/mautrix-patched/crypto/goolm/crypto/ed25519.go @@ -0,0 +1,164 @@ +package crypto + +import ( + "bytes" + "encoding/base64" + + "filippo.io/edwards25519" + + "maunium.net/go/mautrix/crypto/ed25519" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/id" +) + +const ( + Ed25519SignatureSize = ed25519.SignatureSize //The length of a signature +) + +// Ed25519GenerateKey creates a new ed25519 key pair. +func Ed25519GenerateKey() (Ed25519KeyPair, error) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + return Ed25519KeyPair{ + PrivateKey: Ed25519PrivateKey(privateKey), + PublicKey: Ed25519PublicKey(publicKey), + }, err +} + +// Ed25519GenerateFromPrivate creates a new ed25519 key pair with the private key given. +func Ed25519GenerateFromPrivate(privKey Ed25519PrivateKey) Ed25519KeyPair { + return Ed25519KeyPair{ + PrivateKey: privKey, + PublicKey: privKey.PubKey(), + } +} + +// Ed25519GenerateFromSeed creates a new ed25519 key pair with a given seed. +func Ed25519GenerateFromSeed(seed []byte) Ed25519KeyPair { + privKey := Ed25519PrivateKey(ed25519.NewKeyFromSeed(seed)) + return Ed25519KeyPair{ + PrivateKey: privKey, + PublicKey: privKey.PubKey(), + } +} + +// Ed25519KeyPair stores both parts of a ed25519 key. +type Ed25519KeyPair struct { + PrivateKey Ed25519PrivateKey `json:"private,omitempty"` + PublicKey Ed25519PublicKey `json:"public,omitempty"` +} + +// B64Encoded returns a base64 encoded string of the public key. +func (c Ed25519KeyPair) B64Encoded() id.Ed25519 { + return id.Ed25519(base64.RawStdEncoding.EncodeToString(c.PublicKey)) +} + +// Sign returns the signature for the message. +func (c Ed25519KeyPair) Sign(message []byte) ([]byte, error) { + return c.PrivateKey.Sign(message) +} + +// Verify checks the signature of the message against the givenSignature +func (c Ed25519KeyPair) Verify(message, givenSignature []byte) bool { + return c.PublicKey.Verify(message, givenSignature) +} + +// PickleLibOlm pickles the key pair into the encoder. +func (c Ed25519KeyPair) PickleLibOlm(encoder *libolmpickle.Encoder) { + c.PublicKey.PickleLibOlm(encoder) + if len(c.PrivateKey) == ed25519.PrivateKeySize { + encoder.Write(c.PrivateKey) + } else { + encoder.WriteEmptyBytes(ed25519.PrivateKeySize) + } +} + +// UnpickleLibOlm unpickles the unencryted value and populates the key pair accordingly. +func (c *Ed25519KeyPair) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + if err := c.PublicKey.UnpickleLibOlm(decoder); err != nil { + return err + } else if privKey, err := decoder.ReadBytesOrNil(ed25519.PrivateKeySize); err != nil { + return err + } else { + c.PrivateKey = privKey + return nil + } +} + +// Curve25519PrivateKey represents the private key for ed25519 usage. This is just a wrapper. +type Ed25519PrivateKey ed25519.PrivateKey + +// Equal compares the private key to the given private key. +func (c Ed25519PrivateKey) Equal(x Ed25519PrivateKey) bool { + return ed25519.PrivateKey(c).Equal(ed25519.PrivateKey(x)) +} + +// PubKey returns the public key derived from the private key. +func (c Ed25519PrivateKey) PubKey() Ed25519PublicKey { + publicKey := ed25519.PrivateKey(c).Public() + return Ed25519PublicKey(publicKey.([]byte)) +} + +// Sign returns the signature for the message. +func (c Ed25519PrivateKey) Sign(message []byte) ([]byte, error) { + return ed25519.PrivateKey(c).Sign(nil, message, &ed25519.Options{}) +} + +// Ed25519PublicKey represents the public key for ed25519 usage. This is just a wrapper. +type Ed25519PublicKey ed25519.PublicKey + +// Equal compares the public key to the given public key. +func (c Ed25519PublicKey) Equal(x Ed25519PublicKey) bool { + return ed25519.PublicKey(c).Equal(ed25519.PublicKey(x)) +} + +// B64Encoded returns a base64 encoded string of the public key. +func (c Ed25519PublicKey) B64Encoded() id.Curve25519 { + return id.Curve25519(base64.RawStdEncoding.EncodeToString(c)) +} + +// Verify checks the signature of the message against the givenSignature using strict verification. +// In addition to the standard library ed25519 verification which checks signature malleability, +// this also rejects non-canonical and small-order public keys. +func (c Ed25519PublicKey) Verify(message, givenSignature []byte) bool { + if len(givenSignature) != Ed25519SignatureSize || !c.IsValidKey() { + return false + } + + return ed25519.Verify(ed25519.PublicKey(c), message, givenSignature) +} + +func (c Ed25519PublicKey) IsValidKey() bool { + if len(c) != ed25519.PublicKeySize { + return false + } + pubPoint, err := (&edwards25519.Point{}).SetBytes(c) + if err != nil { + return false + } + // Reject non-canonical public keys + if !bytes.Equal(pubPoint.Bytes(), c) { + return false + } + // Reject small-order public keys + if new(edwards25519.Point).MultByCofactor(pubPoint).Equal(edwards25519.NewIdentityPoint()) == 1 { + return false + } + return true +} + +// PickleLibOlm pickles the public key into the encoder. +func (c Ed25519PublicKey) PickleLibOlm(encoder *libolmpickle.Encoder) { + if len(c) == ed25519.PublicKeySize { + encoder.Write(c) + } else { + encoder.WriteEmptyBytes(ed25519.PublicKeySize) + } +} + +// UnpickleLibOlm unpickles the unencryted value and populates the public key +// accordingly. +func (c *Ed25519PublicKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + key, err := decoder.ReadBytesOrNil(ed25519.PublicKeySize) + *c = key + return err +} diff --git a/mautrix-patched/crypto/goolm/crypto/ed25519_test.go b/mautrix-patched/crypto/goolm/crypto/ed25519_test.go new file mode 100644 index 00000000..610b8f3e --- /dev/null +++ b/mautrix-patched/crypto/goolm/crypto/ed25519_test.go @@ -0,0 +1,89 @@ +package crypto_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/ed25519" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +const ed25519KeyPairPickleLength = ed25519.PublicKeySize + // PublicKey + ed25519.PrivateKeySize // Private Key + +func TestEd25519(t *testing.T) { + keypair, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + message := []byte("test message") + signature, err := keypair.Sign(message) + require.NoError(t, err) + assert.True(t, keypair.Verify(message, signature)) +} + +func TestEd25519Case1(t *testing.T) { + //64 bytes for ed25519 package + keyPair, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + message := []byte("Hello, World") + + keyPair2 := crypto.Ed25519GenerateFromPrivate(keyPair.PrivateKey) + assert.Equal(t, keyPair, keyPair2, "not equal key pairs") + signature, err := keyPair.Sign(message) + require.NoError(t, err) + verified := keyPair.Verify(message, signature) + assert.True(t, verified, "message did not verify although it should") + + //Now change the message and verify again + message = append(message, []byte("a")...) + verified = keyPair.Verify(message, signature) + assert.False(t, verified, "message did verify although it should not") +} + +func TestEd25519Pickle(t *testing.T) { + //create keypair + keyPair, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + encoder := libolmpickle.NewEncoder() + keyPair.PickleLibOlm(encoder) + assert.Len(t, encoder.Bytes(), ed25519KeyPairPickleLength) + + unpickledKeyPair := crypto.Ed25519KeyPair{} + err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) + assert.NoError(t, err) + assert.Equal(t, keyPair, unpickledKeyPair) +} + +func TestEd25519PicklePubKeyOnly(t *testing.T) { + //create keypair + keyPair, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + //Remove privateKey + keyPair.PrivateKey = nil + encoder := libolmpickle.NewEncoder() + keyPair.PickleLibOlm(encoder) + assert.Len(t, encoder.Bytes(), ed25519KeyPairPickleLength) + + unpickledKeyPair := crypto.Ed25519KeyPair{} + err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) + assert.NoError(t, err) + assert.Equal(t, keyPair, unpickledKeyPair) +} + +func TestEd25519PicklePrivKeyOnly(t *testing.T) { + //create keypair + keyPair, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + //Remove public + keyPair.PublicKey = nil + encoder := libolmpickle.NewEncoder() + keyPair.PickleLibOlm(encoder) + assert.Len(t, encoder.Bytes(), ed25519KeyPairPickleLength) + + unpickledKeyPair := crypto.Ed25519KeyPair{} + err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) + assert.NoError(t, err) + assert.Equal(t, keyPair, unpickledKeyPair) +} diff --git a/mautrix-patched/crypto/goolm/crypto/one_time_key.go b/mautrix-patched/crypto/goolm/crypto/one_time_key.go new file mode 100644 index 00000000..888b1749 --- /dev/null +++ b/mautrix-patched/crypto/goolm/crypto/one_time_key.go @@ -0,0 +1,46 @@ +package crypto + +import ( + "encoding/base64" + "encoding/binary" + + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +// OneTimeKey stores the information about a one time key. +type OneTimeKey struct { + ID uint32 `json:"id"` + Published bool `json:"published"` + Key Curve25519KeyPair `json:"key,omitempty"` +} + +// Equal compares the one time key to the given one. +func (otk OneTimeKey) Equal(other OneTimeKey) bool { + return otk.ID == other.ID && + otk.Published == other.Published && + otk.Key.PrivateKey.Equal(other.Key.PrivateKey) && + otk.Key.PublicKey.Equal(other.Key.PublicKey) +} + +// PickleLibOlm pickles the key pair into the encoder. +func (c OneTimeKey) PickleLibOlm(encoder *libolmpickle.Encoder) { + encoder.WriteUInt32(c.ID) + encoder.WriteBool(c.Published) + c.Key.PickleLibOlm(encoder) +} + +// UnpickleLibOlm unpickles the unencryted value and populates the [OneTimeKey] +// accordingly. +func (c *OneTimeKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) (err error) { + if c.ID, err = decoder.ReadUInt32(); err != nil { + return + } else if c.Published, err = decoder.ReadBool(); err != nil { + return + } + return c.Key.UnpickleLibOlm(decoder) +} + +// KeyIDEncoded returns the base64 encoded key ID. +func (c OneTimeKey) KeyIDEncoded() string { + return base64.RawStdEncoding.EncodeToString(binary.BigEndian.AppendUint32(nil, c.ID)) +} diff --git a/mautrix-patched/crypto/goolm/goolmbase64/base64.go b/mautrix-patched/crypto/goolm/goolmbase64/base64.go new file mode 100644 index 00000000..58ee26f7 --- /dev/null +++ b/mautrix-patched/crypto/goolm/goolmbase64/base64.go @@ -0,0 +1,22 @@ +package goolmbase64 + +import ( + "encoding/base64" +) + +// These methods should only be used for raw byte operations, never with string conversion + +func Decode(input []byte) ([]byte, error) { + decoded := make([]byte, base64.RawStdEncoding.DecodedLen(len(input))) + writtenBytes, err := base64.RawStdEncoding.Decode(decoded, input) + if err != nil { + return nil, err + } + return decoded[:writtenBytes], nil +} + +func Encode(input []byte) []byte { + encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(input))) + base64.RawStdEncoding.Encode(encoded, input) + return encoded +} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/encoder.go b/mautrix-patched/crypto/goolm/libolmpickle/encoder.go new file mode 100644 index 00000000..63e7b09b --- /dev/null +++ b/mautrix-patched/crypto/goolm/libolmpickle/encoder.go @@ -0,0 +1,40 @@ +package libolmpickle + +import ( + "bytes" + "encoding/binary" + + "go.mau.fi/util/exerrors" +) + +const ( + PickleBoolLength = 1 + PickleUInt8Length = 1 + PickleUInt32Length = 4 +) + +type Encoder struct { + bytes.Buffer +} + +func NewEncoder() *Encoder { return &Encoder{} } + +func (p *Encoder) WriteUInt8(value uint8) { + exerrors.PanicIfNotNil(p.WriteByte(value)) +} + +func (p *Encoder) WriteBool(value bool) { + if value { + exerrors.PanicIfNotNil(p.WriteByte(0x01)) + } else { + exerrors.PanicIfNotNil(p.WriteByte(0x00)) + } +} + +func (p *Encoder) WriteEmptyBytes(count int) { + exerrors.Must(p.Write(make([]byte, count))) +} + +func (p *Encoder) WriteUInt32(value uint32) { + exerrors.PanicIfNotNil(binary.Write(&p.Buffer, binary.BigEndian, value)) +} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go b/mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go new file mode 100644 index 00000000..c7811225 --- /dev/null +++ b/mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go @@ -0,0 +1,99 @@ +package libolmpickle_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +func TestEncoder(t *testing.T) { + var encoder libolmpickle.Encoder + encoder.WriteUInt32(4) + encoder.WriteUInt8(8) + encoder.WriteBool(false) + encoder.WriteEmptyBytes(10) + encoder.WriteBool(true) + encoder.Write([]byte("test")) + encoder.WriteUInt32(420_000) + assert.Equal(t, []byte{ + 0x00, 0x00, 0x00, 0x04, // 4 + 0x08, // 8 + 0x00, // false + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ten empty bytes + 0x01, //true + 0x74, 0x65, 0x73, 0x74, // "test" (ASCII) + 0x00, 0x06, 0x68, 0xa0, // 420,000 + }, encoder.Bytes()) +} + +func TestPickleUInt32(t *testing.T) { + values := []uint32{ + 0xffffffff, + 0x00ff00ff, + 0xf0000000, + 0xf00f0000, + } + expected := [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + {0x00, 0xff, 0x00, 0xff}, + {0xf0, 0x00, 0x00, 0x00}, + {0xf0, 0x0f, 0x00, 0x00}, + } + for i, value := range values { + var encoder libolmpickle.Encoder + encoder.WriteUInt32(value) + assert.Equal(t, expected[i], encoder.Bytes()) + } +} + +func TestPickleBool(t *testing.T) { + values := []bool{ + true, + false, + } + expected := [][]byte{ + {0x01}, + {0x00}, + } + for i, value := range values { + var encoder libolmpickle.Encoder + encoder.WriteBool(value) + assert.Equal(t, expected[i], encoder.Bytes()) + } +} + +func TestPickleUInt8(t *testing.T) { + values := []uint8{ + 0xff, + 0x1a, + } + expected := [][]byte{ + {0xff}, + {0x1a}, + } + for i, value := range values { + var encoder libolmpickle.Encoder + encoder.WriteUInt8(value) + assert.Equal(t, expected[i], encoder.Bytes()) + } +} + +func TestPickleBytes(t *testing.T) { + values := [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + {0x00, 0xff, 0x00, 0xff}, + {0xf0, 0x00, 0x00, 0x00}, + } + expected := [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + {0x00, 0xff, 0x00, 0xff}, + {0xf0, 0x00, 0x00, 0x00}, + } + for i, value := range values { + var encoder libolmpickle.Encoder + encoder.Write(value) + assert.Equal(t, expected[i], encoder.Bytes()) + } +} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/pickle.go b/mautrix-patched/crypto/goolm/libolmpickle/pickle.go new file mode 100644 index 00000000..477e5620 --- /dev/null +++ b/mautrix-patched/crypto/goolm/libolmpickle/pickle.go @@ -0,0 +1,53 @@ +package libolmpickle + +import ( + "crypto/aes" + "fmt" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/goolmbase64" + "maunium.net/go/mautrix/crypto/olm" +) + +const pickleMACLength = 8 + +var kdfPickle = []byte("Pickle") //used to derive the keys for encryption + +// Pickle encrypts the input with the key and the cipher AESSHA256. The result is then encoded in base64. +// +// The encryption used here is not particularly secure: both the AES key and IV are deterministic based on the pickle key. +// However, pickles are only used locally and the key is usually hardcoded or stored next to the database anyway. +func Pickle(key, plaintext []byte) ([]byte, error) { + if c, err := aessha2.NewAESSHA2(key, kdfPickle); err != nil { + return nil, err + } else if ciphertext, err := c.Encrypt(plaintext); err != nil { + return nil, err + } else if mac, err := c.MAC(ciphertext); err != nil { + return nil, err + } else { + return goolmbase64.Encode(append(ciphertext, mac[:pickleMACLength]...)), nil + } +} + +// Unpickle decodes the input from base64 and decrypts the decoded input with the key and the cipher AESSHA256. +func Unpickle(key, input []byte) ([]byte, error) { + ciphertext, err := goolmbase64.Decode(input) + if err != nil { + return nil, err + } + if len(ciphertext) < pickleMACLength { + return nil, fmt.Errorf("decrypt pickle: input too short") + } + ciphertext, mac := ciphertext[:len(ciphertext)-pickleMACLength], ciphertext[len(ciphertext)-pickleMACLength:] + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("decrypt pickle: ciphertext length %d not a multiple of block size", len(ciphertext)) + } else if c, err := aessha2.NewAESSHA2(key, kdfPickle); err != nil { + return nil, err + } else if verified, err := c.VerifyMAC(ciphertext, mac, pickleMACLength); err != nil { + return nil, err + } else if !verified { + return nil, fmt.Errorf("decrypt pickle: %w", olm.ErrBadMAC) + } else { + return c.Decrypt(ciphertext) + } +} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go b/mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go new file mode 100644 index 00000000..0720e008 --- /dev/null +++ b/mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go @@ -0,0 +1,26 @@ +package libolmpickle + +import ( + "crypto/aes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEncoding(t *testing.T) { + key := []byte("test key") + input := []byte("test") + //pad marshaled to get block size + toEncrypt := input + if len(input)%aes.BlockSize != 0 { + padding := aes.BlockSize - len(input)%aes.BlockSize + toEncrypt = make([]byte, len(input)+padding) + copy(toEncrypt, input) + } + encoded, err := Pickle(key, toEncrypt) + assert.NoError(t, err) + + decoded, err := Unpickle(key, encoded) + assert.NoError(t, err) + assert.Equal(t, toEncrypt, decoded) +} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/unpickle.go b/mautrix-patched/crypto/goolm/libolmpickle/unpickle.go new file mode 100644 index 00000000..84ad43ea --- /dev/null +++ b/mautrix-patched/crypto/goolm/libolmpickle/unpickle.go @@ -0,0 +1,58 @@ +package libolmpickle + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +func isZeroByteSlice(data []byte) bool { + for _, b := range data { + if b != 0 { + return false + } + } + return true +} + +type Decoder struct { + buf bytes.Buffer +} + +func NewDecoder(buf []byte) *Decoder { + return &Decoder{buf: *bytes.NewBuffer(buf)} +} + +func (d *Decoder) ReadUInt8() (uint8, error) { + return d.buf.ReadByte() +} + +func (d *Decoder) ReadBool() (bool, error) { + val, err := d.buf.ReadByte() + return val != 0x00, err +} + +func (d *Decoder) ReadBytesOrNil(length int) (data []byte, err error) { + data, err = d.ReadBytes(length) + if err == nil && isZeroByteSlice(data) { + data = nil + } + return +} + +func (d *Decoder) ReadBytes(length int) (data []byte, err error) { + data = d.buf.Next(length) + if len(data) != length { + return nil, fmt.Errorf("only %d in buffer, expected %d", len(data), length) + } + return +} + +func (d *Decoder) ReadUInt32() (uint32, error) { + data := d.buf.Next(4) + if len(data) != 4 { + return 0, fmt.Errorf("only %d bytes is buffer, expected 4 for uint32", len(data)) + } else { + return binary.BigEndian.Uint32(data), nil + } +} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go b/mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go new file mode 100644 index 00000000..30355a76 --- /dev/null +++ b/mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go @@ -0,0 +1,83 @@ +package libolmpickle_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +func TestUnpickleUInt32(t *testing.T) { + expected := []uint32{ + 0xffffffff, + 0x00ff00ff, + 0xf0000000, + } + values := [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + {0x00, 0xff, 0x00, 0xff}, + {0xf0, 0x00, 0x00, 0x00}, + } + for curIndex := range values { + decoder := libolmpickle.NewDecoder(values[curIndex]) + response, err := decoder.ReadUInt32() + assert.NoError(t, err) + assert.Equal(t, expected[curIndex], response) + } +} + +func TestUnpickleBool(t *testing.T) { + expected := []bool{ + true, + false, + true, + } + values := [][]byte{ + {0x01}, + {0x00}, + {0x02}, + } + for curIndex := range values { + decoder := libolmpickle.NewDecoder(values[curIndex]) + response, err := decoder.ReadBool() + assert.NoError(t, err) + assert.Equal(t, expected[curIndex], response) + } +} + +func TestUnpickleUInt8(t *testing.T) { + expected := []uint8{ + 0xff, + 0x1a, + } + values := [][]byte{ + {0xff}, + {0x1a}, + } + for curIndex := range values { + decoder := libolmpickle.NewDecoder(values[curIndex]) + response, err := decoder.ReadUInt8() + assert.NoError(t, err) + assert.Equal(t, expected[curIndex], response) + } +} + +func TestUnpickleBytes(t *testing.T) { + values := [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + {0x00, 0xff, 0x00, 0xff}, + {0xf0, 0x00, 0x00, 0x00}, + } + expected := [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + {0x00, 0xff, 0x00, 0xff}, + {0xf0, 0x00, 0x00, 0x00}, + } + for curIndex := range values { + decoder := libolmpickle.NewDecoder(values[curIndex]) + response, err := decoder.ReadBytes(4) + assert.NoError(t, err) + assert.Equal(t, expected[curIndex], response) + } +} diff --git a/mautrix-patched/crypto/goolm/main.go b/mautrix-patched/crypto/goolm/main.go new file mode 100644 index 00000000..55674305 --- /dev/null +++ b/mautrix-patched/crypto/goolm/main.go @@ -0,0 +1,6 @@ +// Package goolm is a pure Go implementation of libolm. Libolm is a cryptographic library used for end-to-end encryption in Matrix and written in C++. +// With goolm there is no need to use cgo when building Matrix clients in go. +/* +This package contains the possible errors which can occur as well as some simple functions. All the 'action' happens in the subdirectories. +*/ +package goolm diff --git a/mautrix-patched/crypto/goolm/megolm/megolm.go b/mautrix-patched/crypto/goolm/megolm/megolm.go new file mode 100644 index 00000000..12029d53 --- /dev/null +++ b/mautrix-patched/crypto/goolm/megolm/megolm.go @@ -0,0 +1,209 @@ +// megolm provides the ratchet used by the megolm protocol +package megolm + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "fmt" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/goolmbase64" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/goolm/message" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + protocolVersion = 3 + RatchetParts = 4 // number of ratchet parts + RatchetPartLength = 256 / 8 // length of each ratchet part in bytes +) + +var megolmKeysKDFInfo = []byte("MEGOLM_KEYS") + +// hasKeySeed are the seed for the different ratchet parts +var hashKeySeeds [RatchetParts][]byte = [RatchetParts][]byte{ + {0x00}, + {0x01}, + {0x02}, + {0x03}, +} + +// Ratchet represents the megolm ratchet as described in +// +// https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/megolm.md +type Ratchet struct { + Data [RatchetParts * RatchetPartLength]byte `json:"data"` + Counter uint32 `json:"counter"` +} + +// New creates a new ratchet with counter set to counter and the ratchet data set to data. +func New(counter uint32, data [RatchetParts * RatchetPartLength]byte) (*Ratchet, error) { + m := &Ratchet{ + Counter: counter, + Data: data, + } + return m, nil +} + +// NewWithRandom creates a new ratchet with counter set to counter an the data filled with random values. +func NewWithRandom(counter uint32) (*Ratchet, error) { + var data [RatchetParts * RatchetPartLength]byte + _, err := rand.Read(data[:]) + if err != nil { + return nil, err + } + return New(counter, data) +} + +// rehashPart rehases the part of the ratchet data with the base defined as from storing into the target to. +func (m *Ratchet) rehashPart(from, to int) { + hash := hmac.New(sha256.New, m.Data[from*RatchetPartLength:from*RatchetPartLength+RatchetPartLength]) + hash.Write(hashKeySeeds[to]) + copy(m.Data[to*RatchetPartLength:], hash.Sum(nil)) +} + +// Advance advances the ratchet one step. +func (m *Ratchet) Advance() { + var mask uint32 = 0x00FFFFFF + var h int + m.Counter++ + + // figure out how much we need to rekey + for h < RatchetParts { + if (m.Counter & mask) == 0 { + break + } + h++ + mask >>= 8 + } + + // now update R(h)...R(3) based on R(h) + for i := RatchetParts - 1; i >= h; i-- { + m.rehashPart(h, i) + } +} + +// AdvanceTo advances the ratchet so that the ratchet counter = target +func (m *Ratchet) AdvanceTo(target uint32) { + //starting with R0, see if we need to update each part of the hash + for j := 0; j < RatchetParts; j++ { + shift := uint32((RatchetParts - j - 1) * 8) + mask := (^uint32(0)) << shift + + // how many times do we need to rehash this part? + // '& 0xff' ensures we handle integer wraparound correctly + steps := ((target >> shift) - (m.Counter >> shift)) & uint32(0xff) + + if steps == 0 { + /* + deal with the edge case where m.Counter is slightly larger + than target. This should only happen for R(0), and implies + that target has wrapped around and we need to advance R(0) + 256 times. + */ + if target < m.Counter { + steps = 0x100 + } else { + continue + } + } + // for all but the last step, we can just bump R(j) without regard to R(j+1)...R(3). + for steps > 1 { + m.rehashPart(j, j) + steps-- + } + /* + on the last step we also need to bump R(j+1)...R(3). + + (Theoretically, we could skip bumping R(j+2) if we're going to bump + R(j+1) again, but the code to figure that out is a bit baroque and + doesn't save us much). + */ + for k := 3; k >= j; k-- { + m.rehashPart(j, k) + } + m.Counter = target & mask + } +} + +// Encrypt encrypts the message in a message.GroupMessage with MAC and signature. +// The output is base64 encoded. +func (r *Ratchet) Encrypt(plaintext []byte, key crypto.Ed25519KeyPair) ([]byte, error) { + cipher, err := aessha2.NewAESSHA2(r.Data[:], megolmKeysKDFInfo) + if err != nil { + return nil, fmt.Errorf("cipher encrypt: %w", err) + } + + message := &message.GroupMessage{} + message.Version = protocolVersion + message.MessageIndex = r.Counter + message.Ciphertext, err = cipher.Encrypt(plaintext) + if err != nil { + return nil, err + } + //creating the MAC and signing is done in encode + output, err := message.EncodeAndMACAndSign(cipher, key) + if err != nil { + return nil, err + } + r.Advance() + return output, nil +} + +// SessionSharingMessage creates a message in the session sharing format. +func (r Ratchet) SessionSharingMessage(key crypto.Ed25519KeyPair) ([]byte, error) { + m := message.MegolmSessionSharing{} + m.Counter = r.Counter + m.RatchetData = r.Data + encoded, err := m.EncodeAndSign(key) + return goolmbase64.Encode(encoded), err +} + +// SessionExportMessage creates a message in the session export format. +func (r Ratchet) SessionExportMessage(key crypto.Ed25519PublicKey) ([]byte, error) { + m := message.MegolmSessionExport{} + m.Counter = r.Counter + m.RatchetData = r.Data + m.PublicKey = key + encoded := m.Encode() + return goolmbase64.Encode(encoded), nil +} + +// Decrypt decrypts the ciphertext and verifies the MAC but not the signature. +func (r Ratchet) Decrypt(ciphertext []byte, msg *message.GroupMessage) ([]byte, error) { + //verify mac + cipher, err := aessha2.NewAESSHA2(r.Data[:], megolmKeysKDFInfo) + if err != nil { + return nil, err + } + verifiedMAC, err := msg.VerifyMACInline(cipher, ciphertext) + if err != nil { + return nil, err + } + if !verifiedMAC { + return nil, fmt.Errorf("decrypt: %w", olm.ErrBadMAC) + } + + return cipher.Decrypt(msg.Ciphertext) +} + +// UnpickleLibOlm decodes the unencryted value and populates the Ratchet accordingly. It returns the number of bytes read. +func (r *Ratchet) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + ratchetData, err := decoder.ReadBytes(RatchetParts * RatchetPartLength) + if err != nil { + return err + } + copy(r.Data[:], ratchetData) + + r.Counter, err = decoder.ReadUInt32() + return err +} + +// PickleLibOlm pickles the ratchet into the encoder. +func (r Ratchet) PickleLibOlm(encoder *libolmpickle.Encoder) { + encoder.Write(r.Data[:]) + encoder.WriteUInt32(r.Counter) +} diff --git a/mautrix-patched/crypto/goolm/megolm/megolm_test.go b/mautrix-patched/crypto/goolm/megolm/megolm_test.go new file mode 100644 index 00000000..a6f7c1a7 --- /dev/null +++ b/mautrix-patched/crypto/goolm/megolm/megolm_test.go @@ -0,0 +1,105 @@ +package megolm_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/megolm" +) + +var startData [megolm.RatchetParts * megolm.RatchetPartLength]byte + +func init() { + startValue := []byte("0123456789ABCDEF0123456789ABCDEF") + copy(startData[:], startValue) + copy(startData[32:], startValue) + copy(startData[64:], startValue) + copy(startData[96:], startValue) +} + +func TestAdvance(t *testing.T) { + m, err := megolm.New(0, startData) + assert.NoError(t, err) + + expectedData := [megolm.RatchetParts * megolm.RatchetPartLength]byte{ + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0xba, 0x9c, 0xd9, 0x55, 0x74, 0x1d, 0x1c, 0x16, 0x23, 0x23, 0xec, 0x82, 0x5e, 0x7c, 0x5c, 0xe8, + 0x89, 0xbb, 0xb4, 0x23, 0xa1, 0x8f, 0x23, 0x82, 0x8f, 0xb2, 0x09, 0x0d, 0x6e, 0x2a, 0xf8, 0x6a, + } + m.Advance() + assert.Equal(t, m.Data[:], expectedData[:], "result after advancing the ratchet is not as expected") + + //repeat with complex advance + m.Data = startData + expectedData = [megolm.RatchetParts * megolm.RatchetPartLength]byte{ + 0x54, 0x02, 0x2d, 0x7d, 0xc0, 0x29, 0x8e, 0x16, 0x37, 0xe2, 0x1c, 0x97, 0x15, 0x30, 0x92, 0xf9, + 0x33, 0xc0, 0x56, 0xff, 0x74, 0xfe, 0x1b, 0x92, 0x2d, 0x97, 0x1f, 0x24, 0x82, 0xc2, 0x85, 0x9c, + 0x70, 0x04, 0xc0, 0x1e, 0xe4, 0x9b, 0xd6, 0xef, 0xe0, 0x07, 0x35, 0x25, 0xaf, 0x9b, 0x16, 0x32, + 0xc5, 0xbe, 0x72, 0x6d, 0x12, 0x34, 0x9c, 0xc5, 0xbd, 0x47, 0x2b, 0xdc, 0x2d, 0xf6, 0x54, 0x0f, + 0x31, 0x12, 0x59, 0x11, 0x94, 0xfd, 0xa6, 0x17, 0xe5, 0x68, 0xc6, 0x83, 0x10, 0x1e, 0xae, 0xcd, + 0x7e, 0xdd, 0xd6, 0xde, 0x1f, 0xbc, 0x07, 0x67, 0xae, 0x34, 0xda, 0x1a, 0x09, 0xa5, 0x4e, 0xab, + 0xba, 0x9c, 0xd9, 0x55, 0x74, 0x1d, 0x1c, 0x16, 0x23, 0x23, 0xec, 0x82, 0x5e, 0x7c, 0x5c, 0xe8, + 0x89, 0xbb, 0xb4, 0x23, 0xa1, 0x8f, 0x23, 0x82, 0x8f, 0xb2, 0x09, 0x0d, 0x6e, 0x2a, 0xf8, 0x6a, + } + m.AdvanceTo(0x1000000) + assert.Equal(t, m.Data[:], expectedData[:], "result after advancing the ratchet is not as expected") + + expectedData = [megolm.RatchetParts * megolm.RatchetPartLength]byte{ + 0x54, 0x02, 0x2d, 0x7d, 0xc0, 0x29, 0x8e, 0x16, 0x37, 0xe2, 0x1c, 0x97, 0x15, 0x30, 0x92, 0xf9, + 0x33, 0xc0, 0x56, 0xff, 0x74, 0xfe, 0x1b, 0x92, 0x2d, 0x97, 0x1f, 0x24, 0x82, 0xc2, 0x85, 0x9c, + 0x55, 0x58, 0x8d, 0xf5, 0xb7, 0xa4, 0x88, 0x78, 0x42, 0x89, 0x27, 0x86, 0x81, 0x64, 0x58, 0x9f, + 0x36, 0x63, 0x44, 0x7b, 0x51, 0xed, 0xc3, 0x59, 0x5b, 0x03, 0x6c, 0xa6, 0x04, 0xc4, 0x6d, 0xcd, + 0x5c, 0x54, 0x85, 0x0b, 0xfa, 0x98, 0xa1, 0xfd, 0x79, 0xa9, 0xdf, 0x1c, 0xbe, 0x8f, 0xc5, 0x68, + 0x19, 0x37, 0xd3, 0x0c, 0x85, 0xc8, 0xc3, 0x1f, 0x7b, 0xb8, 0x28, 0x81, 0x6c, 0xf9, 0xff, 0x3b, + 0x95, 0x6c, 0xbf, 0x80, 0x7e, 0x65, 0x12, 0x6a, 0x49, 0x55, 0x8d, 0x45, 0xc8, 0x4a, 0x2e, 0x4c, + 0xd5, 0x6f, 0x03, 0xe2, 0x44, 0x16, 0xb9, 0x8e, 0x1c, 0xfd, 0x97, 0xc2, 0x06, 0xaa, 0x90, 0x7a, + } + m.AdvanceTo(0x1041506) + assert.Equal(t, m.Data[:], expectedData[:], "result after advancing the ratchet is not as expected") +} + +func TestAdvanceWraparound(t *testing.T) { + m, err := megolm.New(0xffffffff, startData) + assert.NoError(t, err) + m.AdvanceTo(0x1000000) + assert.EqualValues(t, 0x1000000, m.Counter, "counter not correct") + + m2, err := megolm.New(0, startData) + assert.NoError(t, err) + m2.AdvanceTo(0x2000000) + assert.EqualValues(t, 0x2000000, m2.Counter, "counter not correct") + assert.Equal(t, m.Data, m2.Data, "result after wrapping the ratchet is not as expected") +} + +func TestAdvanceOverflowByOne(t *testing.T) { + m, err := megolm.New(0xffffffff, startData) + assert.NoError(t, err) + m.AdvanceTo(0x0) + assert.EqualValues(t, 0x0, m.Counter, "counter not correct") + + m2, err := megolm.New(0xffffffff, startData) + assert.NoError(t, err) + m2.Advance() + assert.EqualValues(t, 0x0, m2.Counter, "counter not correct") + assert.Equal(t, m.Data, m2.Data, "result after wrapping the ratchet is not as expected") +} + +func TestAdvanceOverflow(t *testing.T) { + m, err := megolm.New(0x1, startData) + assert.NoError(t, err) + m.AdvanceTo(0x80000000) + m.AdvanceTo(0x0) + assert.EqualValues(t, 0x0, m.Counter, "counter not correct") + + m2, err := megolm.New(0x1, startData) + assert.NoError(t, err) + m2.AdvanceTo(0x0) + assert.EqualValues(t, 0x0, m2.Counter, "counter not correct") + assert.Equal(t, m.Data, m2.Data, "result after wrapping the ratchet is not as expected") +} diff --git a/mautrix-patched/crypto/goolm/message/decoder.go b/mautrix-patched/crypto/goolm/message/decoder.go new file mode 100644 index 00000000..b06756a9 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/decoder.go @@ -0,0 +1,33 @@ +package message + +import ( + "bytes" + "encoding/binary" + "fmt" + + "maunium.net/go/mautrix/crypto/olm" +) + +type Decoder struct { + *bytes.Buffer +} + +func NewDecoder(buf []byte) *Decoder { + return &Decoder{bytes.NewBuffer(buf)} +} + +func (d *Decoder) ReadVarInt() (uint64, error) { + return binary.ReadUvarint(d) +} + +func (d *Decoder) ReadVarBytes() ([]byte, error) { + if n, err := d.ReadVarInt(); err != nil { + return nil, err + } else if n > uint64(d.Len()) { + return nil, fmt.Errorf("%w: var bytes length says %d, but only %d bytes left", olm.ErrInputToSmall, n, d.Available()) + } else { + out := make([]byte, n) + _, err = d.Read(out) + return out, err + } +} diff --git a/mautrix-patched/crypto/goolm/message/encoder.go b/mautrix-patched/crypto/goolm/message/encoder.go new file mode 100644 index 00000000..95ab6d41 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/encoder.go @@ -0,0 +1,24 @@ +package message + +import "encoding/binary" + +type Encoder struct { + buf []byte +} + +func (e *Encoder) Bytes() []byte { + return e.buf +} + +func (e *Encoder) PutByte(val byte) { + e.buf = append(e.buf, val) +} + +func (e *Encoder) PutVarInt(val uint64) { + e.buf = binary.AppendUvarint(e.buf, val) +} + +func (e *Encoder) PutVarBytes(data []byte) { + e.PutVarInt(uint64(len(data))) + e.buf = append(e.buf, data...) +} diff --git a/mautrix-patched/crypto/goolm/message/encoder_test.go b/mautrix-patched/crypto/goolm/message/encoder_test.go new file mode 100644 index 00000000..1fe2ebdb --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/encoder_test.go @@ -0,0 +1,59 @@ +package message_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/message" +) + +func TestEncodeInt(t *testing.T) { + var ints []uint32 + var expected [][]byte + ints = append(ints, 7) + expected = append(expected, []byte{0b00000111}) + ints = append(ints, 127) + expected = append(expected, []byte{0b01111111}) + ints = append(ints, 128) + expected = append(expected, []byte{0b10000000, 0b00000001}) + ints = append(ints, 16383) + expected = append(expected, []byte{0b11111111, 0b01111111}) + for curIndex := range ints { + var encoder message.Encoder + encoder.PutVarInt(uint64(ints[curIndex])) + assert.Equal(t, expected[curIndex], encoder.Bytes()) + } +} + +func TestEncodeString(t *testing.T) { + var strings [][]byte + var expected [][]byte + curTest := []byte("test") + strings = append(strings, curTest) + res := []byte{ + 0b00000100, //varint length of string + } + res = append(res, curTest...) //Add string itself + expected = append(expected, res) + curTest = []byte("this is a long message with a length of 127 so that the varint of the length is just one byte. just needs some padding---------") + strings = append(strings, curTest) + res = []byte{ + 0b01111111, //varint length of string + } + res = append(res, curTest...) //Add string itself + expected = append(expected, res) + curTest = []byte("this is an even longer message with a length between 128 and 16383 so that the varint of the length needs two byte. just needs some padding again ---------") + strings = append(strings, curTest) + res = []byte{ + 0b10011011, //varint length of string + 0b00000001, //varint length of string + } + res = append(res, curTest...) //Add string itself + expected = append(expected, res) + for curIndex := range strings { + var encoder message.Encoder + encoder.PutVarBytes(strings[curIndex]) + assert.Equal(t, expected[curIndex], encoder.Bytes()) + } +} diff --git a/mautrix-patched/crypto/goolm/message/group_message.go b/mautrix-patched/crypto/goolm/message/group_message.go new file mode 100644 index 00000000..48e7329f --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/group_message.go @@ -0,0 +1,109 @@ +package message + +import ( + "fmt" + "io" + "math" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + messageIndexTag = 0x08 + cipherTextTag = 0x12 + countMACBytesGroupMessage = 8 +) + +// GroupMessage represents a message in the group message format. +type GroupMessage struct { + Version byte `json:"version"` + MessageIndex uint32 `json:"index"` + Ciphertext []byte `json:"ciphertext"` + HasMessageIndex bool `json:"has_index"` +} + +// Decodes decodes the input and populates the corresponding fileds. MAC and signature are ignored but have to be present. +func (r *GroupMessage) Decode(input []byte) (err error) { + r.Version = 0 + r.MessageIndex = 0 + r.Ciphertext = nil + if len(input) < countMACBytesGroupMessage+crypto.Ed25519SignatureSize { + return fmt.Errorf("%w (%d bytes)", olm.ErrInputToSmall, len(input)) + } + + decoder := NewDecoder(input[:len(input)-countMACBytesGroupMessage-crypto.Ed25519SignatureSize]) + r.Version, err = decoder.ReadByte() // First byte is the version + if err != nil { + return + } + if r.Version != protocolVersion { + return fmt.Errorf("GroupMessage.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) + } + + for { + // Read Key + if curKey, err := decoder.ReadVarInt(); err != nil { + if err == io.EOF { + // No more keys to read + return nil + } + return err + } else if (curKey & 0b111) == 0 { + // The value is of type varint + if value, err := decoder.ReadVarInt(); err != nil { + return err + } else if curKey == messageIndexTag { + if value > math.MaxUint32 { + return fmt.Errorf("GroupMessage.Decode: message index %d exceeds uint32 limit", value) + } + r.MessageIndex = uint32(value) + r.HasMessageIndex = true + } + } else if (curKey & 0b111) == 2 { + // The value is of type string + if value, err := decoder.ReadVarBytes(); err != nil { + return err + } else if curKey == cipherTextTag { + r.Ciphertext = value + } + } else { + return fmt.Errorf("GroupMessage.Decode: unexpected proto key %d", curKey) + } + } +} + +// EncodeAndMACAndSign encodes the message, creates the mac with the key and the cipher and signs the message. +// If macKey or cipher is nil, no mac is appended. If signKey is nil, no signature is appended. +func (r *GroupMessage) EncodeAndMACAndSign(cipher aessha2.AESSHA2, signKey crypto.Ed25519KeyPair) ([]byte, error) { + var encoder Encoder + encoder.PutByte(r.Version) + encoder.PutVarInt(messageIndexTag) + encoder.PutVarInt(uint64(r.MessageIndex)) + encoder.PutVarInt(cipherTextTag) + encoder.PutVarBytes(r.Ciphertext) + mac, err := cipher.MAC(encoder.Bytes()) + if err != nil { + return nil, err + } + ciphertextWithMAC := append(encoder.Bytes(), mac[:countMACBytesGroupMessage]...) + signature, err := signKey.Sign(ciphertextWithMAC) + return append(ciphertextWithMAC, signature...), err +} + +// VerifySignature verifies the signature taken from the message to the calculated signature of the message. +func (r *GroupMessage) VerifySignatureInline(key crypto.Ed25519PublicKey, message []byte) bool { + signature := message[len(message)-crypto.Ed25519SignatureSize:] + message = message[:len(message)-crypto.Ed25519SignatureSize] + return key.Verify(message, signature) +} + +// VerifyMACInline verifies the MAC taken from the message to the calculated MAC of the message. +func (r *GroupMessage) VerifyMACInline(cipher aessha2.AESSHA2, message []byte) (bool, error) { + startMAC := len(message) - countMACBytesGroupMessage - crypto.Ed25519SignatureSize + endMAC := startMAC + countMACBytesGroupMessage + suplMac := message[startMAC:endMAC] + message = message[:startMAC] + return cipher.VerifyMAC(message, suplMac, countMACBytesMessage) +} diff --git a/mautrix-patched/crypto/goolm/message/group_message_test.go b/mautrix-patched/crypto/goolm/message/group_message_test.go new file mode 100644 index 00000000..272138c4 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/group_message_test.go @@ -0,0 +1,62 @@ +package message_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/message" +) + +func TestGroupMessageDecode(t *testing.T) { + messageRaw := []byte("\x03\x08\xC8\x01\x12\x0aciphertexthmacsha2") + signature := []byte("signature1234567891234567890123412345678912345678912345678901234") + messageRaw = append(messageRaw, signature...) + expectedMessageIndex := uint32(200) + expectedCipherText := []byte("ciphertext") + + msg := message.GroupMessage{} + err := msg.Decode(messageRaw) + assert.NoError(t, err) + assert.EqualValues(t, 3, msg.Version) + assert.Equal(t, expectedMessageIndex, msg.MessageIndex) + assert.Equal(t, expectedCipherText, msg.Ciphertext) +} + +func TestGroupMessageEncode(t *testing.T) { + hmacsha256 := []byte("hmacsha2") + sign := []byte("signature") + msg := message.GroupMessage{ + Version: 3, + MessageIndex: 200, + Ciphertext: []byte("ciphertext"), + } + + cipher, err := aessha2.NewAESSHA2(nil, nil) + require.NoError(t, err) + encoded, err := msg.EncodeAndMACAndSign(cipher, crypto.Ed25519GenerateFromSeed(make([]byte, 32))) + assert.NoError(t, err) + encoded = append(encoded, hmacsha256...) + encoded = append(encoded, sign...) + expected := []byte{ + 0x03, // Version + 0x08, + 0xC8, // 200 + 0x01, + 0x12, + 0x0a, + } + expected = append(expected, []byte("ciphertext")...) + expected = append(expected, []byte{ + 0x6f, 0x95, 0x35, 0x51, 0xdc, 0xdb, 0xcb, 0x03, 0x0b, 0x22, 0xa2, 0xa7, 0xa1, 0xb7, 0x4f, 0x1a, + 0xa3, 0xe9, 0x5c, 0x05, 0x5d, 0x56, 0xdc, 0x5b, 0x87, 0x73, 0x05, 0x42, 0x2a, 0x59, 0x9a, 0x9a, + 0x26, 0x7a, 0x8d, 0xba, 0x65, 0xb2, 0x17, 0x65, 0x51, 0x6f, 0x37, 0xf3, 0x8f, 0xa1, 0x70, 0xd0, + 0xc4, 0x06, 0x05, 0xdc, 0x17, 0x71, 0x5e, 0x63, 0x84, 0xbe, 0xec, 0x7b, 0xa0, 0xc4, 0x08, 0xb8, + 0x9b, 0xc5, 0x08, 0x16, 0xad, 0xe5, 0x43, 0x0c, + }...) + expected = append(expected, []byte("hmacsha2signature")...) + assert.Equal(t, expected, encoded) +} diff --git a/mautrix-patched/crypto/goolm/message/message.go b/mautrix-patched/crypto/goolm/message/message.go new file mode 100644 index 00000000..de2a93a4 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/message.go @@ -0,0 +1,103 @@ +package message + +import ( + "fmt" + "io" + "math" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + ratchetKeyTag = 0x0A + counterTag = 0x10 + cipherTextKeyTag = 0x22 + countMACBytesMessage = 8 +) + +// GroupMessage represents a message in the message format. +type Message struct { + Version byte `json:"version"` + HasCounter bool `json:"has_counter"` + Counter uint32 `json:"counter"` + RatchetKey crypto.Curve25519PublicKey `json:"ratchet_key"` + Ciphertext []byte `json:"ciphertext"` +} + +// Decodes decodes the input and populates the corresponding fileds. MAC is ignored but has to be present. +func (r *Message) Decode(input []byte) (err error) { + r.Version = 0 + r.HasCounter = false + r.Counter = 0 + r.RatchetKey = nil + r.Ciphertext = nil + if len(input) < countMACBytesMessage { + return fmt.Errorf("%w (%d bytes)", olm.ErrInputToSmall, len(input)) + } + + decoder := NewDecoder(input[:len(input)-countMACBytesMessage]) + r.Version, err = decoder.ReadByte() // first byte is always version + if err != nil { + return + } + if r.Version != protocolVersion { + return fmt.Errorf("Message.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) + } + + for { + // Read Key + if curKey, err := decoder.ReadVarInt(); err != nil { + if err == io.EOF { + // No more keys to read + return nil + } + return err + } else if (curKey & 0b111) == 0 { + // The value is of type varint + if value, err := decoder.ReadVarInt(); err != nil { + return err + } else if curKey == counterTag { + // TODO add support for 64-bit counters like vodozemac + if value > math.MaxUint32 { + return fmt.Errorf("Message.Decode: counter value %d exceeds uint32 limit", value) + } + r.Counter = uint32(value) + r.HasCounter = true + } + } else if (curKey & 0b111) == 2 { + // The value is of type string + if value, err := decoder.ReadVarBytes(); err != nil { + return err + } else if curKey == ratchetKeyTag { + r.RatchetKey = value + } else if curKey == cipherTextKeyTag { + r.Ciphertext = value + } + } else { + return fmt.Errorf("Message.Decode: unexpected proto key %d", curKey) + } + } +} + +// EncodeAndMAC encodes the message and creates the MAC with the key and the cipher. +// If key or cipher is nil, no MAC is appended. +func (r *Message) EncodeAndMAC(cipher aessha2.AESSHA2) ([]byte, error) { + var encoder Encoder + encoder.PutByte(r.Version) + encoder.PutVarInt(ratchetKeyTag) + encoder.PutVarBytes(r.RatchetKey) + encoder.PutVarInt(counterTag) + encoder.PutVarInt(uint64(r.Counter)) + encoder.PutVarInt(cipherTextKeyTag) + encoder.PutVarBytes(r.Ciphertext) + mac, err := cipher.MAC(encoder.Bytes()) + return append(encoder.Bytes(), mac[:countMACBytesMessage]...), err +} + +// VerifyMACInline verifies the MAC taken from the message to the calculated MAC of the message. +func (r *Message) VerifyMACInline(cipher aessha2.AESSHA2, message []byte) (bool, error) { + givenMAC := message[len(message)-countMACBytesMessage:] + return cipher.VerifyMAC(message[:len(message)-countMACBytesMessage], givenMAC, countMACBytesMessage) +} diff --git a/mautrix-patched/crypto/goolm/message/message_test.go b/mautrix-patched/crypto/goolm/message/message_test.go new file mode 100644 index 00000000..f3aa7108 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/message_test.go @@ -0,0 +1,42 @@ +package message_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/message" +) + +func TestMessageDecode(t *testing.T) { + messageRaw := []byte("\x03\x10\x01\n\nratchetkey\"\nciphertexthmacsha2") + expectedRatchetKey := []byte("ratchetkey") + expectedCipherText := []byte("ciphertext") + + msg := message.Message{} + err := msg.Decode(messageRaw) + assert.NoError(t, err) + assert.EqualValues(t, 3, msg.Version) + assert.True(t, msg.HasCounter) + assert.EqualValues(t, 1, msg.Counter) + assert.Equal(t, expectedCipherText, msg.Ciphertext) + assert.EqualValues(t, expectedRatchetKey, msg.RatchetKey) +} + +func TestMessageEncode(t *testing.T) { + expectedRaw := []byte("\x03\n\nratchetkey\x10\x01\"\nciphertext\x95\x95\x92\x72\x04\x70\x56\xcdhmacsha2") + hmacsha256 := []byte("hmacsha2") + msg := message.Message{ + Version: 3, + Counter: 1, + RatchetKey: []byte("ratchetkey"), + Ciphertext: []byte("ciphertext"), + } + cipher, err := aessha2.NewAESSHA2(nil, nil) + assert.NoError(t, err) + encoded, err := msg.EncodeAndMAC(cipher) + assert.NoError(t, err) + encoded = append(encoded, hmacsha256...) + assert.Equal(t, expectedRaw, encoded) +} diff --git a/mautrix-patched/crypto/goolm/message/prekey_message.go b/mautrix-patched/crypto/goolm/message/prekey_message.go new file mode 100644 index 00000000..97d9d43d --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/prekey_message.go @@ -0,0 +1,114 @@ +package message + +import ( + "fmt" + "io" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + oneTimeKeyIDTag = 0x0A + baseKeyTag = 0x12 + identityKeyTag = 0x1A + messageTag = 0x22 +) + +type PreKeyMessage struct { + Version byte `json:"version"` + IdentityKey crypto.Curve25519PublicKey `json:"id_key"` + BaseKey crypto.Curve25519PublicKey `json:"base_key"` + OneTimeKey crypto.Curve25519PublicKey `json:"one_time_key"` + Message []byte `json:"message"` +} + +// TODO deduplicate constant with one in session/olm_session.go +const ( + protocolVersion = 0x3 +) + +// Decodes decodes the input and populates the corresponding fileds. +func (r *PreKeyMessage) Decode(input []byte) (err error) { + r.Version = 0 + r.IdentityKey = nil + r.BaseKey = nil + r.OneTimeKey = nil + r.Message = nil + if len(input) == 0 { + return olm.ErrInputToSmall + } + + decoder := NewDecoder(input) + r.Version, err = decoder.ReadByte() // first byte is always version + if err != nil { + if err == io.EOF { + return olm.ErrInputToSmall + } + return + } + if r.Version != protocolVersion { + return fmt.Errorf("PreKeyMessage.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) + } + + for { + // Read Key + if curKey, err := decoder.ReadVarInt(); err != nil { + if err == io.EOF { + return nil + } + return err + } else if (curKey & 0b111) == 0 { + // The value is of type varint + if _, err = decoder.ReadVarInt(); err != nil { + if err == io.EOF { + return olm.ErrInputToSmall + } + return err + } + } else if (curKey & 0b111) == 2 { + // The value is of type string + if value, err := decoder.ReadVarBytes(); err != nil { + if err == io.EOF { + return olm.ErrInputToSmall + } + return err + } else { + switch curKey { + case oneTimeKeyIDTag: + r.OneTimeKey = value + case baseKeyTag: + r.BaseKey = value + case identityKeyTag: + r.IdentityKey = value + case messageTag: + r.Message = value + } + } + } else { + return fmt.Errorf("PreKeyMessage.Decode: unexpected proto key %d", curKey) + } + } +} + +func (r *PreKeyMessage) CheckFields() bool { + return len(r.IdentityKey) == crypto.Curve25519PrivateKeyLength && + len(r.Message) != 0 && + len(r.BaseKey) == crypto.Curve25519PrivateKeyLength && + len(r.OneTimeKey) == crypto.Curve25519PrivateKeyLength +} + +// Encode encodes the message. +func (r *PreKeyMessage) Encode() ([]byte, error) { + var encoder Encoder + encoder.PutByte(r.Version) + encoder.PutVarInt(oneTimeKeyIDTag) + encoder.PutVarBytes(r.OneTimeKey) + encoder.PutVarInt(identityKeyTag) + encoder.PutVarBytes(r.IdentityKey) + encoder.PutVarInt(baseKeyTag) + encoder.PutVarBytes(r.BaseKey) + encoder.PutVarInt(messageTag) + encoder.PutVarBytes(r.Message) + return encoder.Bytes(), nil +} diff --git a/mautrix-patched/crypto/goolm/message/prekey_message_test.go b/mautrix-patched/crypto/goolm/message/prekey_message_test.go new file mode 100644 index 00000000..fb620346 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/prekey_message_test.go @@ -0,0 +1,43 @@ +package message_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/message" +) + +func TestPreKeyMessageDecode(t *testing.T) { + //Keys are 32 bytes to pass field check + //Added a tag for an integer of 0 just for checkes + messageRaw := []byte("\x03\x0a\x20onetimeKey.-.-.-.-.-.-.-.-.-.-.-\x1a\x20idKeywithlendth32bytes-.-.-.-.-.\x12\x20baseKey-.-.-.-.-.-.-.-.-.-.-.-.-\x22\x07message\x00\x00") + expectedOneTimeKey := []byte("onetimeKey.-.-.-.-.-.-.-.-.-.-.-") + expectedIdKey := []byte("idKeywithlendth32bytes-.-.-.-.-.") + expectedbaseKey := []byte("baseKey-.-.-.-.-.-.-.-.-.-.-.-.-") + expectedmessage := []byte("message") + + msg := message.PreKeyMessage{} + err := msg.Decode(messageRaw) + assert.NoError(t, err) + assert.EqualValues(t, 3, msg.Version) + assert.EqualValues(t, expectedOneTimeKey, msg.OneTimeKey) + assert.EqualValues(t, expectedIdKey, msg.IdentityKey) + assert.EqualValues(t, expectedbaseKey, msg.BaseKey) + assert.Equal(t, expectedmessage, msg.Message) + assert.True(t, msg.CheckFields(), "field check failed") +} + +func TestPreKeyMessageEncode(t *testing.T) { + expectedRaw := []byte("\x03\x0a\x0aonetimeKey\x1a\x05idKey\x12\x07baseKey\x22\x07message") + msg := message.PreKeyMessage{ + Version: 3, + IdentityKey: []byte("idKey"), + BaseKey: []byte("baseKey"), + OneTimeKey: []byte("onetimeKey"), + Message: []byte("message"), + } + encoded, err := msg.Encode() + assert.NoError(t, err) + assert.Equal(t, expectedRaw, encoded) +} diff --git a/mautrix-patched/crypto/goolm/message/session_export.go b/mautrix-patched/crypto/goolm/message/session_export.go new file mode 100644 index 00000000..188974e8 --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/session_export.go @@ -0,0 +1,47 @@ +package message + +import ( + "encoding/binary" + "fmt" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + sessionExportVersion = 0x01 +) + +// MegolmSessionExport represents a message in the session export format. +type MegolmSessionExport struct { + Counter uint32 `json:"counter"` + RatchetData [128]byte `json:"data"` + PublicKey crypto.Ed25519PublicKey `json:"public_key"` +} + +// Encode returns the encoded message in the correct format. +func (s MegolmSessionExport) Encode() []byte { + output := make([]byte, 165) + output[0] = sessionExportVersion + binary.BigEndian.PutUint32(output[1:], s.Counter) + copy(output[5:], s.RatchetData[:]) + copy(output[133:], s.PublicKey) + return output +} + +// Decode populates the struct with the data encoded in input. +func (s *MegolmSessionExport) Decode(input []byte) error { + if len(input) != 165 { + return fmt.Errorf("decrypt: %w", olm.ErrBadInput) + } + if input[0] != sessionExportVersion { + return fmt.Errorf("decrypt: %w", olm.ErrUnknownOlmPickleVersion) + } + s.Counter = binary.BigEndian.Uint32(input[1:5]) + copy(s.RatchetData[:], input[5:133]) + s.PublicKey = input[133:] + if !s.PublicKey.IsValidKey() { + return fmt.Errorf("MegolmSessionExport.Decode: invalid Ed25519 public key") + } + return nil +} diff --git a/mautrix-patched/crypto/goolm/message/session_sharing.go b/mautrix-patched/crypto/goolm/message/session_sharing.go new file mode 100644 index 00000000..d04ef15a --- /dev/null +++ b/mautrix-patched/crypto/goolm/message/session_sharing.go @@ -0,0 +1,50 @@ +package message + +import ( + "encoding/binary" + "fmt" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + sessionSharingVersion = 0x02 +) + +// MegolmSessionSharing represents a message in the session sharing format. +type MegolmSessionSharing struct { + Counter uint32 `json:"counter"` + RatchetData [128]byte `json:"data"` + PublicKey crypto.Ed25519PublicKey `json:"-"` //only used when decrypting messages +} + +// Encode returns the encoded message in the correct format with the signature by key appended. +func (s MegolmSessionSharing) EncodeAndSign(key crypto.Ed25519KeyPair) ([]byte, error) { + output := make([]byte, 229) + output[0] = sessionSharingVersion + binary.BigEndian.PutUint32(output[1:], s.Counter) + copy(output[5:], s.RatchetData[:]) + copy(output[133:], key.PublicKey) + signature, err := key.Sign(output[:165]) + copy(output[165:], signature) + return output, err +} + +// VerifyAndDecode verifies the input and populates the struct with the data encoded in input. +func (s *MegolmSessionSharing) VerifyAndDecode(input []byte) error { + if len(input) != 229 { + return fmt.Errorf("verify: %w", olm.ErrBadInput) + } + publicKey := crypto.Ed25519PublicKey(input[133:165]) + if !publicKey.Verify(input[:165], input[165:]) { + return fmt.Errorf("verify: %w", olm.ErrBadVerification) + } + s.PublicKey = publicKey + if input[0] != sessionSharingVersion { + return fmt.Errorf("verify: %w", olm.ErrUnknownOlmPickleVersion) + } + s.Counter = binary.BigEndian.Uint32(input[1:5]) + copy(s.RatchetData[:], input[5:133]) + return nil +} diff --git a/mautrix-patched/crypto/goolm/pk/pk_test.go b/mautrix-patched/crypto/goolm/pk/pk_test.go new file mode 100644 index 00000000..d4d81e69 --- /dev/null +++ b/mautrix-patched/crypto/goolm/pk/pk_test.go @@ -0,0 +1,54 @@ +package pk_test + +import ( + "crypto/ed25519" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/pk" +) + +func TestSigning(t *testing.T) { + seed := []byte{ + 0x77, 0x07, 0x6D, 0x0A, 0x73, 0x18, 0xA5, 0x7D, + 0x3C, 0x16, 0xC1, 0x72, 0x51, 0xB2, 0x66, 0x45, + 0xDF, 0x4C, 0x2F, 0x87, 0xEB, 0xC0, 0x99, 0x2A, + 0xB1, 0x77, 0xFB, 0xA5, 0x1D, 0xB9, 0x2C, 0x2A, + } + message := []byte("We hold these truths to be self-evident, that all men are created equal, that they are endowed by their Creator with certain unalienable Rights, that among these are Life, Liberty and the pursuit of Happiness.") + signing, _ := pk.NewSigningFromSeed(seed) + signature, err := signing.Sign(message) + assert.NoError(t, err) + signatureDecoded, err := base64.RawStdEncoding.DecodeString(string(signature)) + assert.NoError(t, err) + pubKeyEncoded := signing.PublicKey() + pubKeyDecoded, err := base64.RawStdEncoding.DecodeString(string(pubKeyEncoded)) + assert.NoError(t, err) + pubKey := crypto.Ed25519PublicKey(pubKeyDecoded) + + verified := pubKey.Verify(message, signatureDecoded) + assert.True(t, verified, "signature did not verify") + + copy(signatureDecoded[0:], []byte("m")) + verified = pubKey.Verify(message, signatureDecoded) + assert.False(t, verified, "signature verified with wrong message") +} + +func TestStrictVerifyRejectsSmallOrderKey(t *testing.T) { + message := []byte("test message for small-order key check") + + pubKey := make(crypto.Ed25519PublicKey, 32) + pubKey[0] = 0x01 + + signature := make([]byte, 64) + signature[0] = 0x01 + + assert.True(t, ed25519.Verify(ed25519.PublicKey(pubKey), message, signature), + "standard library accepts small-order keys") + + assert.False(t, pubKey.Verify(message, signature), + "strict verify rejects small-order keys") +} diff --git a/mautrix-patched/crypto/goolm/pk/register.go b/mautrix-patched/crypto/goolm/pk/register.go new file mode 100644 index 00000000..cd4ce9e8 --- /dev/null +++ b/mautrix-patched/crypto/goolm/pk/register.go @@ -0,0 +1,18 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pk + +import "maunium.net/go/mautrix/crypto/olm" + +func Register() { + olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { + return NewSigningFromSeed(seed) + } + olm.InitNewPKSigning = func() (olm.PKSigning, error) { + return NewSigning() + } +} diff --git a/mautrix-patched/crypto/goolm/pk/signing.go b/mautrix-patched/crypto/goolm/pk/signing.go new file mode 100644 index 00000000..8841548e --- /dev/null +++ b/mautrix-patched/crypto/goolm/pk/signing.go @@ -0,0 +1,71 @@ +package pk + +import ( + "crypto/rand" + "fmt" + + "github.com/tidwall/sjson" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/goolmbase64" + "maunium.net/go/mautrix/id" +) + +// Signing is used for signing a pk +type Signing struct { + keyPair crypto.Ed25519KeyPair + seed []byte +} + +// NewSigningFromSeed constructs a new Signing based on a seed. +func NewSigningFromSeed(seed []byte) (*Signing, error) { + s := &Signing{} + s.seed = seed + s.keyPair = crypto.Ed25519GenerateFromSeed(seed) + return s, nil +} + +// NewSigning returns a Signing based on a random seed +func NewSigning() (*Signing, error) { + seed := make([]byte, 32) + _, err := rand.Read(seed) + if err != nil { + return nil, err + } + return NewSigningFromSeed(seed) +} + +// Seed returns the seed of the key pair. +func (s Signing) Seed() []byte { + return s.seed +} + +// PublicKey returns the public key of the key pair base 64 encoded. +func (s Signing) PublicKey() id.Ed25519 { + return s.keyPair.B64Encoded() +} + +// Sign returns the signature of the message base64 encoded. +func (s Signing) Sign(message []byte) ([]byte, error) { + signature, err := s.keyPair.Sign(message) + return goolmbase64.Encode(signature), err +} + +// SignJSON creates a signature for the given object after encoding it to +// canonical JSON. +func (s Signing) SignJSON(obj any) (string, error) { + objJSON, err := canonicaljson.Marshal(obj) + if err != nil { + return "", err + } + objJSON, _ = sjson.DeleteBytes(objJSON, "unsigned") + objJSON, _ = sjson.DeleteBytes(objJSON, "signatures") + // This is probably not necessary + err = canonicaljson.Canonicalize(&objJSON) + if err != nil { + return "", fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) + } + signature, err := s.Sign(objJSON) + return string(signature), err +} diff --git a/mautrix-patched/crypto/goolm/ratchet/chain.go b/mautrix-patched/crypto/goolm/ratchet/chain.go new file mode 100644 index 00000000..3089e19e --- /dev/null +++ b/mautrix-patched/crypto/goolm/ratchet/chain.go @@ -0,0 +1,178 @@ +package ratchet + +import ( + "crypto/hmac" + "crypto/sha256" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +const ( + chainKeySeed = 0x02 + messageKeyLength = 32 +) + +// chainKey wraps the index and the public key +type chainKey struct { + Index uint32 `json:"index"` + Key crypto.Curve25519PublicKey `json:"key"` +} + +// advance advances the chain +func (c *chainKey) advance() { + hash := hmac.New(sha256.New, c.Key) + hash.Write([]byte{chainKeySeed}) + c.Key = hash.Sum(nil) + c.Index++ +} + +// UnpickleLibOlm unpickles the unencryted value and populates the chain key accordingly. +func (r *chainKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + err := r.Key.UnpickleLibOlm(decoder) + if err != nil { + return err + } + r.Index, err = decoder.ReadUInt32() + return err +} + +// PickleLibOlm pickles the chain key into the encoder. +func (r chainKey) PickleLibOlm(encoder *libolmpickle.Encoder) { + r.Key.PickleLibOlm(encoder) + encoder.WriteUInt32(r.Index) +} + +func (r chainKey) createMessageKeys() messageKey { + hash := hmac.New(sha256.New, r.Key) + hash.Write([]byte{messageKeySeed}) + return messageKey{ + Key: hash.Sum(nil), + Index: r.Index, + } +} + +// senderChain is a chain for sending messages +type senderChain struct { + RKey crypto.Curve25519KeyPair `json:"ratchet_key"` + CKey chainKey `json:"chain_key"` + IsSet bool `json:"set"` +} + +// newSenderChain returns a sender chain initialized with chainKey and ratchet key pair. +func newSenderChain(key crypto.Curve25519PublicKey, ratchet crypto.Curve25519KeyPair) *senderChain { + return &senderChain{ + RKey: ratchet, + CKey: chainKey{ + Index: 0, + Key: key, + }, + IsSet: true, + } +} + +// advance advances the chain +func (s *senderChain) advance() { + s.CKey.advance() +} + +// ratchetKey returns the ratchet key pair. +func (s senderChain) ratchetKey() crypto.Curve25519KeyPair { + return s.RKey +} + +// chainKey returns the current chainKey. +func (s senderChain) chainKey() chainKey { + return s.CKey +} + +// UnpickleLibOlm unpickles the unencryted value and populates the sender chain +// accordingly. +func (r *senderChain) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + if err := r.RKey.UnpickleLibOlm(decoder); err != nil { + return err + } + return r.CKey.UnpickleLibOlm(decoder) +} + +// PickleLibOlm pickles the sender chain into the encoder. +func (r senderChain) PickleLibOlm(encoder *libolmpickle.Encoder) { + if r.IsSet { + encoder.WriteUInt32(1) // Length of the sender chain (1 if set) + r.RKey.PickleLibOlm(encoder) + r.CKey.PickleLibOlm(encoder) + } else { + encoder.WriteUInt32(0) + } +} + +// senderChain is a chain for receiving messages +type receiverChain struct { + RKey crypto.Curve25519PublicKey `json:"ratchet_key"` + CKey chainKey `json:"chain_key"` +} + +// newReceiverChain returns a receiver chain initialized with chainKey and ratchet public key. +func newReceiverChain(chain crypto.Curve25519PublicKey, ratchet crypto.Curve25519PublicKey) *receiverChain { + return &receiverChain{ + RKey: ratchet, + CKey: chainKey{ + Index: 0, + Key: chain, + }, + } +} + +// advance advances the chain +func (s *receiverChain) advance() { + s.CKey.advance() +} + +// ratchetKey returns the ratchet public key. +func (s receiverChain) ratchetKey() crypto.Curve25519PublicKey { + return s.RKey +} + +// chainKey returns the current chainKey. +func (s receiverChain) chainKey() chainKey { + return s.CKey +} + +// UnpickleLibOlm unpickles the unencryted value and populates the chain accordingly. +func (r *receiverChain) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { + if err := r.RKey.UnpickleLibOlm(decoder); err != nil { + return err + } + return r.CKey.UnpickleLibOlm(decoder) +} + +// PickleLibOlm pickles the receiver chain into the encoder. +func (r receiverChain) PickleLibOlm(encoder *libolmpickle.Encoder) { + r.RKey.PickleLibOlm(encoder) + r.CKey.PickleLibOlm(encoder) +} + +// messageKey wraps the index and the key of a message +type messageKey struct { + Index uint32 `json:"index"` + Key []byte `json:"key"` +} + +// UnpickleLibOlm unpickles the unencryted value and populates the message key +// accordingly. +func (m *messageKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) (err error) { + if m.Key, err = decoder.ReadBytes(messageKeyLength); err != nil { + return + } + m.Index, err = decoder.ReadUInt32() + return +} + +// PickleLibOlm pickles the message key into the encoder. +func (m messageKey) PickleLibOlm(encoder *libolmpickle.Encoder) { + if len(m.Key) != messageKeyLength { + panic("messageKey.PickleLibOlm: key length is not 32 bytes") + } + encoder.Write(m.Key) + encoder.WriteUInt32(m.Index) +} diff --git a/mautrix-patched/crypto/goolm/ratchet/olm.go b/mautrix-patched/crypto/goolm/ratchet/olm.go new file mode 100644 index 00000000..72b914c9 --- /dev/null +++ b/mautrix-patched/crypto/goolm/ratchet/olm.go @@ -0,0 +1,393 @@ +// Package ratchet provides the ratchet used by the olm protocol +package ratchet + +import ( + "crypto/sha256" + "fmt" + "io" + "slices" + + "golang.org/x/crypto/hkdf" + + "maunium.net/go/mautrix/crypto/goolm/aessha2" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/goolm/message" + "maunium.net/go/mautrix/crypto/olm" +) + +const ( + maxReceiverChains = 5 + maxSkippedMessageKeys = 40 + protocolVersion = 3 + messageKeySeed = 0x01 + + maxMessageGap = 2000 + sharedKeyLength = 32 +) + +var olmKeysKDFInfo = []byte("OLM_KEYS") + +// KdfInfo has the infos used for the kdf +var KdfInfo = struct { + Root []byte + Ratchet []byte +}{ + Root: []byte("OLM_ROOT"), + Ratchet: []byte("OLM_RATCHET"), +} + +// Ratchet represents the olm ratchet as described in +// +// https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/olm.md +type Ratchet struct { + // The root key is used to generate chain keys from the ephemeral keys. + // A new root_key is derived each time a new chain is started. + RootKey crypto.Curve25519PublicKey `json:"root_key"` + + // The sender chain is used to send messages. Each time a new ephemeral + // key is received from the remote server we generate a new sender chain + // with a new ephemeral key when we next send a message. + SenderChains senderChain `json:"sender_chain"` + + // The receiver chain is used to decrypt received messages. We store the + // last few chains so we can decrypt any out of order messages we haven't + // received yet. + // New chains are prepended for easier access. + ReceiverChains []receiverChain `json:"receiver_chains"` + + // Storing the keys of missed messages for future use. + // The order of the elements is not important. + // TODO move this inside receiver chains to match vodozemac + // Due to being global, this is currently capped at a total of 40, instead of 5x40 like vodozemac + SkippedMessageKeys []skippedMessageKey `json:"skipped_message_keys"` +} + +// New creates a new ratchet, setting the kdfInfos and cipher. +func New() *Ratchet { + return &Ratchet{} +} + +// InitializeAsBob initializes this ratchet from a receiving point of view (only first message). +func (r *Ratchet) InitializeAsBob(sharedSecret []byte, theirRatchetKey crypto.Curve25519PublicKey) error { + derivedSecretsReader := hkdf.New(sha256.New, sharedSecret, nil, KdfInfo.Root) + derivedSecrets := make([]byte, 2*sharedKeyLength) + if _, err := io.ReadFull(derivedSecretsReader, derivedSecrets); err != nil { + return err + } + r.RootKey = derivedSecrets[0:sharedKeyLength] + newReceiverChain := newReceiverChain(derivedSecrets[sharedKeyLength:], theirRatchetKey) + r.ReceiverChains = append([]receiverChain{*newReceiverChain}, r.ReceiverChains...) + if len(r.ReceiverChains) > maxReceiverChains { + r.ReceiverChains = r.ReceiverChains[:maxReceiverChains] + } + return nil +} + +// InitializeAsAlice initializes this ratchet from a sending point of view (only first message). +func (r *Ratchet) InitializeAsAlice(sharedSecret []byte, ourRatchetKey crypto.Curve25519KeyPair) error { + derivedSecretsReader := hkdf.New(sha256.New, sharedSecret, nil, KdfInfo.Root) + derivedSecrets := make([]byte, 2*sharedKeyLength) + if _, err := io.ReadFull(derivedSecretsReader, derivedSecrets); err != nil { + return err + } + r.RootKey = derivedSecrets[0:sharedKeyLength] + newSenderChain := newSenderChain(derivedSecrets[sharedKeyLength:], ourRatchetKey) + r.SenderChains = *newSenderChain + return nil +} + +// Encrypt encrypts the message in a message.Message with MAC. +func (r *Ratchet) Encrypt(plaintext []byte) ([]byte, error) { + var err error + if !r.SenderChains.IsSet { + newRatchetKey, err := crypto.Curve25519GenerateKey() + if err != nil { + return nil, err + } + newChainKey, err := r.advanceRootKey(newRatchetKey, r.ReceiverChains[0].ratchetKey()) + if err != nil { + return nil, err + } + newSenderChain := newSenderChain(newChainKey, newRatchetKey) + r.SenderChains = *newSenderChain + } + + messageKey := r.SenderChains.chainKey().createMessageKeys() + r.SenderChains.advance() + + cipher, err := aessha2.NewAESSHA2(messageKey.Key, olmKeysKDFInfo) + if err != nil { + return nil, err + } + encryptedText, err := cipher.Encrypt(plaintext) + if err != nil { + return nil, fmt.Errorf("cipher encrypt: %w", err) + } + + message := &message.Message{} + message.Version = protocolVersion + message.Counter = messageKey.Index + message.RatchetKey = r.SenderChains.ratchetKey().PublicKey + message.Ciphertext = encryptedText + //creating the mac is done in encode + return message.EncodeAndMAC(cipher) +} + +// Decrypt decrypts the ciphertext and verifies the MAC. +func (r *Ratchet) Decrypt(input []byte) ([]byte, error) { + message := &message.Message{} + //The mac is not verified here, as we do not know the key yet + err := message.Decode(input) + if err != nil { + return nil, err + } + if message.Version != protocolVersion { + return nil, fmt.Errorf("decrypt: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, message.Version, protocolVersion) + } + if !message.HasCounter || len(message.RatchetKey) != crypto.Curve25519PublicKeyLength || len(message.Ciphertext) == 0 { + return nil, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) + } + var receiverChainFromMessage *receiverChain + for curChainIndex := range r.ReceiverChains { + if r.ReceiverChains[curChainIndex].ratchetKey().Equal(message.RatchetKey) { + receiverChainFromMessage = &r.ReceiverChains[curChainIndex] + break + } + } + if receiverChainFromMessage == nil { + //Advancing the chain is done in this method + return r.decryptForNewChain(message, input) + } else if receiverChainFromMessage.chainKey().Index > message.Counter { + // No need to advance the chain + // Chain already advanced beyond the key for this message + // Check if the message keys are in the skipped key list. + for idx, smk := range r.SkippedMessageKeys { + if message.Counter != smk.MKey.Index { + continue + } + // TODO this check will be unnecessary when skipped message keys are moved inside receiver chains + if !smk.RKey.Equal(message.RatchetKey) { + continue + } + + // Found the key for this message. Check the MAC. + if cipher, err := aessha2.NewAESSHA2(smk.MKey.Key, olmKeysKDFInfo); err != nil { + return nil, err + } else if verified, err := message.VerifyMACInline(cipher, input); err != nil { + return nil, err + } else if !verified { + return nil, fmt.Errorf("decrypt from skipped message keys: %w", olm.ErrBadMAC) + } else if result, err := cipher.Decrypt(message.Ciphertext); err != nil { + return nil, fmt.Errorf("cipher decrypt: %w", err) + } else { + // Remove the key from the skipped keys now that we've + // decoded the message it corresponds to. + r.SkippedMessageKeys = slices.Delete(r.SkippedMessageKeys, idx, idx+1) + return result, nil + } + } + return nil, fmt.Errorf("decrypt: %w", olm.ErrMessageKeyNotFound) + } else { + receiverChainCopy := *receiverChainFromMessage + decrypted, skippedKeys, err := decryptForExistingChain(&receiverChainCopy, message, input) + if err != nil { + return nil, err + } + receiverChainFromMessage.CKey = receiverChainCopy.CKey + r.SkippedMessageKeys = append(r.SkippedMessageKeys, skippedKeys...) + if len(r.SkippedMessageKeys) > maxSkippedMessageKeys { + r.SkippedMessageKeys = r.SkippedMessageKeys[len(r.SkippedMessageKeys)-maxSkippedMessageKeys:] + } + return decrypted, nil + } +} + +func (r *Ratchet) getNextRootKey(newRatchetKey crypto.Curve25519KeyPair, oldRatchetKey crypto.Curve25519PublicKey) (rootKey, newChainKey crypto.Curve25519PublicKey, err error) { + sharedSecret, err := newRatchetKey.SharedSecret(oldRatchetKey) + if err != nil { + return + } + derivedSecretsReader := hkdf.New(sha256.New, sharedSecret, r.RootKey, KdfInfo.Ratchet) + derivedSecrets := make([]byte, 2*sharedKeyLength) + if _, err = io.ReadFull(derivedSecretsReader, derivedSecrets); err != nil { + return + } + rootKey = derivedSecrets[:sharedKeyLength] + newChainKey = derivedSecrets[sharedKeyLength:] + return +} + +// advanceRootKey created the next root key and returns the next chainKey +func (r *Ratchet) advanceRootKey(newRatchetKey crypto.Curve25519KeyPair, oldRatchetKey crypto.Curve25519PublicKey) (crypto.Curve25519PublicKey, error) { + rootKey, chainKey, err := r.getNextRootKey(newRatchetKey, oldRatchetKey) + if err != nil { + return nil, err + } + r.RootKey = rootKey + return chainKey, nil +} + +// decryptForExistingChain returns the decrypted message by using the chain. The MAC of the rawMessage is verified. +func decryptForExistingChain(chain *receiverChain, message *message.Message, rawMessage []byte) ([]byte, []skippedMessageKey, error) { + if message.Counter < chain.CKey.Index { + return nil, nil, fmt.Errorf("decrypt: %w", olm.ErrChainTooHigh) + } + // Limit the number of hashes we're prepared to compute + if message.Counter-chain.CKey.Index > maxMessageGap { + return nil, nil, fmt.Errorf("decrypt from existing chain: %w", olm.ErrMsgIndexTooHigh) + } + skippedKeys := make([]skippedMessageKey, 0, min(maxSkippedMessageKeys, max(message.Counter-chain.CKey.Index, 0))) + for chain.CKey.Index < message.Counter { + if message.Counter-chain.CKey.Index <= maxSkippedMessageKeys { + messageKey := chain.chainKey().createMessageKeys() + skippedKey := skippedMessageKey{ + MKey: messageKey, + RKey: chain.ratchetKey(), + } + skippedKeys = append(skippedKeys, skippedKey) + } + chain.advance() + } + messageKey := chain.chainKey().createMessageKeys() + chain.advance() + cipher, err := aessha2.NewAESSHA2(messageKey.Key, olmKeysKDFInfo) + if err != nil { + return nil, nil, err + } + verified, err := message.VerifyMACInline(cipher, rawMessage) + if err != nil { + return nil, nil, err + } + if !verified { + return nil, nil, fmt.Errorf("decrypt from existing chain: %w", olm.ErrBadMAC) + } + res, err := cipher.Decrypt(message.Ciphertext) + return res, skippedKeys, err +} + +// decryptForNewChain returns the decrypted message by creating a new chain and advancing the root key. +func (r *Ratchet) decryptForNewChain(message *message.Message, rawMessage []byte) ([]byte, error) { + // They shouldn't move to a new chain until we've sent them a message + // acknowledging the last one + if !r.SenderChains.IsSet { + return nil, fmt.Errorf("decrypt for new chain: %w", olm.ErrProtocolViolation) + } + // Limit the number of hashes we're prepared to compute + if message.Counter > maxMessageGap { + return nil, fmt.Errorf("decrypt for new chain: %w", olm.ErrMsgIndexTooHigh) + } + + newRootKey, newChainKey, err := r.getNextRootKey(r.SenderChains.ratchetKey(), message.RatchetKey) + if err != nil { + return nil, err + } + newChain := newReceiverChain(newChainKey, message.RatchetKey) + /* + They have started using a new ephemeral ratchet key. + We needed to derive a new set of chain keys. + We can discard our previous ephemeral ratchet key. + We will generate a new key when we send the next message. + */ + + decrypted, skippedKeys, err := decryptForExistingChain(newChain, message, rawMessage) + if err != nil { + return nil, err + } + r.RootKey = newRootKey + r.ReceiverChains = append([]receiverChain{*newChain}, r.ReceiverChains...) + if len(r.ReceiverChains) > maxReceiverChains { + r.ReceiverChains = r.ReceiverChains[:maxReceiverChains] + } + r.SenderChains = senderChain{} + r.SkippedMessageKeys = append(r.SkippedMessageKeys, skippedKeys...) + if len(r.SkippedMessageKeys) > maxSkippedMessageKeys { + r.SkippedMessageKeys = r.SkippedMessageKeys[len(r.SkippedMessageKeys)-maxSkippedMessageKeys:] + } + return decrypted, nil +} + +// UnpickleLibOlm unpickles the unencryted value and populates the [Ratchet] +// accordingly. +func (r *Ratchet) UnpickleLibOlm(decoder *libolmpickle.Decoder, includesChainIndex bool) error { + if err := r.RootKey.UnpickleLibOlm(decoder); err != nil { + return err + } + senderChainsCount, err := decoder.ReadUInt32() + if err != nil { + return err + } else if senderChainsCount > 1000 { + return fmt.Errorf("Ratchet.UnpickleLibOlm: too many sender chains: %d", senderChainsCount) + } + + for i := uint32(0); i < senderChainsCount; i++ { + if i == 0 { + // only the first sender key is stored + err = r.SenderChains.UnpickleLibOlm(decoder) + r.SenderChains.IsSet = true + } else { + // just eat the values + err = (&senderChain{}).UnpickleLibOlm(decoder) + } + if err != nil { + return err + } + } + + receiverChainCount, err := decoder.ReadUInt32() + if err != nil { + return err + } else if receiverChainCount > 1000 { + return fmt.Errorf("Ratchet.UnpickleLibOlm: too many receiver chains: %d", receiverChainCount) + } + r.ReceiverChains = make([]receiverChain, receiverChainCount) + for i := uint32(0); i < receiverChainCount; i++ { + if err := r.ReceiverChains[i].UnpickleLibOlm(decoder); err != nil { + return err + } + } + if len(r.ReceiverChains) > maxReceiverChains { + r.ReceiverChains = r.ReceiverChains[:maxReceiverChains] + } + + skippedMessageKeysCount, err := decoder.ReadUInt32() + if err != nil { + return err + } else if skippedMessageKeysCount > 1000 { + return fmt.Errorf("Ratchet.UnpickleLibOlm: too many skipped message keys: %d", skippedMessageKeysCount) + } + r.SkippedMessageKeys = make([]skippedMessageKey, skippedMessageKeysCount) + for i := uint32(0); i < skippedMessageKeysCount; i++ { + if err := r.SkippedMessageKeys[i].UnpickleLibOlm(decoder); err != nil { + return err + } + } + if len(r.SkippedMessageKeys) > maxSkippedMessageKeys { + r.SkippedMessageKeys = r.SkippedMessageKeys[len(r.SkippedMessageKeys)-maxSkippedMessageKeys:] + } + + // pickle version 0x80000001 includes a chain index; pickle version 1 does not. + if includesChainIndex { + _, err = decoder.ReadUInt32() + return err + } + return nil +} + +// PickleLibOlm pickles the ratchet into the encoder. +func (r Ratchet) PickleLibOlm(encoder *libolmpickle.Encoder) { + r.RootKey.PickleLibOlm(encoder) + r.SenderChains.PickleLibOlm(encoder) + + // Receiver Chains + encoder.WriteUInt32(uint32(len(r.ReceiverChains))) + for _, curChain := range r.ReceiverChains { + curChain.PickleLibOlm(encoder) + } + + // Skipped Message Keys + encoder.WriteUInt32(uint32(len(r.SkippedMessageKeys))) + for _, curChain := range r.SkippedMessageKeys { + curChain.PickleLibOlm(encoder) + } +} diff --git a/mautrix-patched/crypto/goolm/ratchet/olm_test.go b/mautrix-patched/crypto/goolm/ratchet/olm_test.go new file mode 100644 index 00000000..2bf7ea0a --- /dev/null +++ b/mautrix-patched/crypto/goolm/ratchet/olm_test.go @@ -0,0 +1,126 @@ +package ratchet_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/ratchet" +) + +var ( + sharedSecret = []byte("A secret") +) + +func initializeRatchets() (*ratchet.Ratchet, *ratchet.Ratchet, error) { + ratchet.KdfInfo = struct { + Root []byte + Ratchet []byte + }{ + Root: []byte("Olm"), + Ratchet: []byte("OlmRatchet"), + } + aliceRatchet := ratchet.New() + bobRatchet := ratchet.New() + + aliceKey, err := crypto.Curve25519GenerateKey() + if err != nil { + return nil, nil, err + } + + aliceRatchet.InitializeAsAlice(sharedSecret, aliceKey) + bobRatchet.InitializeAsBob(sharedSecret, aliceKey.PublicKey) + return aliceRatchet, bobRatchet, nil +} + +func TestSendReceive(t *testing.T) { + aliceRatchet, bobRatchet, err := initializeRatchets() + assert.NoError(t, err) + + plainText := []byte("Hello Bob") + + //Alice sends Bob a message + encryptedMessage, err := aliceRatchet.Encrypt(plainText) + assert.NoError(t, err) + + decrypted, err := bobRatchet.Decrypt(encryptedMessage) + assert.NoError(t, err) + assert.Equal(t, plainText, decrypted) + + //Bob sends Alice a message + plainText = []byte("Hello Alice") + encryptedMessage, err = bobRatchet.Encrypt(plainText) + assert.NoError(t, err) + decrypted, err = aliceRatchet.Decrypt(encryptedMessage) + assert.NoError(t, err) + assert.Equal(t, plainText, decrypted) +} + +func TestOutOfOrder(t *testing.T) { + aliceRatchet, bobRatchet, err := initializeRatchets() + assert.NoError(t, err) + + plainText1 := []byte("First Message") + plainText2 := []byte("Second Messsage. A bit longer than the first.") + + /* Alice sends Bob two messages and they arrive out of order */ + message1Encrypted, err := aliceRatchet.Encrypt(plainText1) + assert.NoError(t, err) + message2Encrypted, err := aliceRatchet.Encrypt(plainText2) + assert.NoError(t, err) + + decrypted2, err := bobRatchet.Decrypt(message2Encrypted) + assert.NoError(t, err) + decrypted1, err := bobRatchet.Decrypt(message1Encrypted) + assert.NoError(t, err) + assert.Equal(t, plainText1, decrypted1) + assert.Equal(t, plainText2, decrypted2) +} + +func TestMoreMessages(t *testing.T) { + aliceRatchet, bobRatchet, err := initializeRatchets() + assert.NoError(t, err) + plainText := []byte("These 15 bytes") + for i := 0; i < 8; i++ { + messageEncrypted, err := aliceRatchet.Encrypt(plainText) + assert.NoError(t, err) + + decrypted, err := bobRatchet.Decrypt(messageEncrypted) + assert.NoError(t, err) + assert.Equal(t, plainText, decrypted) + } + for i := 0; i < 8; i++ { + messageEncrypted, err := bobRatchet.Encrypt(plainText) + assert.NoError(t, err) + + decrypted, err := aliceRatchet.Decrypt(messageEncrypted) + assert.NoError(t, err) + assert.Equal(t, plainText, decrypted) + } + messageEncrypted, err := aliceRatchet.Encrypt(plainText) + assert.NoError(t, err) + decrypted, err := bobRatchet.Decrypt(messageEncrypted) + assert.NoError(t, err) + assert.Equal(t, plainText, decrypted) +} + +func TestJSONEncoding(t *testing.T) { + aliceRatchet, bobRatchet, err := initializeRatchets() + assert.NoError(t, err) + marshaled, err := json.Marshal(aliceRatchet) + assert.NoError(t, err) + + newRatcher := ratchet.Ratchet{} + err = json.Unmarshal(marshaled, &newRatcher) + assert.NoError(t, err) + + plainText := []byte("These 15 bytes") + + messageEncrypted, err := newRatcher.Encrypt(plainText) + assert.NoError(t, err) + decrypted, err := bobRatchet.Decrypt(messageEncrypted) + assert.NoError(t, err) + assert.Equal(t, plainText, decrypted) +} diff --git a/mautrix-patched/crypto/goolm/ratchet/skipped_message.go b/mautrix-patched/crypto/goolm/ratchet/skipped_message.go new file mode 100644 index 00000000..2ffaee7b --- /dev/null +++ b/mautrix-patched/crypto/goolm/ratchet/skipped_message.go @@ -0,0 +1,27 @@ +package ratchet + +import ( + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" +) + +// skippedMessageKey stores a skipped message key +type skippedMessageKey struct { + RKey crypto.Curve25519PublicKey `json:"ratchet_key"` + MKey messageKey `json:"message_key"` +} + +// UnpickleLibOlm unpickles the unencryted value and populates the skipped +// message keys accordingly. +func (r *skippedMessageKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) (err error) { + if err = r.RKey.UnpickleLibOlm(decoder); err != nil { + return + } + return r.MKey.UnpickleLibOlm(decoder) +} + +// PickleLibOlm pickles the skipped message key into the encoder. +func (r skippedMessageKey) PickleLibOlm(encoder *libolmpickle.Encoder) { + r.RKey.PickleLibOlm(encoder) + r.MKey.PickleLibOlm(encoder) +} diff --git a/mautrix-patched/crypto/goolm/register.go b/mautrix-patched/crypto/goolm/register.go new file mode 100644 index 00000000..800f567f --- /dev/null +++ b/mautrix-patched/crypto/goolm/register.go @@ -0,0 +1,29 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package goolm + +import ( + "maunium.net/go/mautrix/crypto/goolm/account" + "maunium.net/go/mautrix/crypto/goolm/pk" + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/olm" +) + +func Register() { + olm.Driver = "goolm" + + olm.GetVersion = func() (major, minor, patch uint8) { + return 3, 2, 15 + } + olm.SetPickleKeyImpl = func(key []byte) { + panic("gob and json encoding is deprecated and not supported with goolm") + } + + account.Register() + pk.Register() + session.Register() +} diff --git a/mautrix-patched/crypto/goolm/session/doc.go b/mautrix-patched/crypto/goolm/session/doc.go new file mode 100644 index 00000000..bc2e8f46 --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/doc.go @@ -0,0 +1,3 @@ +// Package session provides the different types of sessions for en/decrypting +// of messages +package session diff --git a/mautrix-patched/crypto/goolm/session/megolm_inbound_session.go b/mautrix-patched/crypto/goolm/session/megolm_inbound_session.go new file mode 100644 index 00000000..fd7e0a01 --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/megolm_inbound_session.go @@ -0,0 +1,250 @@ +package session + +import ( + "encoding/base64" + "fmt" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/goolmbase64" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/goolm/megolm" + "maunium.net/go/mautrix/crypto/goolm/message" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +const ( + megolmInboundSessionPickleVersionLibOlm uint32 = 2 +) + +// MegolmInboundSession stores information about the sessions of receive. +type MegolmInboundSession struct { + Ratchet megolm.Ratchet `json:"ratchet"` + SigningKey crypto.Ed25519PublicKey `json:"signing_key"` + InitialRatchet megolm.Ratchet `json:"initial_ratchet"` + SigningKeyVerified bool `json:"signing_key_verified"` //not used for now +} + +// Ensure that MegolmInboundSession implements the [olm.InboundGroupSession] +// interface. +var _ olm.InboundGroupSession = (*MegolmInboundSession)(nil) + +// NewMegolmInboundSession creates a new MegolmInboundSession from a base64 encoded session sharing message. +func NewMegolmInboundSession(input []byte) (*MegolmInboundSession, error) { + var err error + input, err = goolmbase64.Decode(input) + if err != nil { + return nil, err + } + msg := message.MegolmSessionSharing{} + err = msg.VerifyAndDecode(input) + if err != nil { + return nil, err + } + o := &MegolmInboundSession{} + o.SigningKey = msg.PublicKey + o.SigningKeyVerified = true + ratchet, err := megolm.New(msg.Counter, msg.RatchetData) + if err != nil { + return nil, err + } + o.Ratchet = *ratchet + o.InitialRatchet = *ratchet + return o, nil +} + +// NewMegolmInboundSessionFromExport creates a new MegolmInboundSession from a base64 encoded session export message. +func NewMegolmInboundSessionFromExport(input []byte) (*MegolmInboundSession, error) { + var err error + input, err = goolmbase64.Decode(input) + if err != nil { + return nil, err + } + msg := message.MegolmSessionExport{} + err = msg.Decode(input) + if err != nil { + return nil, err + } + o := &MegolmInboundSession{} + o.SigningKey = msg.PublicKey + ratchet, err := megolm.New(msg.Counter, msg.RatchetData) + if err != nil { + return nil, err + } + o.Ratchet = *ratchet + o.InitialRatchet = *ratchet + return o, nil +} + +// MegolmInboundSessionFromPickled loads the MegolmInboundSession details from a pickled base64 string. The input is decrypted with the supplied key. +func MegolmInboundSessionFromPickled(pickled, key []byte) (*MegolmInboundSession, error) { + if len(pickled) == 0 { + return nil, fmt.Errorf("megolmInboundSessionFromPickled: %w", olm.ErrEmptyInput) + } + a := &MegolmInboundSession{} + err := a.Unpickle(pickled, key) + if err != nil { + return nil, err + } + return a, nil +} + +// getRatchet tries to find the correct ratchet for a messageIndex. +func (o *MegolmInboundSession) getRatchet(messageIndex uint32) (*megolm.Ratchet, error) { + // pick a megolm instance to use. if we are at or beyond the latest ratchet value, use that + if (messageIndex - o.Ratchet.Counter) < uint32(1<<31) { + o.Ratchet.AdvanceTo(messageIndex) + return &o.Ratchet, nil + } + if (messageIndex - o.InitialRatchet.Counter) >= uint32(1<<31) { + // the counter is before our initial ratchet - we can't decode this + return nil, fmt.Errorf("decrypt: %w", olm.ErrUnknownMessageIndex) + } + // otherwise, start from the initial ratchet. Take a copy so that we don't overwrite the initial ratchet + copiedRatchet := o.InitialRatchet + copiedRatchet.AdvanceTo(messageIndex) + return &copiedRatchet, nil + +} + +// Decrypt decrypts a base64 encoded group message. +func (o *MegolmInboundSession) Decrypt(ciphertext []byte) ([]byte, uint, error) { + if len(ciphertext) == 0 { + return nil, 0, olm.ErrEmptyInput + } + if o.SigningKey == nil { + return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) + } + decoded, err := goolmbase64.Decode(ciphertext) + if err != nil { + return nil, 0, err + } + msg := &message.GroupMessage{} + err = msg.Decode(decoded) + if err != nil { + return nil, 0, err + } + if msg.Version != protocolVersion { + return nil, 0, fmt.Errorf("decrypt: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, msg.Version, protocolVersion) + } + if msg.Ciphertext == nil || !msg.HasMessageIndex { + return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) + } + + // verify signature + verifiedSignature := msg.VerifySignatureInline(o.SigningKey, decoded) + if !verifiedSignature { + return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadSignature) + } + + // Note: this will mutate the latest ratchet state even if decryption fails. + // We don't care because the signature was already checked, plus the initial ratchet is preserved. + targetRatch, err := o.getRatchet(msg.MessageIndex) + if err != nil { + return nil, 0, err + } + + decrypted, err := targetRatch.Decrypt(decoded, msg) + if err != nil { + return nil, 0, err + } + o.SigningKeyVerified = true + return decrypted, uint(msg.MessageIndex), nil + +} + +// ID returns the base64 endoded signing key +func (o *MegolmInboundSession) ID() id.SessionID { + return id.SessionID(base64.RawStdEncoding.EncodeToString(o.SigningKey)) +} + +// Export returns the base64-encoded ratchet key for this session, at the given +// index, in a format which can be used by +// InboundGroupSession.InboundGroupSessionImport(). Encrypts the +// InboundGroupSession using the supplied key. Returns error on failure. +// if we do not have a session key corresponding to the given index (ie, it was +// sent before the session key was shared with us) the error will be +// returned. +func (o *MegolmInboundSession) Export(messageIndex uint32) ([]byte, error) { + ratchet, err := o.getRatchet(messageIndex) + if err != nil { + return nil, err + } + return ratchet.SessionExportMessage(o.SigningKey) +} + +// Unpickle decodes the base64 encoded string and decrypts the result with the key. +// The decrypted value is then passed to UnpickleLibOlm. +func (o *MegolmInboundSession) Unpickle(pickled, key []byte) error { + if len(key) == 0 { + return olm.ErrNoKeyProvided + } else if len(pickled) == 0 { + return olm.ErrEmptyInput + } + decrypted, err := libolmpickle.Unpickle(key, pickled) + if err != nil { + return err + } + return o.UnpickleLibOlm(decrypted) +} + +// UnpickleLibOlm unpickles the unencryted value and populates the [Session] +// accordingly. +func (o *MegolmInboundSession) UnpickleLibOlm(value []byte) error { + decoder := libolmpickle.NewDecoder(value) + pickledVersion, err := decoder.ReadUInt32() + if err != nil { + return err + } + if pickledVersion != megolmInboundSessionPickleVersionLibOlm && pickledVersion != 1 { + return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) + } + + if err = o.InitialRatchet.UnpickleLibOlm(decoder); err != nil { + return err + } else if err = o.Ratchet.UnpickleLibOlm(decoder); err != nil { + return err + } else if err = o.SigningKey.UnpickleLibOlm(decoder); err != nil { + return err + } + + if pickledVersion == 1 { + // pickle v1 had no signing_key_verified field (all keyshares were verified at import time) + o.SigningKeyVerified = true + } else { + o.SigningKeyVerified, err = decoder.ReadBool() + return err + } + return nil +} + +// Pickle returns a base64 encoded and with key encrypted pickled MegolmInboundSession using PickleLibOlm(). +func (o *MegolmInboundSession) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + return libolmpickle.Pickle(key, o.PickleLibOlm()) +} + +// PickleLibOlm pickles the session returning the raw bytes. +func (o *MegolmInboundSession) PickleLibOlm() []byte { + encoder := libolmpickle.NewEncoder() + encoder.WriteUInt32(megolmInboundSessionPickleVersionLibOlm) + o.InitialRatchet.PickleLibOlm(encoder) + o.Ratchet.PickleLibOlm(encoder) + o.SigningKey.PickleLibOlm(encoder) + encoder.WriteBool(o.SigningKeyVerified) + return encoder.Bytes() +} + +// FirstKnownIndex returns the first message index we know how to decrypt. +func (s *MegolmInboundSession) FirstKnownIndex() uint32 { + return s.InitialRatchet.Counter +} + +// IsVerified check if the session has been verified as a valid session. (A +// session is verified either because the original session share was signed, or +// because we have subsequently successfully decrypted a message.) +func (s *MegolmInboundSession) IsVerified() bool { + return s.SigningKeyVerified +} diff --git a/mautrix-patched/crypto/goolm/session/megolm_outbound_session.go b/mautrix-patched/crypto/goolm/session/megolm_outbound_session.go new file mode 100644 index 00000000..e5cf16fc --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/megolm_outbound_session.go @@ -0,0 +1,135 @@ +package session + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/goolmbase64" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/goolm/megolm" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +const ( + megolmOutboundSessionPickleVersion byte = 1 + megolmOutboundSessionPickleVersionLibOlm uint32 = 1 +) + +// MegolmOutboundSession stores information about the sessions to send. +type MegolmOutboundSession struct { + Ratchet megolm.Ratchet `json:"ratchet"` + SigningKey crypto.Ed25519KeyPair `json:"signing_key"` +} + +var _ olm.OutboundGroupSession = (*MegolmOutboundSession)(nil) + +// NewMegolmOutboundSession creates a new MegolmOutboundSession. +func NewMegolmOutboundSession() (*MegolmOutboundSession, error) { + o := &MegolmOutboundSession{} + var err error + o.SigningKey, err = crypto.Ed25519GenerateKey() + if err != nil { + return nil, err + } + var randomData [megolm.RatchetParts * megolm.RatchetPartLength]byte + _, err = rand.Read(randomData[:]) + if err != nil { + return nil, err + } + ratchet, err := megolm.New(0, randomData) + if err != nil { + return nil, err + } + o.Ratchet = *ratchet + return o, nil +} + +// MegolmOutboundSessionFromPickled loads the MegolmOutboundSession details from a pickled base64 string. The input is decrypted with the supplied key. +func MegolmOutboundSessionFromPickled(pickled, key []byte) (*MegolmOutboundSession, error) { + if len(pickled) == 0 { + return nil, fmt.Errorf("megolmOutboundSessionFromPickled: %w", olm.ErrEmptyInput) + } + a := &MegolmOutboundSession{} + err := a.Unpickle(pickled, key) + return a, err +} + +// Encrypt encrypts the plaintext as a base64 encoded group message. +func (o *MegolmOutboundSession) Encrypt(plaintext []byte) ([]byte, error) { + if len(plaintext) == 0 { + return nil, olm.ErrEmptyInput + } + encrypted, err := o.Ratchet.Encrypt(plaintext, o.SigningKey) + return goolmbase64.Encode(encrypted), err +} + +// SessionID returns the base64 endoded public signing key +func (o *MegolmOutboundSession) ID() id.SessionID { + return id.SessionID(base64.RawStdEncoding.EncodeToString(o.SigningKey.PublicKey)) +} + +// Unpickle decodes the base64 encoded string and decrypts the result with the key. +// The decrypted value is then passed to UnpickleLibOlm. +func (o *MegolmOutboundSession) Unpickle(pickled, key []byte) error { + if len(key) == 0 { + return olm.ErrNoKeyProvided + } + decrypted, err := libolmpickle.Unpickle(key, pickled) + if err != nil { + return err + } + return o.UnpickleLibOlm(decrypted) +} + +// UnpickleLibOlm unpickles the unencryted value and populates the +// [MegolmOutboundSession] accordingly. +func (o *MegolmOutboundSession) UnpickleLibOlm(buf []byte) error { + decoder := libolmpickle.NewDecoder(buf) + pickledVersion, err := decoder.ReadUInt32() + if err != nil { + return fmt.Errorf("unpickle MegolmOutboundSession: failed to read version: %w", err) + } else if pickledVersion != megolmOutboundSessionPickleVersionLibOlm { + return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) + } + if err = o.Ratchet.UnpickleLibOlm(decoder); err != nil { + return err + } + return o.SigningKey.UnpickleLibOlm(decoder) +} + +// Pickle returns a base64 encoded and with key encrypted pickled MegolmOutboundSession using PickleLibOlm(). +func (o *MegolmOutboundSession) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + return libolmpickle.Pickle(key, o.PickleLibOlm()) +} + +// PickleLibOlm pickles the session returning the raw bytes. +func (o *MegolmOutboundSession) PickleLibOlm() []byte { + encoder := libolmpickle.NewEncoder() + encoder.WriteUInt32(megolmOutboundSessionPickleVersionLibOlm) + o.Ratchet.PickleLibOlm(encoder) + o.SigningKey.PickleLibOlm(encoder) + return encoder.Bytes() +} + +func (o *MegolmOutboundSession) SessionSharingMessage() ([]byte, error) { + return o.Ratchet.SessionSharingMessage(o.SigningKey) +} + +// MessageIndex returns the message index for this session. Each message is +// sent with an increasing index; this returns the index for the next message. +func (s *MegolmOutboundSession) MessageIndex() uint { + return uint(s.Ratchet.Counter) +} + +// Key returns the base64-encoded current ratchet key for this session. +func (s *MegolmOutboundSession) Key() string { + return string(exerrors.Must(s.SessionSharingMessage())) +} diff --git a/mautrix-patched/crypto/goolm/session/megolm_session_test.go b/mautrix-patched/crypto/goolm/session/megolm_session_test.go new file mode 100644 index 00000000..38aa223b --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/megolm_session_test.go @@ -0,0 +1,186 @@ +package session_test + +import ( + "crypto/rand" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/megolm" + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/olm" +) + +func TestOutboundPickleRoundtrip(t *testing.T) { + pickleKey := []byte("secretKey") + sess, err := session.NewMegolmOutboundSession() + assert.NoError(t, err) + kp, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + sess.SigningKey = kp + pickled, err := sess.Pickle(pickleKey) + assert.NoError(t, err) + + newSession := session.MegolmOutboundSession{} + err = newSession.Unpickle(pickled, pickleKey) + assert.NoError(t, err) + assert.Equal(t, sess.ID(), newSession.ID()) + assert.Equal(t, sess.SigningKey, newSession.SigningKey) + assert.Equal(t, sess.Ratchet, newSession.Ratchet) +} + +func TestInboundPickleRoundtrip(t *testing.T) { + pickleKey := []byte("secretKey") + sess := session.MegolmInboundSession{} + kp, err := crypto.Ed25519GenerateKey() + assert.NoError(t, err) + sess.SigningKey = kp.PublicKey + var randomData [megolm.RatchetParts * megolm.RatchetPartLength]byte + _, err = rand.Read(randomData[:]) + assert.NoError(t, err) + ratchet, err := megolm.New(0, randomData) + assert.NoError(t, err) + sess.Ratchet = *ratchet + pickled, err := sess.Pickle(pickleKey) + assert.NoError(t, err) + + newSession := session.MegolmInboundSession{} + err = newSession.Unpickle(pickled, pickleKey) + assert.NoError(t, err) + assert.Equal(t, sess.ID(), newSession.ID()) + assert.Equal(t, sess.SigningKey, newSession.SigningKey) + assert.Equal(t, sess.Ratchet, newSession.Ratchet) +} + +func TestGroupSendReceive(t *testing.T) { + randomData := []byte( + "0123456789ABDEF0123456789ABCDEF" + + "0123456789ABDEF0123456789ABCDEF" + + "0123456789ABDEF0123456789ABCDEF" + + "0123456789ABDEF0123456789ABCDEF" + + "0123456789ABDEF0123456789ABCDEF" + + "0123456789ABDEF0123456789ABCDEF", + ) + + outboundSession, err := session.NewMegolmOutboundSession() + assert.NoError(t, err) + copy(outboundSession.Ratchet.Data[:], randomData) + assert.EqualValues(t, 0, outboundSession.Ratchet.Counter) + + sessionSharing, err := outboundSession.SessionSharingMessage() + assert.NoError(t, err) + plainText := []byte("Message") + ciphertext, err := outboundSession.Encrypt(plainText) + assert.NoError(t, err) + assert.EqualValues(t, 1, outboundSession.Ratchet.Counter) + + //build inbound session + inboundSession, err := session.NewMegolmInboundSession(sessionSharing) + assert.NoError(t, err) + assert.True(t, inboundSession.SigningKeyVerified) + assert.Equal(t, outboundSession.ID(), inboundSession.ID()) + + //decode message + decoded, _, err := inboundSession.Decrypt(ciphertext) + assert.NoError(t, err) + assert.Equal(t, plainText, decoded) +} + +func TestGroupSessionExportImport(t *testing.T) { + plaintext := []byte("Message") + sessionKey := []byte( + "AgAAAAAwMTIzNDU2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMzQ1Njc4OUFCREVGM" + + "DEyMzQ1Njc4OUFCQ0RFRjAxMjM0NTY3ODlBQkRFRjAxMjM0NTY3ODlBQkNERUYwMTIzND" + + "U2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMw0bdg1BDq4Px/slBow06q8n/B9WBfw" + + "WYyNOB8DlUmXGGwrFmaSb9bR/eY8xgERrxmP07hFmD9uqA2p8PMHdnV5ysmgufE6oLZ5+" + + "8/mWQOW3VVTnDIlnwd8oHUYRuk8TCQ", + ) + message := []byte( + "AwgAEhAcbh6UpbByoyZxufQ+h2B+8XHMjhR69G8F4+qjMaFlnIXusJZX3r8LnRORG9T3D" + + "XFdbVuvIWrLyRfm4i8QRbe8VPwGRFG57B1CtmxanuP8bHtnnYqlwPsD", + ) + + //init inbound + inboundSession, err := session.NewMegolmInboundSession(sessionKey) + assert.NoError(t, err) + assert.True(t, inboundSession.SigningKeyVerified) + + decrypted, _, err := inboundSession.Decrypt(message) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + + //Export the keys + exported, err := inboundSession.Export(0) + assert.NoError(t, err) + + secondInboundSession, err := session.NewMegolmInboundSessionFromExport(exported) + assert.NoError(t, err) + assert.False(t, secondInboundSession.SigningKeyVerified) + + //decrypt with new session + decrypted, _, err = secondInboundSession.Decrypt(message) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + assert.True(t, secondInboundSession.SigningKeyVerified) +} + +func TestBadSignatureGroupMessage(t *testing.T) { + plaintext := []byte("Message") + sessionKey := []byte( + "AgAAAAAwMTIzNDU2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMzQ1Njc4OUFCREVGM" + + "DEyMzQ1Njc4OUFCQ0RFRjAxMjM0NTY3ODlBQkRFRjAxMjM0NTY3ODlBQkNERUYwMTIzND" + + "U2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMztqJ7zOtqQtYqOo0CpvDXNlMhV3HeJ" + + "DpjrASKGLWdop4lx1cSN3Xv1TgfLPW8rhGiW+hHiMxd36nRuxscNv9k4oJA/KP+o0mi1w" + + "v44StrEJ1wwx9WZHBUIWkQbaBSuBDw", + ) + message := []byte( + "AwgAEhAcbh6UpbByoyZxufQ+h2B+8XHMjhR69G8nP4pNZGl/3QMgrzCZPmP+F2aPLyKPz" + + "xRPBMUkeXRJ6Iqm5NeOdx2eERgTW7P20CM+lL3Xpk+ZUOOPvsSQNaAL", + ) + + //init inbound + inboundSession, err := session.NewMegolmInboundSession(sessionKey) + assert.NoError(t, err) + assert.True(t, inboundSession.SigningKeyVerified) + + decrypted, _, err := inboundSession.Decrypt(message) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + + //Now twiddle the signature + copy(message[len(message)-1:], []byte("E")) + _, _, err = inboundSession.Decrypt(message) + assert.ErrorIs(t, err, olm.ErrBadSignature) +} + +func TestOutbountPickle(t *testing.T) { + pickledDataFromLibOlm := []byte("icDKYm0b4aO23WgUuOxdpPoxC0UlEOYPVeuduNH3IkpFsmnWx5KuEOpxGiZw5IuB/sSn2RZUCTiJ90IvgC7AClkYGHep9O8lpiqQX73XVKD9okZDCAkBc83eEq0DKYC7HBkGRAU/4T6QPIBBY3UK4QZwULLE/fLsi3j4YZBehMtnlsqgHK0q1bvX4cRznZItUO3TiOp5I+6PnQka6n8eHTyIEh3tCetilD+BKnHvtakE0eHHvG6pjEsMNN/vs7lkB5rV6XkoUKHLTE1dAfFunYEeHEZuKQpbG385dBwaMJXt4JrC0hU5jnv6jWNqAA0Ud9GxRDvkp04") + pickleKey := []byte("secret_key") + sess, err := session.MegolmOutboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) + assert.NoError(t, err) + newPickled, err := sess.Pickle(pickleKey) + assert.NoError(t, err) + assert.Equal(t, pickledDataFromLibOlm, newPickled) + + pickledDataFromLibOlm[len(pickledDataFromLibOlm)-1] = 'a' + pickledDataFromLibOlm[len(pickledDataFromLibOlm)-2] = 'b' + pickledDataFromLibOlm[len(pickledDataFromLibOlm)-3] = 'c' + _, err = session.MegolmOutboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) + assert.ErrorIs(t, err, olm.ErrBadMAC) +} + +func TestInbountPickle(t *testing.T) { + pickledDataFromLibOlm := []byte("1/IPCdtUoQxMba5XT7sjjUW0Hrs7no9duGFnhsEmxzFX2H3qtRc4eaFBRZYXxOBRTGZ6eMgy3IiSrgAQ1gUlSZf5Q4AVKeBkhvN4LZ6hdhQFv91mM+C2C55/4B9/gDjJEbDGiRgLoMqbWPDV+y0F4h0KaR1V1PiTCC7zCi4WdxJQ098nJLgDL4VSsDbnaLcSMO60FOYgRN4KsLaKUGkXiiUBWp4boFMCiuTTOiyH8XlH0e9uWc0vMLyGNUcO8kCbpAnx3v1JTIVan3WGsnGv4K8Qu4M8GAkZewpexrsb2BSNNeLclOV9/cR203Y5KlzXcpiWNXSs8XoB3TLEtHYMnjuakMQfyrcXKIQntg4xPD/+wvfqkcMg9i7pcplQh7X2OK5ylrMZQrZkJ1fAYBGbBz1tykWOjfrZ") + pickleKey := []byte("secret_key") + sess, err := session.MegolmInboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) + assert.NoError(t, err) + newPickled, err := sess.Pickle(pickleKey) + assert.NoError(t, err) + assert.Equal(t, pickledDataFromLibOlm, newPickled) + + pickledDataFromLibOlm = append(pickledDataFromLibOlm, []byte("a")...) + _, err = session.MegolmInboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) + assert.ErrorIs(t, err, base64.CorruptInputError(416)) +} diff --git a/mautrix-patched/crypto/goolm/session/olm_session.go b/mautrix-patched/crypto/goolm/session/olm_session.go new file mode 100644 index 00000000..12694663 --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/olm_session.go @@ -0,0 +1,404 @@ +package session + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/goolmbase64" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/goolm/message" + "maunium.net/go/mautrix/crypto/goolm/ratchet" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +const ( + olmSessionPickleVersionLibOlm uint32 = 1 +) + +const ( + protocolVersion = 0x3 +) + +// OlmSession stores all information for an olm session +type OlmSession struct { + ReceivedMessage bool `json:"received_message"` + AliceIdentityKey crypto.Curve25519PublicKey `json:"alice_id_key"` + AliceBaseKey crypto.Curve25519PublicKey `json:"alice_base_key"` + BobOneTimeKey crypto.Curve25519PublicKey `json:"bob_one_time_key"` + Ratchet ratchet.Ratchet `json:"ratchet"` +} + +var _ olm.Session = (*OlmSession)(nil) + +// SearchOTKFunc is used to retrieve a crypto.OneTimeKey from a public key. +type SearchOTKFunc = func(crypto.Curve25519PublicKey) *crypto.OneTimeKey + +// OlmSessionFromPickled loads the OlmSession details from a pickled base64 string. The input is decrypted with the supplied key. +func OlmSessionFromPickled(pickled, key []byte) (*OlmSession, error) { + if len(pickled) == 0 { + return nil, fmt.Errorf("sessionFromPickled: %w", olm.ErrEmptyInput) + } + a := &OlmSession{} + return a, a.Unpickle(pickled, key) +} + +// NewOlmSession creates a new Session. +func NewOlmSession() *OlmSession { + s := &OlmSession{} + s.Ratchet = *ratchet.New() + return s +} + +// NewOutboundOlmSession creates a new outbound session for sending the first message to a +// given curve25519 identityKey and oneTimeKey. +func NewOutboundOlmSession(identityKeyAlice crypto.Curve25519KeyPair, identityKeyBob crypto.Curve25519PublicKey, oneTimeKeyBob crypto.Curve25519PublicKey) (*OlmSession, error) { + s := NewOlmSession() + //generate E_A + baseKey, err := crypto.Curve25519GenerateKey() + if err != nil { + return nil, err + } + //generate T_0 + ratchetKey, err := crypto.Curve25519GenerateKey() + if err != nil { + return nil, err + } + + //Calculate shared secret via Triple Diffie-Hellman + var secret []byte + //ECDH(I_A,E_B) + idSecret, err := identityKeyAlice.SharedSecret(oneTimeKeyBob) + if err != nil { + return nil, err + } + //ECDH(E_A,I_B) + baseIdSecret, err := baseKey.SharedSecret(identityKeyBob) + if err != nil { + return nil, err + } + //ECDH(E_A,E_B) + baseOneTimeSecret, err := baseKey.SharedSecret(oneTimeKeyBob) + if err != nil { + return nil, err + } + secret = append(secret, idSecret...) + secret = append(secret, baseIdSecret...) + secret = append(secret, baseOneTimeSecret...) + //Init Ratchet + err = s.Ratchet.InitializeAsAlice(secret, ratchetKey) + if err != nil { + return nil, fmt.Errorf("ratchet initialize: %w", err) + } + s.AliceIdentityKey = identityKeyAlice.PublicKey + s.AliceBaseKey = baseKey.PublicKey + s.BobOneTimeKey = oneTimeKeyBob + return s, nil +} + +// NewInboundOlmSession creates a new inbound session from receiving the first message. +func NewInboundOlmSession(identityKeyAlice *crypto.Curve25519PublicKey, receivedOTKMsg []byte, searchBobOTK SearchOTKFunc, identityKeyBob crypto.Curve25519KeyPair) (*OlmSession, error) { + decodedOTKMsg, err := goolmbase64.Decode(receivedOTKMsg) + if err != nil { + return nil, err + } + s := NewOlmSession() + + //decode OneTimeKeyMessage + oneTimeMsg := message.PreKeyMessage{} + err = oneTimeMsg.Decode(decodedOTKMsg) + if err != nil { + return nil, fmt.Errorf("OneTimeKeyMessage decode: %w", err) + } + if !oneTimeMsg.CheckFields() { + return nil, fmt.Errorf("OneTimeKeyMessage check fields: %w", olm.ErrBadMessageFormat) + } + + //Either the identityKeyAlice is set and/or the oneTimeMsg.IdentityKey is set, which is checked + // by oneTimeMsg.CheckFields + if identityKeyAlice != nil && !identityKeyAlice.Equal(oneTimeMsg.IdentityKey) { + return nil, fmt.Errorf("OneTimeKeyMessage identity keys: %w", olm.ErrBadMessageKeyID) + } + + oneTimeKeyBob := searchBobOTK(oneTimeMsg.OneTimeKey) + if oneTimeKeyBob == nil { + return nil, fmt.Errorf("ourOneTimeKey: %w", olm.ErrBadMessageKeyID) + } + + //Calculate shared secret via Triple Diffie-Hellman + var secret []byte + //ECDH(E_B,I_A) + idSecret, err := oneTimeKeyBob.Key.SharedSecret(oneTimeMsg.IdentityKey) + if err != nil { + return nil, err + } + //ECDH(I_B,E_A) + baseIdSecret, err := identityKeyBob.SharedSecret(oneTimeMsg.BaseKey) + if err != nil { + return nil, err + } + //ECDH(E_B,E_A) + baseOneTimeSecret, err := oneTimeKeyBob.Key.SharedSecret(oneTimeMsg.BaseKey) + if err != nil { + return nil, err + } + secret = append(secret, idSecret...) + secret = append(secret, baseIdSecret...) + secret = append(secret, baseOneTimeSecret...) + //decode message + msg := message.Message{} + err = msg.Decode(oneTimeMsg.Message) + if err != nil { + return nil, fmt.Errorf("message decode: %w", err) + } + + if len(msg.RatchetKey) != crypto.Curve25519PublicKeyLength { + return nil, fmt.Errorf("message missing ratchet key: %w", olm.ErrBadMessageFormat) + } + //Init Ratchet + err = s.Ratchet.InitializeAsBob(secret, msg.RatchetKey) + if err != nil { + return nil, fmt.Errorf("ratchet initialize: %w", err) + } + s.AliceBaseKey = oneTimeMsg.BaseKey + s.AliceIdentityKey = oneTimeMsg.IdentityKey + s.BobOneTimeKey = oneTimeKeyBob.Key.PublicKey + + //https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/olm.md states to remove the oneTimeKey + //this is done via the account itself + return s, nil +} + +// ID returns an identifier for this Session. Will be the same for both ends of the conversation. +// Generated by hashing the public keys used to create the session. +func (s *OlmSession) ID() id.SessionID { + message := make([]byte, 3*crypto.Curve25519PrivateKeyLength) + copy(message, s.AliceIdentityKey) + copy(message[crypto.Curve25519PrivateKeyLength:], s.AliceBaseKey) + copy(message[2*crypto.Curve25519PrivateKeyLength:], s.BobOneTimeKey) + hash := sha256.Sum256(message) + res := id.SessionID(base64.RawStdEncoding.EncodeToString(hash[:])) + return res +} + +// HasReceivedMessage returns true if this session has received any message. +func (s *OlmSession) HasReceivedMessage() bool { + return s.ReceivedMessage +} + +// MatchesInboundSession checks if the PRE_KEY message is for this in-bound +// Session. This can happen if multiple messages are sent to this Account +// before this Account sends a message in reply. Returns true if the session +// matches. Returns false if the session does not match. Returns error on +// failure. +func (s *OlmSession) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { + return s.matchesInboundSession(nil, []byte(oneTimeKeyMsg)) +} + +// MatchesInboundSessionFrom checks if the PRE_KEY message is for this in-bound +// Session. This can happen if multiple messages are sent to this Account +// before this Account sends a message in reply. Returns true if the session +// matches. Returns false if the session does not match. Returns error on +// failure. +func (s *OlmSession) MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) { + var theirKey *id.Curve25519 + if theirIdentityKey != "" { + theirs := id.Curve25519(theirIdentityKey) + theirKey = &theirs + } + + return s.matchesInboundSession(theirKey, []byte(oneTimeKeyMsg)) +} + +// matchesInboundSession checks if the oneTimeKeyMsg message is set for this +// inbound Session. This can happen if multiple messages are sent to this +// Account before this Account sends a message in reply. Returns true if the +// session matches. Returns false if the session does not match. +func (s *OlmSession) matchesInboundSession(theirIdentityKeyEncoded *id.Curve25519, receivedOTKMsg []byte) (bool, error) { + if len(receivedOTKMsg) == 0 { + return false, fmt.Errorf("inbound match: %w", olm.ErrEmptyInput) + } + decodedOTKMsg, err := goolmbase64.Decode(receivedOTKMsg) + if err != nil { + return false, err + } + + var theirIdentityKey *crypto.Curve25519PublicKey + if theirIdentityKeyEncoded != nil { + decodedKey, err := base64.RawStdEncoding.DecodeString(string(*theirIdentityKeyEncoded)) + if err != nil { + return false, err + } + theirIdentityKeyByte := crypto.Curve25519PublicKey(decodedKey) + theirIdentityKey = &theirIdentityKeyByte + } + + msg := message.PreKeyMessage{} + err = msg.Decode(decodedOTKMsg) + if err != nil { + return false, err + } + if !msg.CheckFields() { + return false, nil + } + + same := true + same = same && msg.IdentityKey.Equal(s.AliceIdentityKey) + if theirIdentityKey != nil { + same = same && theirIdentityKey.Equal(s.AliceIdentityKey) + } + same = same && msg.BaseKey.Equal(s.AliceBaseKey) + same = same && msg.OneTimeKey.Equal(s.BobOneTimeKey) + return same, nil +} + +// EncryptMsgType returns the type of the next message that Encrypt will +// return. Returns MsgTypePreKey if the message will be a oneTimeKeyMsg. +// Returns MsgTypeMsg if the message will be a normal message. +func (s *OlmSession) EncryptMsgType() id.OlmMsgType { + if s.ReceivedMessage { + return id.OlmMsgTypeMsg + } + return id.OlmMsgTypePreKey +} + +// Encrypt encrypts a message using the Session. Returns the encrypted message base64 encoded. +func (s *OlmSession) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { + if len(plaintext) == 0 { + return 0, nil, fmt.Errorf("encrypt: %w", olm.ErrEmptyInput) + } + messageType := s.EncryptMsgType() + encrypted, err := s.Ratchet.Encrypt(plaintext) + if err != nil { + return 0, nil, err + } + result := encrypted + if !s.ReceivedMessage { + msg := message.PreKeyMessage{} + msg.Version = protocolVersion + msg.OneTimeKey = s.BobOneTimeKey + msg.IdentityKey = s.AliceIdentityKey + msg.BaseKey = s.AliceBaseKey + msg.Message = encrypted + + var err error + messageBody, err := msg.Encode() + if err != nil { + return 0, nil, err + } + result = messageBody + } + + return messageType, goolmbase64.Encode(result), nil +} + +// Decrypt decrypts a base64 encoded message using the Session. +func (s *OlmSession) Decrypt(crypttext string, msgType id.OlmMsgType) ([]byte, error) { + if len(crypttext) == 0 { + return nil, fmt.Errorf("decrypt: %w", olm.ErrEmptyInput) + } + decodedCrypttext, err := base64.RawStdEncoding.DecodeString(crypttext) + if err != nil { + return nil, err + } + msgBody := decodedCrypttext + if msgType != id.OlmMsgTypeMsg { + //Pre-Key Message + msg := message.PreKeyMessage{} + err := msg.Decode(decodedCrypttext) + if err != nil { + return nil, err + } + msgBody = msg.Message + } + plaintext, err := s.Ratchet.Decrypt(msgBody) + if err != nil { + return nil, err + } + s.ReceivedMessage = true + return plaintext, nil +} + +// Unpickle decodes the base64 encoded string and decrypts the result with the key. +// The decrypted value is then passed to UnpickleLibOlm. +func (o *OlmSession) Unpickle(pickled, key []byte) error { + if len(pickled) == 0 { + return olm.ErrEmptyInput + } + decrypted, err := libolmpickle.Unpickle(key, pickled) + if err != nil { + return err + } + return o.UnpickleLibOlm(decrypted) +} + +// UnpickleLibOlm unpickles the unencryted value and populates the [OlmSession] +// accordingly. +func (o *OlmSession) UnpickleLibOlm(buf []byte) error { + decoder := libolmpickle.NewDecoder(buf) + pickledVersion, err := decoder.ReadUInt32() + if err != nil { + return fmt.Errorf("unpickle olmSession: failed to read version: %w", err) + } + + var includesChainIndex bool + switch pickledVersion { + case olmSessionPickleVersionLibOlm: + includesChainIndex = false + case uint32(0x80000001): + includesChainIndex = true + default: + return fmt.Errorf("unpickle olmSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) + } + + if o.ReceivedMessage, err = decoder.ReadBool(); err != nil { + return err + } else if err = o.AliceIdentityKey.UnpickleLibOlm(decoder); err != nil { + return err + } else if err = o.AliceBaseKey.UnpickleLibOlm(decoder); err != nil { + return err + } else if err = o.BobOneTimeKey.UnpickleLibOlm(decoder); err != nil { + return err + } + return o.Ratchet.UnpickleLibOlm(decoder, includesChainIndex) +} + +// Pickle returns a base64 encoded and with key encrypted pickled olmSession +// using PickleLibOlm(). +func (s *OlmSession) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + return libolmpickle.Pickle(key, s.PickleLibOlm()) +} + +// PickleLibOlm pickles the session and returns the raw bytes. +func (o *OlmSession) PickleLibOlm() []byte { + encoder := libolmpickle.NewEncoder() + encoder.WriteUInt32(olmSessionPickleVersionLibOlm) + encoder.WriteBool(o.ReceivedMessage) + o.AliceIdentityKey.PickleLibOlm(encoder) + o.AliceBaseKey.PickleLibOlm(encoder) + o.BobOneTimeKey.PickleLibOlm(encoder) + o.Ratchet.PickleLibOlm(encoder) + return encoder.Bytes() +} + +// Describe returns a string describing the current state of the session for debugging. +func (o *OlmSession) Describe() string { + var builder strings.Builder + builder.WriteString("sender chain index: ") + builder.WriteString(fmt.Sprint(o.Ratchet.SenderChains.CKey.Index)) + builder.WriteString(" receiver chain indices:") + for _, curChain := range o.Ratchet.ReceiverChains { + builder.WriteString(fmt.Sprintf(" %d", curChain.CKey.Index)) + } + builder.WriteString(" skipped message keys:") + for _, curSkip := range o.Ratchet.SkippedMessageKeys { + builder.WriteString(fmt.Sprintf(" %d", curSkip.MKey.Index)) + } + return builder.String() +} diff --git a/mautrix-patched/crypto/goolm/session/olm_session_test.go b/mautrix-patched/crypto/goolm/session/olm_session_test.go new file mode 100644 index 00000000..eb79e914 --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/olm_session_test.go @@ -0,0 +1,123 @@ +package session_test + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +func TestOlmSession(t *testing.T) { + pickleKey := []byte("secretKey") + aliceKeyPair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + bobKeyPair, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + bobOneTimeKey, err := crypto.Curve25519GenerateKey() + assert.NoError(t, err) + aliceSession, err := session.NewOutboundOlmSession(aliceKeyPair, bobKeyPair.PublicKey, bobOneTimeKey.PublicKey) + assert.NoError(t, err) + //create a message so that there are more keys to marshal + plaintext := []byte("Test message from Alice to Bob") + msgType, message, err := aliceSession.Encrypt(plaintext) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + + searchFunc := func(target crypto.Curve25519PublicKey) *crypto.OneTimeKey { + if target.Equal(bobOneTimeKey.PublicKey) { + return &crypto.OneTimeKey{ + Key: bobOneTimeKey, + Published: false, + ID: 1, + } + } + return nil + } + //bob receives message + bobSession, err := session.NewInboundOlmSession(nil, message, searchFunc, bobKeyPair) + assert.NoError(t, err) + decryptedMsg, err := bobSession.Decrypt(string(message), msgType) + assert.NoError(t, err) + assert.Equal(t, plaintext, decryptedMsg) + + // Alice pickles session + pickled, err := aliceSession.Pickle(pickleKey) + assert.NoError(t, err) + + //bob sends a message + plaintext = []byte("A message from Bob to Alice") + msgType, message, err = bobSession.Encrypt(plaintext) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + + //Alice unpickles session + newAliceSession, err := session.OlmSessionFromPickled(pickled, pickleKey) + assert.NoError(t, err) + + //Alice receives message + decryptedMsg, err = newAliceSession.Decrypt(string(message), msgType) + assert.NoError(t, err) + assert.Equal(t, plaintext, decryptedMsg) + + //Alice receives message again + _, err = newAliceSession.Decrypt(string(message), msgType) + assert.ErrorIs(t, err, olm.ErrMessageKeyNotFound) + + //Alice sends another message + plaintext = []byte("A second message to Bob") + msgType, message, err = newAliceSession.Encrypt(plaintext) + assert.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + + //bob receives message + decryptedMsg, err = bobSession.Decrypt(string(message), msgType) + assert.NoError(t, err) + assert.Equal(t, plaintext, decryptedMsg) +} + +func TestSessionPickle(t *testing.T) { + pickledDataFromLibOlm := []byte("icDKYm0b4aO23WgUuOxdpPoxC0UlEOYPVeuduNH3IkpFsmnWx5KuEOpxGiZw5IuB/sSn2RZUCTiJ90IvgC7AClkYGHep9O8lpiqQX73XVKD9okZDCAkBc83eEq0DKYC7HBkGRAU/4T6QPIBBY3UK4QZwULLE/fLsi3j4YZBehMtnlsqgHK0q1bvX4cRznZItVKR4ro0O9EAk6LLxJtSnRu5elSUk7YXT") + pickleKey := []byte("secret_key") + sess, err := session.OlmSessionFromPickled(pickledDataFromLibOlm, pickleKey) + assert.NoError(t, err) + newPickled, err := sess.Pickle(pickleKey) + assert.NoError(t, err) + assert.Equal(t, pickledDataFromLibOlm, newPickled) + + pickledDataFromLibOlm = append(pickledDataFromLibOlm, []byte("a")...) + _, err = session.OlmSessionFromPickled(pickledDataFromLibOlm, pickleKey) + assert.ErrorIs(t, err, base64.CorruptInputError(224)) +} + +func TestDecrypts(t *testing.T) { + messages := [][]byte{ + {0x41, 0x77, 0x6F}, + {0x7f, 0xff, 0x6f, 0x01, 0x01, 0x34, 0x6d, 0x67, 0x12, 0x01}, + {0xee, 0x77, 0x6f, 0x41, 0x49, 0x6f, 0x67, 0x41, 0x77, 0x80, 0x41, 0x77, 0x77, 0x80, 0x41, 0x77, 0x6f, 0x67, 0x16, 0x67, 0x0a, 0x67, 0x7d, 0x6f, 0x67, 0x0a, 0x67, 0xc2, 0x67, 0x7d}, + {0xe9, 0xe9, 0xc9, 0xc1, 0xe9, 0xe9, 0xc9, 0xe9, 0xc9, 0xc1, 0xe9, 0xe9, 0xc9, 0xc1}, + } + expectedErr := []error{ + olm.ErrInputToSmall, + // Why are these being tested 🤔 + base64.CorruptInputError(0), + base64.CorruptInputError(0), + base64.CorruptInputError(0), + } + sessionPickled := []byte("E0p44KO2y2pzp9FIjv0rud2wIvWDi2dx367kP4Fz/9JCMrH+aG369HGymkFtk0+PINTLB9lQRt" + + "ohea5d7G/UXQx3r5y4IWuyh1xaRnojEZQ9a5HRZSNtvmZ9NY1f1gutYa4UtcZcbvczN8b/5Bqg" + + "e16cPUH1v62JKLlhoAJwRkH1wU6fbyOudERg5gdXA971btR+Q2V8GKbVbO5fGKL5phmEPVXyMs" + + "rfjLdzQrgjOTxN8Pf6iuP+WFPvfnR9lDmNCFxJUVAdLIMnLuAdxf1TGcS+zzCzEE8btIZ99mHF" + + "dGvPXeH8qLeNZA") + pickleKey := []byte("") + sess, err := session.OlmSessionFromPickled(sessionPickled, pickleKey) + assert.NoError(t, err) + for curIndex, curMessage := range messages { + _, err := sess.Decrypt(string(curMessage), id.OlmMsgTypePreKey) + assert.ErrorIs(t, err, expectedErr[curIndex]) + } +} diff --git a/mautrix-patched/crypto/goolm/session/register.go b/mautrix-patched/crypto/goolm/session/register.go new file mode 100644 index 00000000..b95a44ac --- /dev/null +++ b/mautrix-patched/crypto/goolm/session/register.go @@ -0,0 +1,61 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package session + +import ( + "maunium.net/go/mautrix/crypto/olm" +) + +func Register() { + // Inbound Session + olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { + if len(pickled) == 0 { + return nil, olm.ErrEmptyInput + } + if len(key) == 0 { + key = []byte(" ") + } + return MegolmInboundSessionFromPickled(pickled, key) + } + olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { + if len(sessionKey) == 0 { + return nil, olm.ErrEmptyInput + } + return NewMegolmInboundSession(sessionKey) + } + olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { + if len(sessionKey) == 0 { + return nil, olm.ErrEmptyInput + } + return NewMegolmInboundSessionFromExport(sessionKey) + } + olm.InitBlankInboundGroupSession = func() olm.InboundGroupSession { + return &MegolmInboundSession{} + } + + // Outbound Session + olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { + if len(pickled) == 0 { + return nil, olm.ErrEmptyInput + } + lenKey := len(key) + if lenKey == 0 { + key = []byte(" ") + } + return MegolmOutboundSessionFromPickled(pickled, key) + } + olm.InitNewOutboundGroupSession = func() (olm.OutboundGroupSession, error) { return NewMegolmOutboundSession() } + olm.InitNewBlankOutboundGroupSession = func() olm.OutboundGroupSession { return &MegolmOutboundSession{} } + + // Olm Session + olm.InitSessionFromPickled = func(pickled, key []byte) (olm.Session, error) { + return OlmSessionFromPickled(pickled, key) + } + olm.InitNewBlankSession = func() olm.Session { + return NewOlmSession() + } +} diff --git a/mautrix-patched/crypto/keybackup.go b/mautrix-patched/crypto/keybackup.go new file mode 100644 index 00000000..6b1d97a2 --- /dev/null +++ b/mautrix-patched/crypto/keybackup.go @@ -0,0 +1,242 @@ +package crypto + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/backup" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (mach *OlmMachine) DownloadAndStoreLatestKeyBackup(ctx context.Context, megolmBackupKey *backup.MegolmBackupKey) (id.KeyBackupVersion, error) { + log := mach.machOrContextLog(ctx).With(). + Str("action", "download and store latest key backup"). + Logger() + + ctx = log.WithContext(ctx) + + versionInfo, err := mach.GetAndVerifyLatestKeyBackupVersion(ctx, megolmBackupKey) + if err != nil { + return "", err + } else if versionInfo == nil { + return "", nil + } + + err = mach.GetAndStoreKeyBackup(ctx, versionInfo.Version, megolmBackupKey) + return versionInfo.Version, err +} + +func (mach *OlmMachine) GetAndVerifyLatestKeyBackupVersion(ctx context.Context, megolmBackupKey *backup.MegolmBackupKey) (*mautrix.RespRoomKeysVersion[backup.MegolmAuthData], error) { + versionInfo, err := mach.Client.GetKeyBackupLatestVersion(ctx) + if err != nil { + return nil, err + } + + if versionInfo.Algorithm != id.KeyBackupAlgorithmMegolmBackupV1 { + return nil, fmt.Errorf("unsupported key backup algorithm: %s", versionInfo.Algorithm) + } + + log := mach.machOrContextLog(ctx).With(). + Int("count", versionInfo.Count). + Str("etag", versionInfo.ETag). + Stringer("key_backup_version", versionInfo.Version). + Logger() + + // https://spec.matrix.org/v1.10/client-server-api/#server-side-key-backups + // "Clients must only store keys in backups after they have ensured that the auth_data is trusted. This can be done either... + // ...by deriving the public key from a private key that it obtained from a trusted source. Trusted sources for the private + // key include the user entering the key, retrieving the key stored in secret storage, or obtaining the key via secret sharing + // from a verified device belonging to the same user." + if megolmBackupKey != nil { + megolmBackupDerivedPublicKey := id.Ed25519(base64.RawStdEncoding.EncodeToString(megolmBackupKey.PublicKey().Bytes())) + if versionInfo.AuthData.PublicKey == megolmBackupDerivedPublicKey { + log.Debug().Msg("Key backup is trusted based on derived public key") + return versionInfo, nil + } + log.Debug(). + Stringer("expected_key", megolmBackupDerivedPublicKey). + Stringer("actual_key", versionInfo.AuthData.PublicKey). + Msg("key backup public keys do not match, proceeding to check device signatures") + } + + // "...or checking that it is signed by the user’s master cross-signing key or by a verified device belonging to the same user" + userSignatures, ok := versionInfo.AuthData.Signatures[mach.Client.UserID] + if !ok { + return nil, fmt.Errorf("no signature from user %s found in key backup", mach.Client.UserID) + } + + crossSigningPubkeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } else if crossSigningPubkeys == nil { + return nil, ErrCrossSigningPubkeysNotCached + } + + signatureVerified := false + for keyID := range userSignatures { + keyAlg, keyName := keyID.Parse() + if keyAlg != id.KeyAlgorithmEd25519 { + continue + } + log := log.With().Str("key_name", keyName).Logger() + + var key id.Ed25519 + if keyName == crossSigningPubkeys.MasterKey.String() { + key = crossSigningPubkeys.MasterKey + } else if device, err := mach.CryptoStore.GetDevice(ctx, mach.Client.UserID, id.DeviceID(keyName)); err != nil { + return nil, fmt.Errorf("failed to get device %s/%s from store: %w", mach.Client.UserID, keyName, err) + } else if device == nil { + log.Warn().Err(err).Msg("Device does not exist, ignoring signature") + continue + } else if !mach.IsDeviceTrusted(ctx, device) { + log.Warn().Err(err).Msg("Device is not trusted") + continue + } else { + key = device.SigningKey + } + + ok, err = signatures.VerifySignatureJSON(versionInfo.AuthData, mach.Client.UserID, keyName, key) + if err != nil || !ok { + log.Warn().Err(err).Stringer("key_id", keyID).Msg("Signature verification failed") + continue + } else { + // One of the signatures is valid, break from the loop. + log.Debug().Stringer("key_id", keyID).Msg("key backup is trusted based on matching signature") + signatureVerified = true + break + } + } + if !signatureVerified { + return nil, fmt.Errorf("no valid signature from user %s found in key backup", mach.Client.UserID) + } + + return versionInfo, nil +} + +func (mach *OlmMachine) GetAndStoreKeyBackup(ctx context.Context, version id.KeyBackupVersion, megolmBackupKey *backup.MegolmBackupKey) error { + keys, err := mach.Client.GetKeyBackup(ctx, version) + if err != nil { + return err + } + + log := zerolog.Ctx(ctx) + + var count, failedCount int + + for roomID, backup := range keys.Rooms { + for sessionID, keyBackupData := range backup.Sessions { + sessionData, err := keyBackupData.SessionData.Decrypt(megolmBackupKey) + if err != nil { + log.Warn().Err(err).Msg("Failed to decrypt session data") + failedCount++ + continue + } + + _, err = mach.ImportRoomKeyFromBackup(ctx, version, roomID, sessionID, sessionData) + if err != nil { + log.Warn().Err(err).Msg("Failed to import room key from backup") + failedCount++ + continue + } + count++ + } + } + + log.Info(). + Int("count", count). + Int("failed_count", failedCount). + Msg("successfully imported sessions from backup") + + return nil +} + +var ( + ErrUnknownAlgorithmInKeyBackup = errors.New("ignoring room key in backup with weird algorithm") + ErrMismatchingSessionIDInKeyBackup = errors.New("mismatched session ID while creating inbound group session from key backup") + ErrFailedToStoreNewInboundGroupSessionFromBackup = errors.New("failed to store new inbound group session from key backup") +) + +func (mach *OlmMachine) ImportRoomKeyFromBackupWithoutSaving( + ctx context.Context, + version id.KeyBackupVersion, + roomID id.RoomID, + config *event.EncryptionEventContent, + sessionID id.SessionID, + keyBackupData *backup.MegolmSessionData, +) (*InboundGroupSession, error) { + log := zerolog.Ctx(ctx) + if keyBackupData.Algorithm != id.AlgorithmMegolmV1 { + return nil, fmt.Errorf("%w %s", ErrUnknownAlgorithmInKeyBackup, keyBackupData.Algorithm) + } + + igsInternal, err := olm.InboundGroupSessionImport([]byte(keyBackupData.SessionKey)) + if err != nil { + return nil, fmt.Errorf("failed to import inbound group session: %w", err) + } else if igsInternal.ID() != sessionID { + log.Warn(). + Stringer("room_id", roomID). + Stringer("session_id", sessionID). + Stringer("actual_session_id", igsInternal.ID()). + Msg("Mismatched session ID while creating inbound group session from key backup") + return nil, ErrMismatchingSessionIDInKeyBackup + } + + var maxAge time.Duration + var maxMessages int + if config != nil { + maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond + maxMessages = config.RotationPeriodMessages + } + + return &InboundGroupSession{ + Internal: igsInternal, + SigningKey: keyBackupData.SenderClaimedKeys.Ed25519, + SenderKey: keyBackupData.SenderKey, + RoomID: roomID, + ForwardingChains: keyBackupData.ForwardingKeyChain, + id: sessionID, + + ReceivedAt: time.Now().UTC(), + MaxAge: maxAge.Milliseconds(), + MaxMessages: maxMessages, + KeyBackupVersion: version, + KeySource: id.KeySourceBackup, + }, nil +} + +func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, keyBackupData *backup.MegolmSessionData) (*InboundGroupSession, error) { + config, err := mach.StateStore.GetEncryptionEvent(ctx, roomID) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("room_id", roomID). + Stringer("session_id", sessionID). + Msg("Failed to get encryption event for room") + } + imported, err := mach.ImportRoomKeyFromBackupWithoutSaving(ctx, version, roomID, config, sessionID, keyBackupData) + if err != nil { + return nil, err + } + firstKnownIndex := imported.Internal.FirstKnownIndex() + if firstKnownIndex > 0 { + zerolog.Ctx(ctx).Warn(). + Stringer("room_id", roomID). + Stringer("session_id", sessionID). + Uint32("first_known_index", firstKnownIndex). + Msg("Importing partial session") + } + err = mach.CryptoStore.PutGroupSession(ctx, imported) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToStoreNewInboundGroupSessionFromBackup, err) + } + mach.MarkSessionReceived(ctx, roomID, sessionID, firstKnownIndex) + return imported, nil +} diff --git a/mautrix-patched/crypto/keyexport.go b/mautrix-patched/crypto/keyexport.go new file mode 100644 index 00000000..1904c8a5 --- /dev/null +++ b/mautrix-patched/crypto/keyexport.go @@ -0,0 +1,204 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exbytes" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/random" + "golang.org/x/crypto/pbkdf2" + + "maunium.net/go/mautrix/id" +) + +var ErrNoSessionsForExport = errors.New("no sessions provided for export") + +type SenderClaimedKeys struct { + Ed25519 id.Ed25519 `json:"ed25519"` +} + +type ExportedSession struct { + Algorithm id.Algorithm `json:"algorithm"` + ForwardingChains []string `json:"forwarding_curve25519_key_chain"` + RoomID id.RoomID `json:"room_id"` + SenderKey id.SenderKey `json:"sender_key"` + SenderClaimedKeys SenderClaimedKeys `json:"sender_claimed_keys"` + SessionID id.SessionID `json:"session_id"` + SessionKey string `json:"session_key"` +} + +// The default number of pbkdf2 rounds to use when exporting keys +const defaultPassphraseRounds = 100000 + +const exportPrefix = "-----BEGIN MEGOLM SESSION DATA-----\n" +const exportSuffix = "-----END MEGOLM SESSION DATA-----\n" + +// Only version 0x01 is currently specified in the spec +const exportVersion1 = 0x01 + +// The standard for wrapping base64 is 76 bytes +const exportLineLengthLimit = 76 + +// Byte count for version + salt + iv + number of rounds +const exportHeaderLength = 1 + 16 + 16 + 4 + +// SHA-256 hash length +const exportHashLength = 32 + +func computeKey(passphrase string, salt []byte, rounds int) (encryptionKey, hashKey []byte) { + key := pbkdf2.Key([]byte(passphrase), salt, rounds, 64, sha512.New) + encryptionKey = key[:32] + hashKey = key[32:] + return +} + +func makeExportIV() []byte { + iv := random.Bytes(16) + // Set bit 63 to zero + iv[7] &= 0b11111110 + return iv +} + +func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte) { + salt = random.Bytes(16) + encryptionKey, hashKey = computeKey(passphrase, salt, defaultPassphraseRounds) + iv = makeExportIV() + return +} + +func exportSessions(sessions []*InboundGroupSession) ([]*ExportedSession, error) { + export := make([]*ExportedSession, len(sessions)) + var err error + for i, session := range sessions { + export[i], err = session.export() + if err != nil { + return nil, fmt.Errorf("failed to export session: %w", err) + } + } + return export, nil +} + +func exportSessionsJSON(sessions []*InboundGroupSession) ([]byte, error) { + exportedSessions, err := exportSessions(sessions) + if err != nil { + return nil, err + } + return json.Marshal(exportedSessions) +} + +func formatKeyExportData(data []byte) []byte { + encodedLen := base64.StdEncoding.EncodedLen(len(data)) + outputLength := len(exportPrefix) + + encodedLen + int(math.Ceil(float64(encodedLen)/exportLineLengthLimit)) + + len(exportSuffix) + output := make([]byte, 0, outputLength) + outputWriter := (*exbytes.Writer)(&output) + base64Writer := base64.NewEncoder(base64.StdEncoding, outputWriter) + lineByteCount := base64.StdEncoding.DecodedLen(exportLineLengthLimit) + exerrors.Must(outputWriter.WriteString(exportPrefix)) + for i := 0; i < len(data); i += lineByteCount { + exerrors.Must(base64Writer.Write(data[i:min(i+lineByteCount, len(data))])) + if i+lineByteCount >= len(data) { + exerrors.PanicIfNotNil(base64Writer.Close()) + } + exerrors.PanicIfNotNil(outputWriter.WriteByte('\n')) + } + exerrors.Must(outputWriter.WriteString(exportSuffix)) + if len(output) != outputLength { + panic(fmt.Errorf("unexpected length %d / %d", len(output), outputLength)) + } + return output +} + +func ExportKeysIter(passphrase string, sessions dbutil.RowIter[*InboundGroupSession]) ([]byte, error) { + buf := bytes.NewBuffer(make([]byte, 0, 50*1024)) + enc := json.NewEncoder(buf) + buf.WriteByte('[') + err := sessions.Iter(func(session *InboundGroupSession) (bool, error) { + exported, err := session.export() + if err != nil { + return false, err + } + err = enc.Encode(exported) + if err != nil { + return false, err + } + buf.WriteByte(',') + return true, nil + }) + if err != nil { + return nil, err + } + output := buf.Bytes() + if len(output) == 1 { + return nil, ErrNoSessionsForExport + } + output[len(output)-1] = ']' // Replace the last comma with a closing bracket + return EncryptKeyExport(passphrase, output) +} + +// ExportKeys exports the given Megolm sessions with the format specified in the Matrix spec. +// See https://spec.matrix.org/v1.2/client-server-api/#key-exports +func ExportKeys(passphrase string, sessions []*InboundGroupSession) ([]byte, error) { + if len(sessions) == 0 { + return nil, ErrNoSessionsForExport + } + // Export all the given sessions and put them in JSON + unencryptedData, err := exportSessionsJSON(sessions) + if err != nil { + return nil, err + } + return EncryptKeyExport(passphrase, unencryptedData) +} + +func EncryptKeyExport(passphrase string, unencryptedData json.RawMessage) ([]byte, error) { + // Make all the keys necessary for exporting + encryptionKey, hashKey, salt, iv := makeExportKeys(passphrase) + + // The export data consists of: + // 1 byte of export format version + // 16 bytes of salt + // 16 bytes of IV (initialization vector) + // 4 bytes of the number of rounds + // the encrypted export data + // 32 bytes of the hash of all the data above + + exportData := make([]byte, exportHeaderLength+len(unencryptedData)+exportHashLength) + dataWithoutHashLength := len(exportData) - exportHashLength + + // Create the header for the export data + exportData[0] = exportVersion1 + copy(exportData[1:17], salt) + copy(exportData[17:33], iv) + binary.BigEndian.PutUint32(exportData[33:37], defaultPassphraseRounds) + + // Encrypt data with AES-256-CTR + block, _ := aes.NewCipher(encryptionKey) + cipher.NewCTR(block, iv).XORKeyStream(exportData[exportHeaderLength:dataWithoutHashLength], unencryptedData) + + // Hash all the data with HMAC-SHA256 and put it at the end + mac := hmac.New(sha256.New, hashKey) + mac.Write(exportData[:dataWithoutHashLength]) + mac.Sum(exportData[:dataWithoutHashLength]) + + // Format the export (prefix, base64'd exportData, suffix) and return + return formatKeyExportData(exportData), nil +} diff --git a/mautrix-patched/crypto/keyexport_test.go b/mautrix-patched/crypto/keyexport_test.go new file mode 100644 index 00000000..fd6f105d --- /dev/null +++ b/mautrix-patched/crypto/keyexport_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exfmt" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/olm" +) + +func TestExportKeys(t *testing.T) { + acc := crypto.NewOlmAccount() + sess := exerrors.Must(crypto.NewInboundGroupSession( + acc.IdentityKey(), + acc.SigningKey(), + "!room:example.com", + exerrors.Must(olm.NewOutboundGroupSession()).Key(), + 7*exfmt.Day, + 100, + false, + )) + data, err := crypto.ExportKeys("meow", []*crypto.InboundGroupSession{sess}) + assert.NoError(t, err) + assert.Len(t, data, 893) +} diff --git a/mautrix-patched/crypto/keyimport.go b/mautrix-patched/crypto/keyimport.go new file mode 100644 index 00000000..3ffc74a5 --- /dev/null +++ b/mautrix-patched/crypto/keyimport.go @@ -0,0 +1,167 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "time" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +var ( + ErrMissingExportPrefix = errors.New("invalid Matrix key export: missing prefix") + ErrMissingExportSuffix = errors.New("invalid Matrix key export: missing suffix") + ErrUnsupportedExportVersion = errors.New("unsupported Matrix key export format version") + ErrMismatchingExportHash = errors.New("mismatching hash; incorrect passphrase?") + ErrInvalidExportedAlgorithm = errors.New("session has unknown algorithm") + ErrMismatchingExportedSessionID = errors.New("imported session has different ID than expected") +) + +var exportPrefixBytes, exportSuffixBytes = []byte(exportPrefix), []byte(exportSuffix) + +func decodeKeyExport(data []byte) ([]byte, error) { + // Fix some types of corruption in the key export file before checking anything + if bytes.IndexByte(data, '\r') != -1 { + data = bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'}) + } + // If the valid prefix and suffix aren't there, it's probably not a Matrix key export + if !bytes.HasPrefix(data, exportPrefixBytes) { + return nil, ErrMissingExportPrefix + } else if !bytes.HasSuffix(data, exportSuffixBytes) { + return nil, ErrMissingExportSuffix + } + // Remove the prefix and suffix, we don't care about them anymore + data = data[len(exportPrefix) : len(data)-len(exportSuffix)] + + // Allocate space for the decoded data. Ignore newlines when counting the length + exportData := make([]byte, base64.StdEncoding.DecodedLen(len(data)-bytes.Count(data, []byte{'\n'}))) + n, err := base64.StdEncoding.Decode(exportData, data) + if err != nil { + return nil, err + } + + return exportData[:n], nil +} + +func decryptKeyExport(passphrase string, exportData []byte) ([]ExportedSession, error) { + if exportData[0] != exportVersion1 { + return nil, ErrUnsupportedExportVersion + } + + // Get all the different parts of the export + salt := exportData[1:17] + iv := exportData[17:33] + passphraseRounds := binary.BigEndian.Uint32(exportData[33:37]) + dataWithoutHashLength := len(exportData) - exportHashLength + encryptedData := exportData[exportHeaderLength:dataWithoutHashLength] + hash := exportData[dataWithoutHashLength:] + + // Compute the encryption and hash keys from the passphrase and salt + encryptionKey, hashKey := computeKey(passphrase, salt, int(passphraseRounds)) + + // Compute and verify the hash. If it doesn't match, the passphrase is probably wrong + mac := hmac.New(sha256.New, hashKey) + mac.Write(exportData[:dataWithoutHashLength]) + if !bytes.Equal(hash, mac.Sum(nil)) { + return nil, ErrMismatchingExportHash + } + + // Decrypt the export + block, _ := aes.NewCipher(encryptionKey) + unencryptedData := make([]byte, len(exportData)-exportHashLength-exportHeaderLength) + cipher.NewCTR(block, iv).XORKeyStream(unencryptedData, encryptedData) + + // Parse the decrypted JSON + var sessionsJSON []ExportedSession + err := json.Unmarshal(unencryptedData, &sessionsJSON) + if err != nil { + return nil, fmt.Errorf("invalid export json: %w", err) + } + return sessionsJSON, nil +} + +func (mach *OlmMachine) importExportedRoomKey(ctx context.Context, session ExportedSession) (bool, error) { + if session.Algorithm != id.AlgorithmMegolmV1 { + return false, ErrInvalidExportedAlgorithm + } + + igsInternal, err := olm.InboundGroupSessionImport([]byte(session.SessionKey)) + if err != nil { + return false, fmt.Errorf("failed to import session: %w", err) + } else if igsInternal.ID() != session.SessionID { + return false, ErrMismatchingExportedSessionID + } + igs := &InboundGroupSession{ + Internal: igsInternal, + SigningKey: session.SenderClaimedKeys.Ed25519, + SenderKey: session.SenderKey, + RoomID: session.RoomID, + ForwardingChains: session.ForwardingChains, + KeySource: id.KeySourceImport, + ReceivedAt: time.Now().UTC(), + } + existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) + firstKnownIndex := igs.Internal.FirstKnownIndex() + if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= firstKnownIndex { + // We already have an equivalent or better session in the store, so don't override it, + // but do notify the session received callback just in case. + mach.MarkSessionReceived(ctx, session.RoomID, igs.ID(), existingIGS.Internal.FirstKnownIndex()) + return false, nil + } + err = mach.CryptoStore.PutGroupSession(ctx, igs) + if err != nil { + return false, fmt.Errorf("failed to store imported session: %w", err) + } + mach.MarkSessionReceived(ctx, session.RoomID, igs.ID(), firstKnownIndex) + return true, nil +} + +// ImportKeys imports data that was exported with the format specified in the Matrix spec. +// See https://spec.matrix.org/v1.2/client-server-api/#key-exports +func (mach *OlmMachine) ImportKeys(ctx context.Context, passphrase string, data []byte) (int, int, error) { + exportData, err := decodeKeyExport(data) + if err != nil { + return 0, 0, err + } + sessions, err := decryptKeyExport(passphrase, exportData) + if err != nil { + return 0, 0, err + } + + count := 0 + for _, session := range sessions { + log := mach.Log.With(). + Str("room_id", session.RoomID.String()). + Str("session_id", session.SessionID.String()). + Logger() + imported, err := mach.importExportedRoomKey(ctx, session) + if err != nil { + if ctx.Err() != nil { + return count, len(sessions), ctx.Err() + } + log.Error().Err(err).Msg("Failed to import Megolm session from file") + } else if imported { + log.Debug().Msg("Imported Megolm session from file") + count++ + } else { + log.Debug().Msg("Skipped Megolm session which is already in the store") + } + } + return count, len(sessions), nil +} diff --git a/mautrix-patched/crypto/keysharing.go b/mautrix-patched/crypto/keysharing.go new file mode 100644 index 00000000..f5a8ccc5 --- /dev/null +++ b/mautrix-patched/crypto/keysharing.go @@ -0,0 +1,423 @@ +// Copyright (c) 2020 Nikos Filippakis +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +type KeyShareRejection struct { + Code event.RoomKeyWithheldCode + Reason string +} + +var ( + // Reject a key request without responding + KeyShareRejectNoResponse = KeyShareRejection{} + + KeyShareRejectBlacklisted = KeyShareRejection{event.RoomKeyWithheldBlacklisted, "You have been blacklisted by this device"} + KeyShareRejectUnverified = KeyShareRejection{event.RoomKeyWithheldUnverified, "This device does not share keys to unverified devices"} + KeyShareRejectOtherUser = KeyShareRejection{event.RoomKeyWithheldUnauthorized, "This device does not share keys to other users"} + KeyShareRejectNotRecipient = KeyShareRejection{event.RoomKeyWithheldUnauthorized, "You were not in the original recipient list for that session, or that session didn't originate from this device"} + KeyShareRejectUnavailable = KeyShareRejection{event.RoomKeyWithheldUnavailable, "Requested session ID not found on this device"} + KeyShareRejectInternalError = KeyShareRejection{event.RoomKeyWithheldUnavailable, "An internal error occurred while trying to share the requested session"} +) + +// RequestRoomKey sends a key request for a room to the current user's devices. If the context is cancelled, then so is the key request. +// Returns a bool channel that will get notified either when the key is received or the request is cancelled. +// +// Deprecated: this only supports a single key request target, so the whole automatic cancelling feature isn't very useful. +func (mach *OlmMachine) RequestRoomKey(ctx context.Context, toUser id.UserID, toDevice id.DeviceID, + roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID) (chan bool, error) { + + requestID := mach.Client.TxnID() + keyResponseReceived := make(chan struct{}) + mach.roomKeyRequestFilled.Store(sessionID, keyResponseReceived) + + err := mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, requestID, map[id.UserID][]id.DeviceID{toUser: {toDevice}}) + if err != nil { + return nil, err + } + + resChan := make(chan bool, 1) + go func() { + select { + case <-keyResponseReceived: + // key request successful + mach.Log.Debug(). + Stringer("session_id", sessionID). + Msg("Key for session was received, cancelling other key requests") + resChan <- true + case <-ctx.Done(): + // if the context is done, key request was unsuccessful + mach.Log.Debug().Err(err). + Stringer("session_id", sessionID). + Msg("Context closed before forwarded key for session received, sending key request cancellation") + resChan <- false + } + + // send a message to all devices cancelling this key request + mach.roomKeyRequestFilled.Delete(sessionID) + + cancelEvtContent := &event.Content{ + Parsed: event.RoomKeyRequestEventContent{ + Action: event.KeyRequestActionCancel, + RequestID: requestID, + RequestingDeviceID: mach.Client.DeviceID, + }, + } + + toDeviceCancel := &mautrix.ReqSendToDevice{ + Messages: map[id.UserID]map[id.DeviceID]*event.Content{ + toUser: { + toDevice: cancelEvtContent, + }, + }, + } + + mach.Client.SendToDevice(ctx, event.ToDeviceRoomKeyRequest, toDeviceCancel) + }() + return resChan, nil +} + +// SendRoomKeyRequest sends a key request for the given key (identified by the room ID, sender key and session ID) to the given users. +// +// The request ID parameter is optional. If it's empty, a random ID will be generated. +// +// This function does not wait for the keys to arrive. You can use WaitForSession to wait for the session to +// arrive (in any way, not just as a reply to this request). There's also RequestRoomKey which waits for a response +// to the specific key request, but currently it only supports a single target device and is therefore deprecated. +// A future function may properly support multiple targets and automatically canceling the other requests when receiving +// the first response. +func (mach *OlmMachine) SendRoomKeyRequest(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, requestID string, users map[id.UserID][]id.DeviceID) error { + if len(requestID) == 0 { + requestID = mach.Client.TxnID() + } + requestEvent := &event.Content{ + Parsed: &event.RoomKeyRequestEventContent{ + Action: event.KeyRequestActionRequest, + Body: event.RequestedKeyInfo{ + Algorithm: id.AlgorithmMegolmV1, + RoomID: roomID, + SenderKey: senderKey, + SessionID: sessionID, + }, + RequestID: requestID, + RequestingDeviceID: mach.Client.DeviceID, + }, + } + + toDeviceReq := &mautrix.ReqSendToDevice{ + Messages: make(map[id.UserID]map[id.DeviceID]*event.Content, len(users)), + } + for user, devices := range users { + toDeviceReq.Messages[user] = make(map[id.DeviceID]*event.Content, len(devices)) + for _, device := range devices { + toDeviceReq.Messages[user][device] = requestEvent + } + } + _, err := mach.Client.SendToDevice(ctx, event.ToDeviceRoomKeyRequest, toDeviceReq) + return err +} + +func (mach *OlmMachine) importForwardedRoomKey(ctx context.Context, evt *DecryptedOlmEvent, content *event.ForwardedRoomKeyEventContent) bool { + log := zerolog.Ctx(ctx).With(). + Str("session_id", content.SessionID.String()). + Str("room_id", content.RoomID.String()). + Logger() + if content.Algorithm != id.AlgorithmMegolmV1 || evt.Keys.Ed25519 == "" { + log.Debug(). + Str("algorithm", string(content.Algorithm)). + Msg("Ignoring weird forwarded room key") + return false + } + + igsInternal, err := olm.InboundGroupSessionImport([]byte(content.SessionKey)) + if err != nil { + log.Error().Err(err).Msg("Failed to import inbound group session") + return false + } else if igsInternal.ID() != content.SessionID { + log.Warn(). + Str("actual_session_id", igsInternal.ID().String()). + Msg("Mismatched session ID while creating inbound group session from forward") + return false + } + config, err := mach.StateStore.GetEncryptionEvent(ctx, content.RoomID) + if err != nil { + log.Error().Err(err).Msg("Failed to get encryption event for room") + } + var maxAge time.Duration + var maxMessages int + if config != nil { + maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond + maxMessages = config.RotationPeriodMessages + } + if content.MaxAge != 0 { + maxAge = time.Duration(content.MaxAge) * time.Millisecond + } + if content.MaxMessages != 0 { + maxMessages = content.MaxMessages + } + firstKnownIndex := igsInternal.FirstKnownIndex() + if firstKnownIndex > 0 { + log.Warn().Uint32("first_known_index", firstKnownIndex).Msg("Importing partial session") + } + igs := &InboundGroupSession{ + Internal: igsInternal, + SigningKey: content.SenderClaimedKey, + SenderKey: content.SenderKey, + RoomID: content.RoomID, + ForwardingChains: append(content.ForwardingKeyChain, evt.SenderKey.String()), + id: content.SessionID, + + ReceivedAt: time.Now().UTC(), + MaxAge: maxAge.Milliseconds(), + MaxMessages: maxMessages, + IsScheduled: content.IsScheduled, + KeySource: id.KeySourceForward, + } + existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) + if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= igs.Internal.FirstKnownIndex() { + // We already have an equivalent or better session in the store, so don't override it. + return false + } + err = mach.CryptoStore.PutGroupSession(ctx, igs) + if err != nil { + log.Error().Err(err).Msg("Failed to store new inbound group session") + return false + } + mach.MarkSessionReceived(ctx, content.RoomID, content.SessionID, firstKnownIndex) + log.Debug().Msg("Received forwarded inbound group session") + return true +} + +func (mach *OlmMachine) rejectKeyRequest(ctx context.Context, rejection KeyShareRejection, device *id.Device, request event.RequestedKeyInfo) { + if rejection.Code == "" { + // If the rejection code is empty, it means don't share keys, but also don't tell the requester. + return + } + content := event.RoomKeyWithheldEventContent{ + RoomID: request.RoomID, + Algorithm: request.Algorithm, + SessionID: request.SessionID, + //lint:ignore SA1019 This is just echoing back the deprecated field + SenderKey: request.SenderKey, + Code: rejection.Code, + Reason: rejection.Reason, + } + err := mach.sendToOneDevice(ctx, device.UserID, device.DeviceID, event.ToDeviceRoomKeyWithheld, &content) + if err != nil { + mach.Log.Warn().Err(err). + Str("code", string(rejection.Code)). + Str("user_id", device.UserID.String()). + Str("device_id", device.DeviceID.String()). + Msg("Failed to send key share rejection") + } + err = mach.sendToOneDevice(ctx, device.UserID, device.DeviceID, event.ToDeviceOrgMatrixRoomKeyWithheld, &content) + if err != nil { + mach.Log.Warn().Err(err). + Str("code", string(rejection.Code)). + Str("user_id", device.UserID.String()). + Str("device_id", device.DeviceID.String()). + Msg("Failed to send key share rejection (legacy event type)") + } +} + +// sendToOneDevice sends a to-device event to a single device. +func (mach *OlmMachine) sendToOneDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID, eventType event.Type, content interface{}) error { + _, err := mach.Client.SendToDevice(ctx, eventType, &mautrix.ReqSendToDevice{ + Messages: map[id.UserID]map[id.DeviceID]*event.Content{ + userID: { + deviceID: { + Parsed: content, + }, + }, + }, + }) + + return err +} + +func (mach *OlmMachine) defaultAllowKeyShare(ctx context.Context, device *id.Device, evt event.RequestedKeyInfo) *KeyShareRejection { + log := mach.machOrContextLog(ctx) + if mach.Client.UserID != device.UserID { + if mach.DisableSharedGroupSessionTracking { + log.Debug().Msg("Rejecting key request from another user as recipient list tracking is disabled") + return &KeyShareRejectOtherUser + } + isShared, err := mach.CryptoStore.IsOutboundGroupSessionShared(ctx, device.UserID, device.IdentityKey, evt.SessionID) + if err != nil { + log.Err(err).Msg("Rejecting key request due to internal error when checking session sharing") + return &KeyShareRejectNoResponse + } else if !isShared { + igs, _ := mach.CryptoStore.GetGroupSession(ctx, evt.RoomID, evt.SessionID) + if igs != nil && igs.SenderKey == mach.OwnIdentity().IdentityKey { + log.Debug().Msg("Rejecting key request for unshared session") + return &KeyShareRejectNotRecipient + } + // Note: this case will also happen for redacted sessions and database errors + log.Debug().Msg("Rejecting key request for session created by another device") + return &KeyShareRejectNoResponse + } + log.Debug().Msg("Accepting key request for shared session") + return nil + } else if mach.Client.DeviceID == device.DeviceID { + log.Debug().Msg("Ignoring key request from ourselves") + return &KeyShareRejectNoResponse + } else if device.Trust == id.TrustStateBlacklisted { + log.Debug().Msg("Rejecting key request from blacklisted device") + return &KeyShareRejectBlacklisted + } else if trustState, _ := mach.ResolveTrustContext(ctx, device); trustState >= mach.ShareKeysMinTrust { + log.Debug(). + Str("min_trust", mach.SendKeysMinTrust.String()). + Str("device_trust", trustState.String()). + Msg("Accepting key request from trusted device") + return nil + } else { + log.Debug(). + Str("min_trust", mach.SendKeysMinTrust.String()). + Str("device_trust", trustState.String()). + Msg("Rejecting key request from untrusted device") + return &KeyShareRejectUnverified + } +} + +func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.UserID, content *event.RoomKeyRequestEventContent) { + log := zerolog.Ctx(ctx).With(). + Str("request_id", content.RequestID). + Str("device_id", content.RequestingDeviceID.String()). + Str("room_id", content.Body.RoomID.String()). + Str("session_id", content.Body.SessionID.String()). + Logger() + ctx = log.WithContext(ctx) + if content.Action != event.KeyRequestActionRequest { + return + } else if content.RequestingDeviceID == mach.Client.DeviceID && sender == mach.Client.UserID { + log.Debug().Msg("Ignoring key request from ourselves") + return + } + + log.Debug().Msg("Received key request") + + device, err := mach.GetOrFetchDevice(ctx, sender, content.RequestingDeviceID) + if err != nil { + log.Error().Err(err).Msg("Failed to fetch device that requested keys") + return + } + + rejection := mach.AllowKeyShare(ctx, device, content.Body) + if rejection != nil { + mach.rejectKeyRequest(ctx, *rejection, device, content.Body) + return + } + + igs, err := mach.CryptoStore.GetGroupSession(ctx, content.Body.RoomID, content.Body.SessionID) + if err != nil { + if errors.Is(err, ErrGroupSessionWithheld) { + log.Debug().Err(err).Msg("Requested group session not available") + if sender != mach.Client.UserID { + mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) + } + } else { + log.Error().Err(err).Msg("Failed to get group session to forward") + mach.rejectKeyRequest(ctx, KeyShareRejectInternalError, device, content.Body) + } + return + } else if igs == nil { + log.Error().Msg("Didn't find group session to forward") + if sender != mach.Client.UserID { + mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) + } + return + } + if internalID := igs.ID(); internalID != content.Body.SessionID { + // Should this be an error? + log = log.With().Stringer("unexpected_session_id", internalID).Logger() + } + + firstKnownIndex := igs.Internal.FirstKnownIndex() + log = log.With().Uint32("first_known_index", firstKnownIndex).Logger() + exportedKey, err := igs.Internal.Export(firstKnownIndex) + if err != nil { + log.Error().Err(err).Msg("Failed to export group session to forward") + mach.rejectKeyRequest(ctx, KeyShareRejectInternalError, device, content.Body) + return + } + + forwardedRoomKey := event.Content{ + Parsed: &event.ForwardedRoomKeyEventContent{ + RoomKeyEventContent: event.RoomKeyEventContent{ + Algorithm: id.AlgorithmMegolmV1, + RoomID: igs.RoomID, + SessionID: igs.ID(), + SessionKey: string(exportedKey), + }, + SenderKey: igs.SenderKey, + ForwardingKeyChain: igs.ForwardingChains, + SenderClaimedKey: igs.SigningKey, + }, + } + + if err = mach.SendEncryptedToDevice(ctx, device, event.ToDeviceForwardedRoomKey, forwardedRoomKey); err != nil { + log.Error().Err(err).Msg("Failed to encrypt and send group session") + } else { + log.Debug().Msg("Successfully sent forwarded group session") + } +} + +func (mach *OlmMachine) HandleBeeperRoomKeyAck(ctx context.Context, sender id.UserID, content *event.BeeperRoomKeyAckEventContent) { + // Room key acks are only used on Beeper. The server will send an ack when the user uploads a key to key backup. + // No special authentication is needed. The server is single-tenant, so DoS isn't a concern. + // On any other servers, don't do anything. + if !mach.DeleteOutboundKeysOnAck { + return + } + log := mach.machOrContextLog(ctx).With(). + Str("room_id", content.RoomID.String()). + Str("session_id", content.SessionID.String()). + Int("first_message_index", content.FirstMessageIndex). + Logger() + + sess, err := mach.CryptoStore.GetGroupSession(ctx, content.RoomID, content.SessionID) + if err != nil { + if errors.Is(err, ErrGroupSessionWithheld) { + log.Debug().Err(err).Msg("Acked group session was already redacted") + } else { + log.Err(err).Msg("Failed to get group session to check if it should be redacted") + } + return + } else if sess == nil { + log.Warn().Msg("Got key backup ack for unknown session") + return + } + log = log.With(). + Str("sender_key", sess.SenderKey.String()). + Str("own_identity", mach.OwnIdentity().IdentityKey.String()). + Logger() + + isInbound := sess.SenderKey == mach.OwnIdentity().IdentityKey + if isInbound && content.FirstMessageIndex == 0 { + log.Debug().Msg("Redacting inbound copy of outbound group session after ack") + err = mach.CryptoStore.RedactGroupSession(ctx, content.RoomID, content.SessionID, "outbound session acked") + if err != nil { + log.Err(err).Msg("Failed to redact group session") + } + } else { + log.Debug().Bool("inbound", isInbound).Msg("Received room key ack") + } +} diff --git a/mautrix-patched/crypto/libolm/account.go b/mautrix-patched/crypto/libolm/account.go new file mode 100644 index 00000000..0350f083 --- /dev/null +++ b/mautrix-patched/crypto/libolm/account.go @@ -0,0 +1,419 @@ +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +import "C" + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "runtime" + "unsafe" + + "github.com/tidwall/gjson" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +// Account stores a device account for end to end encrypted messaging. +type Account struct { + int *C.OlmAccount + mem []byte +} + +// Ensure that [Account] implements [olm.Account]. +var _ olm.Account = (*Account)(nil) + +// AccountFromPickled loads an Account from a pickled base64 string. Decrypts +// the Account using the supplied key. Returns error on failure. If the key +// doesn't match the one used to encrypt the Account then the error will be +// "BAD_ACCOUNT_KEY". If the base64 couldn't be decoded then the error will be +// "INVALID_BASE64". +func AccountFromPickled(pickled, key []byte) (*Account, error) { + if len(pickled) == 0 { + return nil, olm.ErrEmptyInput + } + a := NewBlankAccount() + return a, a.Unpickle(pickled, key) +} + +func NewBlankAccount() *Account { + memory := make([]byte, accountSize()) + return &Account{ + int: C.olm_account(unsafe.Pointer(unsafe.SliceData(memory))), + mem: memory, + } +} + +// NewAccount creates a new [Account]. +func NewAccount() (*Account, error) { + a := NewBlankAccount() + random := make([]byte, a.createRandomLen()+1) + _, err := rand.Read(random) + if err != nil { + panic(olm.ErrNotEnoughGoRandom) + } + ret := C.olm_create_account( + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(random)), + C.size_t(len(random))) + runtime.KeepAlive(random) + if ret == errorVal() { + return nil, a.lastError() + } else { + return a, nil + } +} + +// accountSize returns the size of an account object in bytes. +func accountSize() uint { + return uint(C.olm_account_size()) +} + +// lastError returns an error describing the most recent error to happen to an +// account. +func (a *Account) lastError() error { + return convertError(C.GoString(C.olm_account_last_error((*C.OlmAccount)(a.int)))) +} + +// Clear clears the memory used to back this Account. +func (a *Account) Clear() error { + r := C.olm_clear_account((*C.OlmAccount)(a.int)) + if r == errorVal() { + return a.lastError() + } else { + return nil + } +} + +// pickleLen returns the number of bytes needed to store an Account. +func (a *Account) pickleLen() uint { + return uint(C.olm_pickle_account_length((*C.OlmAccount)(a.int))) +} + +// createRandomLen returns the number of random bytes needed to create an +// Account. +func (a *Account) createRandomLen() uint { + return uint(C.olm_create_account_random_length((*C.OlmAccount)(a.int))) +} + +// identityKeysLen returns the size of the output buffer needed to hold the +// identity keys. +func (a *Account) identityKeysLen() uint { + return uint(C.olm_account_identity_keys_length((*C.OlmAccount)(a.int))) +} + +// signatureLen returns the length of an ed25519 signature encoded as base64. +func (a *Account) signatureLen() uint { + return uint(C.olm_account_signature_length((*C.OlmAccount)(a.int))) +} + +// oneTimeKeysLen returns the size of the output buffer needed to hold the one +// time keys. +func (a *Account) oneTimeKeysLen() uint { + return uint(C.olm_account_one_time_keys_length((*C.OlmAccount)(a.int))) +} + +// genOneTimeKeysRandomLen returns the number of random bytes needed to +// generate a given number of new one time keys. +func (a *Account) genOneTimeKeysRandomLen(num uint) uint { + return uint(C.olm_account_generate_one_time_keys_random_length( + (*C.OlmAccount)(a.int), + C.size_t(num))) +} + +// Pickle returns an Account as a base64 string. Encrypts the Account using the +// supplied key. +func (a *Account) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + pickled := make([]byte, a.pickleLen()) + r := C.olm_pickle_account( + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled))) + if r == errorVal() { + return nil, a.lastError() + } + return pickled[:r], nil +} + +func (a *Account) Unpickle(pickled, key []byte) error { + if len(key) == 0 { + return olm.ErrNoKeyProvided + } + r := C.olm_unpickle_account( + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled))) + if r == errorVal() { + return a.lastError() + } + return nil +} + +// Deprecated +func (a *Account) GobEncode() ([]byte, error) { + pickled, err := a.Pickle(pickleKey) + if err != nil { + return nil, err + } + length := base64.RawStdEncoding.DecodedLen(len(pickled)) + rawPickled := make([]byte, length) + _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) + return rawPickled, err +} + +// Deprecated +func (a *Account) GobDecode(rawPickled []byte) error { + if a.int == nil { + *a = *NewBlankAccount() + } + length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) + pickled := make([]byte, length) + base64.RawStdEncoding.Encode(pickled, rawPickled) + return a.Unpickle(pickled, pickleKey) +} + +// Deprecated +func (a *Account) MarshalJSON() ([]byte, error) { + pickled, err := a.Pickle(pickleKey) + if err != nil { + return nil, err + } + quotes := make([]byte, len(pickled)+2) + quotes[0] = '"' + quotes[len(quotes)-1] = '"' + copy(quotes[1:len(quotes)-1], pickled) + return quotes, nil +} + +// Deprecated +func (a *Account) UnmarshalJSON(data []byte) error { + if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { + return olm.ErrInputNotJSONString + } + if a.int == nil { + *a = *NewBlankAccount() + } + return a.Unpickle(data[1:len(data)-1], pickleKey) +} + +// IdentityKeysJSON returns the public parts of the identity keys for the Account. +func (a *Account) IdentityKeysJSON() ([]byte, error) { + identityKeys := make([]byte, a.identityKeysLen()) + r := C.olm_account_identity_keys( + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(identityKeys)), + C.size_t(len(identityKeys))) + if r == errorVal() { + return nil, a.lastError() + } else { + return identityKeys, nil + } +} + +// IdentityKeys returns the public parts of the Ed25519 and Curve25519 identity +// keys for the Account. +func (a *Account) IdentityKeys() (id.Ed25519, id.Curve25519, error) { + identityKeysJSON, err := a.IdentityKeysJSON() + if err != nil { + return "", "", err + } + results := gjson.GetManyBytes(identityKeysJSON, "ed25519", "curve25519") + return id.Ed25519(results[0].Str), id.Curve25519(results[1].Str), nil +} + +// Sign returns the signature of a message using the ed25519 key for this +// Account. +func (a *Account) Sign(message []byte) ([]byte, error) { + if len(message) == 0 { + panic(olm.ErrEmptyInput) + } + signature := make([]byte, a.signatureLen()) + r := C.olm_account_sign( + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(message)), + C.size_t(len(message)), + unsafe.Pointer(unsafe.SliceData(signature)), + C.size_t(len(signature))) + runtime.KeepAlive(message) + if r == errorVal() { + panic(a.lastError()) + } + return signature, nil +} + +// OneTimeKeys returns the public parts of the unpublished one time keys for +// the Account. +// +// The returned data is a struct with the single value "Curve25519", which is +// itself an object mapping key id to base64-encoded Curve25519 key. For +// example: +// +// { +// Curve25519: { +// "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo", +// "AAAAAB": "LRvjo46L1X2vx69sS9QNFD29HWulxrmW11Up5AfAjgU" +// } +// } +func (a *Account) OneTimeKeys() (map[string]id.Curve25519, error) { + oneTimeKeysJSON := make([]byte, a.oneTimeKeysLen()) + r := C.olm_account_one_time_keys( + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(oneTimeKeysJSON)), + C.size_t(len(oneTimeKeysJSON)), + ) + if r == errorVal() { + return nil, a.lastError() + } + var oneTimeKeys struct { + Curve25519 map[string]id.Curve25519 `json:"curve25519"` + } + return oneTimeKeys.Curve25519, json.Unmarshal(oneTimeKeysJSON, &oneTimeKeys) +} + +// MarkKeysAsPublished marks the current set of one time keys as being +// published. +func (a *Account) MarkKeysAsPublished() { + C.olm_account_mark_keys_as_published((*C.OlmAccount)(a.int)) +} + +// MaxNumberOfOneTimeKeys returns the largest number of one time keys this +// Account can store. +func (a *Account) MaxNumberOfOneTimeKeys() uint { + return uint(C.olm_account_max_number_of_one_time_keys((*C.OlmAccount)(a.int))) +} + +// GenOneTimeKeys generates a number of new one time keys. If the total number +// of keys stored by this Account exceeds MaxNumberOfOneTimeKeys then the old +// keys are discarded. +func (a *Account) GenOneTimeKeys(num uint) error { + random := make([]byte, a.genOneTimeKeysRandomLen(num)+1) + _, err := rand.Read(random) + if err != nil { + return olm.ErrNotEnoughGoRandom + } + r := C.olm_account_generate_one_time_keys( + (*C.OlmAccount)(a.int), + C.size_t(num), + unsafe.Pointer(unsafe.SliceData(random)), + C.size_t(len(random)), + ) + runtime.KeepAlive(random) + if r == errorVal() { + return a.lastError() + } + return nil +} + +// NewOutboundSession creates a new out-bound session for sending messages to a +// given curve25519 identityKey and oneTimeKey. Returns error on failure. If the +// keys couldn't be decoded as base64 then the error will be "INVALID_BASE64" +func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (olm.Session, error) { + if len(theirIdentityKey) == 0 || len(theirOneTimeKey) == 0 { + return nil, olm.ErrEmptyInput + } + s := NewBlankSession() + random := make([]byte, s.createOutboundRandomLen()+1) + _, err := rand.Read(random) + if err != nil { + panic(olm.ErrNotEnoughGoRandom) + } + theirIdentityKeyCopy := []byte(theirIdentityKey) + theirOneTimeKeyCopy := []byte(theirOneTimeKey) + r := C.olm_create_outbound_session( + (*C.OlmSession)(s.int), + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), + C.size_t(len(theirIdentityKeyCopy)), + unsafe.Pointer(unsafe.SliceData(theirOneTimeKeyCopy)), + C.size_t(len(theirOneTimeKeyCopy)), + unsafe.Pointer(unsafe.SliceData(random)), + C.size_t(len(random)), + ) + runtime.KeepAlive(random) + runtime.KeepAlive(theirIdentityKeyCopy) + runtime.KeepAlive(theirOneTimeKeyCopy) + if r == errorVal() { + return nil, s.lastError() + } + return s, nil +} + +// NewInboundSession creates a new in-bound session for sending/receiving +// messages from an incoming PRE_KEY message. Returns error on failure. If +// the base64 couldn't be decoded then the error will be "INVALID_BASE64". If +// the message was for an unsupported protocol version then the error will be +// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the +// error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one +// time key then the error will be "BAD_MESSAGE_KEY_ID". +func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { + if len(oneTimeKeyMsg) == 0 { + return nil, olm.ErrEmptyInput + } + s := NewBlankSession() + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) + r := C.olm_create_inbound_session( + (*C.OlmSession)(s.int), + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(oneTimeKeyMsgCopy) + if r == errorVal() { + return nil, s.lastError() + } + return s, nil +} + +// NewInboundSessionFrom creates a new in-bound session for sending/receiving +// messages from an incoming PRE_KEY message. Returns error on failure. If +// the base64 couldn't be decoded then the error will be "INVALID_BASE64". If +// the message was for an unsupported protocol version then the error will be +// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the +// error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one +// time key then the error will be "BAD_MESSAGE_KEY_ID". +func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (olm.Session, error) { + if theirIdentityKey == nil || len(oneTimeKeyMsg) == 0 { + return nil, olm.ErrEmptyInput + } + theirIdentityKeyCopy := []byte(*theirIdentityKey) + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) + s := NewBlankSession() + r := C.olm_create_inbound_session_from( + (*C.OlmSession)(s.int), + (*C.OlmAccount)(a.int), + unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), + C.size_t(len(theirIdentityKeyCopy)), + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(theirIdentityKeyCopy) + runtime.KeepAlive(oneTimeKeyMsgCopy) + if r == errorVal() { + return nil, s.lastError() + } + return s, nil +} + +// RemoveOneTimeKeys removes the one time keys that the session used from the +// Account. Returns error on failure. If the Account doesn't have any +// matching one time keys then the error will be "BAD_MESSAGE_KEY_ID". +func (a *Account) RemoveOneTimeKeys(s olm.Session) error { + r := C.olm_remove_one_time_keys( + (*C.OlmAccount)(a.int), + (*C.OlmSession)(s.(*Session).int), + ) + if r == errorVal() { + return a.lastError() + } + return nil +} diff --git a/mautrix-patched/crypto/libolm/error.go b/mautrix-patched/crypto/libolm/error.go new file mode 100644 index 00000000..6fb5512b --- /dev/null +++ b/mautrix-patched/crypto/libolm/error.go @@ -0,0 +1,37 @@ +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +import "C" + +import ( + "fmt" + + "maunium.net/go/mautrix/crypto/olm" +) + +var errorMap = map[string]error{ + "NOT_ENOUGH_RANDOM": olm.ErrLibolmNotEnoughRandom, + "OUTPUT_BUFFER_TOO_SMALL": olm.ErrLibolmOutputBufferTooSmall, + "BAD_MESSAGE_VERSION": olm.ErrWrongProtocolVersion, + "BAD_MESSAGE_FORMAT": olm.ErrBadMessageFormat, + "BAD_MESSAGE_MAC": olm.ErrBadMAC, + "BAD_MESSAGE_KEY_ID": olm.ErrBadMessageKeyID, + "INVALID_BASE64": olm.ErrLibolmInvalidBase64, + "BAD_ACCOUNT_KEY": olm.ErrLibolmBadAccountKey, + "UNKNOWN_PICKLE_VERSION": olm.ErrUnknownOlmPickleVersion, + "CORRUPTED_PICKLE": olm.ErrLibolmCorruptedPickle, + "BAD_SESSION_KEY": olm.ErrLibolmBadSessionKey, + "UNKNOWN_MESSAGE_INDEX": olm.ErrUnknownMessageIndex, + "BAD_LEGACY_ACCOUNT_PICKLE": olm.ErrLibolmBadLegacyAccountPickle, + "BAD_SIGNATURE": olm.ErrBadSignature, + "INPUT_BUFFER_TOO_SMALL": olm.ErrInputToSmall, +} + +func convertError(errCode string) error { + err, ok := errorMap[errCode] + if ok { + return err + } + return fmt.Errorf("unknown error: %s", errCode) +} diff --git a/mautrix-patched/crypto/libolm/inboundgroupsession.go b/mautrix-patched/crypto/libolm/inboundgroupsession.go new file mode 100644 index 00000000..8815ac32 --- /dev/null +++ b/mautrix-patched/crypto/libolm/inboundgroupsession.go @@ -0,0 +1,327 @@ +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +import "C" + +import ( + "bytes" + "encoding/base64" + "runtime" + "unsafe" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +// InboundGroupSession stores an inbound encrypted messaging session for a +// group. +type InboundGroupSession struct { + int *C.OlmInboundGroupSession + mem []byte +} + +// Ensure that [InboundGroupSession] implements [olm.InboundGroupSession]. +var _ olm.InboundGroupSession = (*InboundGroupSession)(nil) + +// InboundGroupSessionFromPickled loads an InboundGroupSession from a pickled +// base64 string. Decrypts the InboundGroupSession using the supplied key. +// Returns error on failure. If the key doesn't match the one used to encrypt +// the InboundGroupSession then the error will be "BAD_SESSION_KEY". If the +// base64 couldn't be decoded then the error will be "INVALID_BASE64". +func InboundGroupSessionFromPickled(pickled, key []byte) (*InboundGroupSession, error) { + if len(pickled) == 0 { + return nil, olm.ErrEmptyInput + } + lenKey := len(key) + if lenKey == 0 { + key = []byte(" ") + } + s := NewBlankInboundGroupSession() + return s, s.Unpickle(pickled, key) +} + +// NewInboundGroupSession creates a new inbound group session from a key +// exported from OutboundGroupSession.Key(). Returns error on failure. +// If the sessionKey is not valid base64 the error will be +// "OLM_INVALID_BASE64". If the session_key is invalid the error will be +// "OLM_BAD_SESSION_KEY". +func NewInboundGroupSession(sessionKey []byte) (*InboundGroupSession, error) { + if len(sessionKey) == 0 { + return nil, olm.ErrEmptyInput + } + s := NewBlankInboundGroupSession() + r := C.olm_init_inbound_group_session( + (*C.OlmInboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), + C.size_t(len(sessionKey)), + ) + runtime.KeepAlive(sessionKey) + if r == errorVal() { + return nil, s.lastError() + } + return s, nil +} + +// InboundGroupSessionImport imports an inbound group session from a previous +// export. Returns error on failure. If the sessionKey is not valid base64 +// the error will be "OLM_INVALID_BASE64". If the session_key is invalid the +// error will be "OLM_BAD_SESSION_KEY". +func InboundGroupSessionImport(sessionKey []byte) (*InboundGroupSession, error) { + if len(sessionKey) == 0 { + return nil, olm.ErrEmptyInput + } + s := NewBlankInboundGroupSession() + r := C.olm_import_inbound_group_session( + (*C.OlmInboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), + C.size_t(len(sessionKey)), + ) + runtime.KeepAlive(sessionKey) + if r == errorVal() { + return nil, s.lastError() + } + return s, nil +} + +// inboundGroupSessionSize is the size of an inbound group session object in +// bytes. +func inboundGroupSessionSize() uint { + return uint(C.olm_inbound_group_session_size()) +} + +// newInboundGroupSession initialises an empty InboundGroupSession. +func NewBlankInboundGroupSession() *InboundGroupSession { + memory := make([]byte, inboundGroupSessionSize()) + return &InboundGroupSession{ + int: C.olm_inbound_group_session(unsafe.Pointer(unsafe.SliceData(memory))), + mem: memory, + } +} + +// lastError returns an error describing the most recent error to happen to an +// inbound group session. +func (s *InboundGroupSession) lastError() error { + return convertError(C.GoString(C.olm_inbound_group_session_last_error((*C.OlmInboundGroupSession)(s.int)))) +} + +// Clear clears the memory used to back this InboundGroupSession. +func (s *InboundGroupSession) Clear() error { + r := C.olm_clear_inbound_group_session((*C.OlmInboundGroupSession)(s.int)) + if r == errorVal() { + return s.lastError() + } + return nil +} + +// pickleLen returns the number of bytes needed to store an inbound group +// session. +func (s *InboundGroupSession) pickleLen() uint { + return uint(C.olm_pickle_inbound_group_session_length((*C.OlmInboundGroupSession)(s.int))) +} + +// Pickle returns an InboundGroupSession as a base64 string. Encrypts the +// InboundGroupSession using the supplied key. +func (s *InboundGroupSession) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + pickled := make([]byte, s.pickleLen()) + r := C.olm_pickle_inbound_group_session( + (*C.OlmInboundGroupSession)(s.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(key) + if r == errorVal() { + return nil, s.lastError() + } + return pickled[:r], nil +} + +func (s *InboundGroupSession) Unpickle(pickled, key []byte) error { + if len(key) == 0 { + return olm.ErrNoKeyProvided + } else if len(pickled) == 0 { + return olm.ErrEmptyInput + } + r := C.olm_unpickle_inbound_group_session( + (*C.OlmInboundGroupSession)(s.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(key) + if r == errorVal() { + return s.lastError() + } + return nil +} + +// Deprecated +func (s *InboundGroupSession) GobEncode() ([]byte, error) { + pickled, err := s.Pickle(pickleKey) + if err != nil { + return nil, err + } + length := base64.RawStdEncoding.DecodedLen(len(pickled)) + rawPickled := make([]byte, length) + _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) + return rawPickled, err +} + +// Deprecated +func (s *InboundGroupSession) GobDecode(rawPickled []byte) error { + if s == nil || s.int == nil { + *s = *NewBlankInboundGroupSession() + } + length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) + pickled := make([]byte, length) + base64.RawStdEncoding.Encode(pickled, rawPickled) + return s.Unpickle(pickled, pickleKey) +} + +// Deprecated +func (s *InboundGroupSession) MarshalJSON() ([]byte, error) { + pickled, err := s.Pickle(pickleKey) + if err != nil { + return nil, err + } + quotes := make([]byte, len(pickled)+2) + quotes[0] = '"' + quotes[len(quotes)-1] = '"' + copy(quotes[1:len(quotes)-1], pickled) + return quotes, nil +} + +// Deprecated +func (s *InboundGroupSession) UnmarshalJSON(data []byte) error { + if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { + return olm.ErrInputNotJSONString + } + if s == nil || s.int == nil { + *s = *NewBlankInboundGroupSession() + } + return s.Unpickle(data[1:len(data)-1], pickleKey) +} + +// decryptMaxPlaintextLen returns the maximum number of bytes of plain-text a +// given message could decode to. The actual size could be different due to +// padding. Returns error on failure. If the message base64 couldn't be +// decoded then the error will be "INVALID_BASE64". If the message is for an +// unsupported version of the protocol then the error will be +// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error +// will be "BAD_MESSAGE_FORMAT". +func (s *InboundGroupSession) decryptMaxPlaintextLen(message []byte) (uint, error) { + if len(message) == 0 { + return 0, olm.ErrEmptyInput + } + // olm_group_decrypt_max_plaintext_length destroys the input, so we have to clone it + messageCopy := bytes.Clone(message) + r := C.olm_group_decrypt_max_plaintext_length( + (*C.OlmInboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(messageCopy))), + C.size_t(len(messageCopy)), + ) + runtime.KeepAlive(messageCopy) + if r == errorVal() { + return 0, s.lastError() + } + return uint(r), nil +} + +// Decrypt decrypts a message using the InboundGroupSession. Returns the the +// plain-text and message index on success. Returns error on failure. If the +// base64 couldn't be decoded then the error will be "INVALID_BASE64". If the +// message is for an unsupported version of the protocol then the error will be +// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error +// will be BAD_MESSAGE_FORMAT". If the MAC on the message was invalid then the +// error will be "BAD_MESSAGE_MAC". If we do not have a session key +// corresponding to the message's index (ie, it was sent before the session key +// was shared with us) the error will be "OLM_UNKNOWN_MESSAGE_INDEX". +func (s *InboundGroupSession) Decrypt(message []byte) ([]byte, uint, error) { + if len(message) == 0 { + return nil, 0, olm.ErrEmptyInput + } + decryptMaxPlaintextLen, err := s.decryptMaxPlaintextLen(message) + if err != nil { + return nil, 0, err + } + messageCopy := bytes.Clone(message) + plaintext := make([]byte, decryptMaxPlaintextLen) + var messageIndex uint32 + r := C.olm_group_decrypt( + (*C.OlmInboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(messageCopy))), + C.size_t(len(messageCopy)), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(plaintext))), + C.size_t(len(plaintext)), + (*C.uint32_t)(unsafe.Pointer(&messageIndex)), + ) + runtime.KeepAlive(messageCopy) + if r == errorVal() { + return nil, 0, s.lastError() + } + return plaintext[:r], uint(messageIndex), nil +} + +// sessionIdLen returns the number of bytes needed to store a session ID. +func (s *InboundGroupSession) sessionIdLen() uint { + return uint(C.olm_inbound_group_session_id_length((*C.OlmInboundGroupSession)(s.int))) +} + +// ID returns a base64-encoded identifier for this session. +func (s *InboundGroupSession) ID() id.SessionID { + sessionID := make([]byte, s.sessionIdLen()) + r := C.olm_inbound_group_session_id( + (*C.OlmInboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionID))), + C.size_t(len(sessionID)), + ) + if r == errorVal() { + panic(s.lastError()) + } + return id.SessionID(sessionID[:r]) +} + +// FirstKnownIndex returns the first message index we know how to decrypt. +func (s *InboundGroupSession) FirstKnownIndex() uint32 { + return uint32(C.olm_inbound_group_session_first_known_index((*C.OlmInboundGroupSession)(s.int))) +} + +// IsVerified check if the session has been verified as a valid session. (A +// session is verified either because the original session share was signed, or +// because we have subsequently successfully decrypted a message.) +func (s *InboundGroupSession) IsVerified() bool { + return uint(C.olm_inbound_group_session_is_verified((*C.OlmInboundGroupSession)(s.int))) == 1 +} + +// exportLen returns the number of bytes needed to export an inbound group +// session. +func (s *InboundGroupSession) exportLen() uint { + return uint(C.olm_export_inbound_group_session_length((*C.OlmInboundGroupSession)(s.int))) +} + +// Export returns the base64-encoded ratchet key for this session, at the given +// index, in a format which can be used by +// InboundGroupSession.InboundGroupSessionImport(). Encrypts the +// InboundGroupSession using the supplied key. Returns error on failure. +// if we do not have a session key corresponding to the given index (ie, it was +// sent before the session key was shared with us) the error will be +// "OLM_UNKNOWN_MESSAGE_INDEX". +func (s *InboundGroupSession) Export(messageIndex uint32) ([]byte, error) { + key := make([]byte, s.exportLen()) + r := C.olm_export_inbound_group_session( + (*C.OlmInboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(key))), + C.size_t(len(key)), + C.uint32_t(messageIndex), + ) + if r == errorVal() { + return nil, s.lastError() + } + return key[:r], nil +} diff --git a/mautrix-patched/crypto/libolm/libolm.go b/mautrix-patched/crypto/libolm/libolm.go new file mode 100644 index 00000000..18815767 --- /dev/null +++ b/mautrix-patched/crypto/libolm/libolm.go @@ -0,0 +1,10 @@ +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +import "C" + +// errorVal returns the value that olm functions return if there was an error. +func errorVal() C.size_t { + return C.olm_error() +} diff --git a/mautrix-patched/crypto/libolm/outboundgroupsession.go b/mautrix-patched/crypto/libolm/outboundgroupsession.go new file mode 100644 index 00000000..ca5b68f7 --- /dev/null +++ b/mautrix-patched/crypto/libolm/outboundgroupsession.go @@ -0,0 +1,245 @@ +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +import "C" + +import ( + "crypto/rand" + "encoding/base64" + "runtime" + "unsafe" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +// OutboundGroupSession stores an outbound encrypted messaging session +// for a group. +type OutboundGroupSession struct { + int *C.OlmOutboundGroupSession + mem []byte +} + +// Ensure that [OutboundGroupSession] implements [olm.OutboundGroupSession]. +var _ olm.OutboundGroupSession = (*OutboundGroupSession)(nil) + +func NewOutboundGroupSession() (*OutboundGroupSession, error) { + s := NewBlankOutboundGroupSession() + random := make([]byte, s.createRandomLen()+1) + _, err := rand.Read(random) + if err != nil { + return nil, err + } + r := C.olm_init_outbound_group_session( + (*C.OlmOutboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(random))), + C.size_t(len(random)), + ) + runtime.KeepAlive(random) + if r == errorVal() { + return nil, s.lastError() + } + return s, nil +} + +// outboundGroupSessionSize is the size of an outbound group session object in +// bytes. +func outboundGroupSessionSize() uint { + return uint(C.olm_outbound_group_session_size()) +} + +// NewBlankOutboundGroupSession initialises an empty [OutboundGroupSession]. +func NewBlankOutboundGroupSession() *OutboundGroupSession { + memory := make([]byte, outboundGroupSessionSize()) + return &OutboundGroupSession{ + int: C.olm_outbound_group_session(unsafe.Pointer(unsafe.SliceData(memory))), + mem: memory, + } +} + +// lastError returns an error describing the most recent error to happen to an +// outbound group session. +func (s *OutboundGroupSession) lastError() error { + return convertError(C.GoString(C.olm_outbound_group_session_last_error((*C.OlmOutboundGroupSession)(s.int)))) +} + +// Clear clears the memory used to back this OutboundGroupSession. +func (s *OutboundGroupSession) Clear() error { + r := C.olm_clear_outbound_group_session((*C.OlmOutboundGroupSession)(s.int)) + if r == errorVal() { + return s.lastError() + } else { + return nil + } +} + +// pickleLen returns the number of bytes needed to store an outbound group +// session. +func (s *OutboundGroupSession) pickleLen() uint { + return uint(C.olm_pickle_outbound_group_session_length((*C.OlmOutboundGroupSession)(s.int))) +} + +// Pickle returns an OutboundGroupSession as a base64 string. Encrypts the +// OutboundGroupSession using the supplied key. +func (s *OutboundGroupSession) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + pickled := make([]byte, s.pickleLen()) + r := C.olm_pickle_outbound_group_session( + (*C.OlmOutboundGroupSession)(s.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(key) + if r == errorVal() { + return nil, s.lastError() + } + return pickled[:r], nil +} + +func (s *OutboundGroupSession) Unpickle(pickled, key []byte) error { + if len(key) == 0 { + return olm.ErrNoKeyProvided + } + r := C.olm_unpickle_outbound_group_session( + (*C.OlmOutboundGroupSession)(s.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled)), + ) + runtime.KeepAlive(pickled) + runtime.KeepAlive(key) + if r == errorVal() { + return s.lastError() + } + return nil +} + +// Deprecated +func (s *OutboundGroupSession) GobEncode() ([]byte, error) { + pickled, err := s.Pickle(pickleKey) + if err != nil { + return nil, err + } + length := base64.RawStdEncoding.DecodedLen(len(pickled)) + rawPickled := make([]byte, length) + _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) + return rawPickled, err +} + +// Deprecated +func (s *OutboundGroupSession) GobDecode(rawPickled []byte) error { + if s == nil || s.int == nil { + *s = *NewBlankOutboundGroupSession() + } + length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) + pickled := make([]byte, length) + base64.RawStdEncoding.Encode(pickled, rawPickled) + return s.Unpickle(pickled, pickleKey) +} + +// Deprecated +func (s *OutboundGroupSession) MarshalJSON() ([]byte, error) { + pickled, err := s.Pickle(pickleKey) + if err != nil { + return nil, err + } + quotes := make([]byte, len(pickled)+2) + quotes[0] = '"' + quotes[len(quotes)-1] = '"' + copy(quotes[1:len(quotes)-1], pickled) + return quotes, nil +} + +// Deprecated +func (s *OutboundGroupSession) UnmarshalJSON(data []byte) error { + if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { + return olm.ErrInputNotJSONString + } + if s == nil || s.int == nil { + *s = *NewBlankOutboundGroupSession() + } + return s.Unpickle(data[1:len(data)-1], pickleKey) +} + +// createRandomLen returns the number of random bytes needed to create an +// Account. +func (s *OutboundGroupSession) createRandomLen() uint { + return uint(C.olm_init_outbound_group_session_random_length((*C.OlmOutboundGroupSession)(s.int))) +} + +// encryptMsgLen returns the size of the next message in bytes for the given +// number of plain-text bytes. +func (s *OutboundGroupSession) encryptMsgLen(plainTextLen int) uint { + return uint(C.olm_group_encrypt_message_length((*C.OlmOutboundGroupSession)(s.int), C.size_t(plainTextLen))) +} + +// Encrypt encrypts a message using the Session. Returns the encrypted message +// as base64. +func (s *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { + if len(plaintext) == 0 { + return nil, olm.ErrEmptyInput + } + message := make([]byte, s.encryptMsgLen(len(plaintext))) + r := C.olm_group_encrypt( + (*C.OlmOutboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(plaintext))), + C.size_t(len(plaintext)), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(message))), + C.size_t(len(message)), + ) + runtime.KeepAlive(plaintext) + if r == errorVal() { + return nil, s.lastError() + } + return message[:r], nil +} + +// sessionIdLen returns the number of bytes needed to store a session ID. +func (s *OutboundGroupSession) sessionIdLen() uint { + return uint(C.olm_outbound_group_session_id_length((*C.OlmOutboundGroupSession)(s.int))) +} + +// ID returns a base64-encoded identifier for this session. +func (s *OutboundGroupSession) ID() id.SessionID { + sessionID := make([]byte, s.sessionIdLen()) + r := C.olm_outbound_group_session_id( + (*C.OlmOutboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionID))), + C.size_t(len(sessionID)), + ) + if r == errorVal() { + panic(s.lastError()) + } + return id.SessionID(sessionID[:r]) +} + +// MessageIndex returns the message index for this session. Each message is +// sent with an increasing index; this returns the index for the next message. +func (s *OutboundGroupSession) MessageIndex() uint { + return uint(C.olm_outbound_group_session_message_index((*C.OlmOutboundGroupSession)(s.int))) +} + +// sessionKeyLen returns the number of bytes needed to store a session key. +func (s *OutboundGroupSession) sessionKeyLen() uint { + return uint(C.olm_outbound_group_session_key_length((*C.OlmOutboundGroupSession)(s.int))) +} + +// Key returns the base64-encoded current ratchet key for this session. +func (s *OutboundGroupSession) Key() string { + sessionKey := make([]byte, s.sessionKeyLen()) + r := C.olm_outbound_group_session_key( + (*C.OlmOutboundGroupSession)(s.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), + C.size_t(len(sessionKey)), + ) + if r == errorVal() { + panic(s.lastError()) + } + return string(sessionKey[:r]) +} diff --git a/mautrix-patched/crypto/libolm/pk.go b/mautrix-patched/crypto/libolm/pk.go new file mode 100644 index 00000000..7ef9d468 --- /dev/null +++ b/mautrix-patched/crypto/libolm/pk.go @@ -0,0 +1,149 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +// #include +import "C" + +import ( + "crypto/rand" + "fmt" + "runtime" + "unsafe" + + "github.com/tidwall/sjson" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +// PKSigning stores a key pair for signing messages. +type PKSigning struct { + int *C.OlmPkSigning + mem []byte + publicKey id.Ed25519 + seed []byte +} + +// Ensure that [PKSigning] implements [olm.PKSigning]. +var _ olm.PKSigning = (*PKSigning)(nil) + +func pkSigningSize() uint { + return uint(C.olm_pk_signing_size()) +} + +func pkSigningSeedLength() uint { + return uint(C.olm_pk_signing_seed_length()) +} + +func pkSigningPublicKeyLength() uint { + return uint(C.olm_pk_signing_public_key_length()) +} + +func pkSigningSignatureLength() uint { + return uint(C.olm_pk_signature_length()) +} + +func newBlankPKSigning() *PKSigning { + memory := make([]byte, pkSigningSize()) + return &PKSigning{ + int: C.olm_pk_signing(unsafe.Pointer(unsafe.SliceData(memory))), + mem: memory, + } +} + +// NewPKSigningFromSeed creates a new [PKSigning] object using the given seed. +func NewPKSigningFromSeed(seed []byte) (*PKSigning, error) { + p := newBlankPKSigning() + p.clear() + pubKey := make([]byte, pkSigningPublicKeyLength()) + r := C.olm_pk_signing_key_from_seed( + (*C.OlmPkSigning)(p.int), + unsafe.Pointer(unsafe.SliceData(pubKey)), + C.size_t(len(pubKey)), + unsafe.Pointer(unsafe.SliceData(seed)), + C.size_t(len(seed)), + ) + if r == errorVal() { + return nil, p.lastError() + } + p.publicKey = id.Ed25519(pubKey) + p.seed = seed + return p, nil +} + +// NewPKSigning creates a new [PKSigning] object, containing a key pair for +// signing messages. +func NewPKSigning() (*PKSigning, error) { + // Generate the seed + seed := make([]byte, pkSigningSeedLength()) + _, err := rand.Read(seed) + if err != nil { + panic(olm.ErrNotEnoughGoRandom) + } + pk, err := NewPKSigningFromSeed(seed) + return pk, err +} + +func (p *PKSigning) PublicKey() id.Ed25519 { + return p.publicKey +} + +func (p *PKSigning) Seed() []byte { + return p.seed +} + +// clear clears the underlying memory of a [PKSigning] object. +func (p *PKSigning) clear() { + C.olm_clear_pk_signing((*C.OlmPkSigning)(p.int)) +} + +// Sign creates a signature for the given message using this key. +func (p *PKSigning) Sign(message []byte) ([]byte, error) { + signature := make([]byte, pkSigningSignatureLength()) + r := C.olm_pk_sign( + (*C.OlmPkSigning)(p.int), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(message))), + C.size_t(len(message)), + (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(signature))), + C.size_t(len(signature)), + ) + runtime.KeepAlive(message) + if r == errorVal() { + return nil, p.lastError() + } + return signature, nil +} + +// SignJSON creates a signature for the given object after encoding it to canonical JSON. +func (p *PKSigning) SignJSON(obj interface{}) (string, error) { + objJSON, err := canonicaljson.Marshal(obj) + if err != nil { + return "", err + } + objJSON, _ = sjson.DeleteBytes(objJSON, "unsigned") + objJSON, _ = sjson.DeleteBytes(objJSON, "signatures") + // This is probably not necessary + err = canonicaljson.Canonicalize(&objJSON) + if err != nil { + return "", fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) + } + signature, err := p.Sign(objJSON) + if err != nil { + return "", err + } + return string(signature), nil +} + +// lastError returns the last error that happened in relation to this +// [PKSigning] object. +func (p *PKSigning) lastError() error { + return convertError(C.GoString(C.olm_pk_signing_last_error((*C.OlmPkSigning)(p.int)))) +} diff --git a/mautrix-patched/crypto/libolm/register.go b/mautrix-patched/crypto/libolm/register.go new file mode 100644 index 00000000..6f4086f3 --- /dev/null +++ b/mautrix-patched/crypto/libolm/register.go @@ -0,0 +1,72 @@ +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +import "C" +import ( + "unsafe" + + "maunium.net/go/mautrix/crypto/olm" +) + +var pickleKey = []byte("maunium.net/go/mautrix/crypto/olm") + +func Register() { + olm.Driver = "libolm" + + olm.GetVersion = func() (major, minor, patch uint8) { + C.olm_get_library_version( + (*C.uint8_t)(unsafe.Pointer(&major)), + (*C.uint8_t)(unsafe.Pointer(&minor)), + (*C.uint8_t)(unsafe.Pointer(&patch))) + return 3, 2, 15 + } + olm.SetPickleKeyImpl = func(key []byte) { + pickleKey = key + } + + olm.InitNewAccount = func() (olm.Account, error) { + return NewAccount() + } + olm.InitBlankAccount = func() olm.Account { + return NewBlankAccount() + } + olm.InitNewAccountFromPickled = func(pickled, key []byte) (olm.Account, error) { + return AccountFromPickled(pickled, key) + } + + olm.InitSessionFromPickled = func(pickled, key []byte) (olm.Session, error) { + return SessionFromPickled(pickled, key) + } + olm.InitNewBlankSession = func() olm.Session { + return NewBlankSession() + } + + olm.InitNewPKSigning = func() (olm.PKSigning, error) { return NewPKSigning() } + olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { + return NewPKSigningFromSeed(seed) + } + + olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { + return InboundGroupSessionFromPickled(pickled, key) + } + olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { + return NewInboundGroupSession(sessionKey) + } + olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { + return InboundGroupSessionImport(sessionKey) + } + olm.InitBlankInboundGroupSession = func() olm.InboundGroupSession { + return NewBlankInboundGroupSession() + } + + olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { + if len(pickled) == 0 { + return nil, olm.ErrEmptyInput + } + s := NewBlankOutboundGroupSession() + return s, s.Unpickle(pickled, key) + } + olm.InitNewOutboundGroupSession = func() (olm.OutboundGroupSession, error) { return NewOutboundGroupSession() } + olm.InitNewBlankOutboundGroupSession = func() olm.OutboundGroupSession { return NewBlankOutboundGroupSession() } +} diff --git a/mautrix-patched/crypto/libolm/session.go b/mautrix-patched/crypto/libolm/session.go new file mode 100644 index 00000000..1441df26 --- /dev/null +++ b/mautrix-patched/crypto/libolm/session.go @@ -0,0 +1,401 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package libolm + +// #cgo LDFLAGS: -lolm -lstdc++ +// #include +// #include +// #include +// void olm_session_describe(OlmSession * session, char *buf, size_t buflen) __attribute__((weak)); +// void meowlm_session_describe(OlmSession * session, char *buf, size_t buflen) { +// if (olm_session_describe) { +// olm_session_describe(session, buf, buflen); +// } else { +// sprintf(buf, "olm_session_describe not supported"); +// } +// } +import "C" + +import ( + "crypto/rand" + "encoding/base64" + "runtime" + "unsafe" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +// Session stores an end to end encrypted messaging session. +type Session struct { + int *C.OlmSession + mem []byte +} + +// Ensure that [Session] implements [olm.Session]. +var _ olm.Session = (*Session)(nil) + +// sessionSize is the size of a session object in bytes. +func sessionSize() uint { + return uint(C.olm_session_size()) +} + +// SessionFromPickled loads a Session from a pickled base64 string. Decrypts +// the Session using the supplied key. Returns error on failure. If the key +// doesn't match the one used to encrypt the Session then the error will be +// "BAD_SESSION_KEY". If the base64 couldn't be decoded then the error will be +// "INVALID_BASE64". +func SessionFromPickled(pickled, key []byte) (*Session, error) { + if len(pickled) == 0 { + return nil, olm.ErrEmptyInput + } + s := NewBlankSession() + return s, s.Unpickle(pickled, key) +} + +func NewBlankSession() *Session { + memory := make([]byte, sessionSize()) + return &Session{ + int: C.olm_session(unsafe.Pointer(unsafe.SliceData(memory))), + mem: memory, + } +} + +// lastError returns an error describing the most recent error to happen to a +// session. +func (s *Session) lastError() error { + return convertError(C.GoString(C.olm_session_last_error((*C.OlmSession)(s.int)))) +} + +// Clear clears the memory used to back this Session. +func (s *Session) Clear() error { + r := C.olm_clear_session((*C.OlmSession)(s.int)) + if r == errorVal() { + return s.lastError() + } + return nil +} + +// pickleLen returns the number of bytes needed to store a session. +func (s *Session) pickleLen() uint { + return uint(C.olm_pickle_session_length((*C.OlmSession)(s.int))) +} + +// createOutboundRandomLen returns the number of random bytes needed to create +// an outbound session. +func (s *Session) createOutboundRandomLen() uint { + return uint(C.olm_create_outbound_session_random_length((*C.OlmSession)(s.int))) +} + +// idLen returns the length of the buffer needed to return the id for this +// session. +func (s *Session) idLen() uint { + return uint(C.olm_session_id_length((*C.OlmSession)(s.int))) +} + +// encryptRandomLen returns the number of random bytes needed to encrypt the +// next message. +func (s *Session) encryptRandomLen() uint { + return uint(C.olm_encrypt_random_length((*C.OlmSession)(s.int))) +} + +// encryptMsgLen returns the size of the next message in bytes for the given +// number of plain-text bytes. +func (s *Session) encryptMsgLen(plainTextLen int) uint { + return uint(C.olm_encrypt_message_length((*C.OlmSession)(s.int), C.size_t(plainTextLen))) +} + +// decryptMaxPlaintextLen returns the maximum number of bytes of plain-text a +// given message could decode to. The actual size could be different due to +// padding. Returns error on failure. If the message base64 couldn't be +// decoded then the error will be "INVALID_BASE64". If the message is for an +// unsupported version of the protocol then the error will be +// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error +// will be "BAD_MESSAGE_FORMAT". +func (s *Session) decryptMaxPlaintextLen(message string, msgType id.OlmMsgType) (uint, error) { + if len(message) == 0 { + return 0, olm.ErrEmptyInput + } + messageCopy := []byte(message) + r := C.olm_decrypt_max_plaintext_length( + (*C.OlmSession)(s.int), + C.size_t(msgType), + unsafe.Pointer(unsafe.SliceData((messageCopy))), + C.size_t(len(messageCopy)), + ) + runtime.KeepAlive(messageCopy) + if r == errorVal() { + return 0, s.lastError() + } + return uint(r), nil +} + +// Pickle returns a Session as a base64 string. Encrypts the Session using the +// supplied key. +func (s *Session) Pickle(key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, olm.ErrNoKeyProvided + } + pickled := make([]byte, s.pickleLen()) + r := C.olm_pickle_session( + (*C.OlmSession)(s.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled))) + runtime.KeepAlive(key) + if r == errorVal() { + panic(s.lastError()) + } + return pickled[:r], nil +} + +// Unpickle unpickles the base64-encoded Olm session decrypting it with the +// provided key. This function mutates the input pickled data slice. +func (s *Session) Unpickle(pickled, key []byte) error { + if len(key) == 0 { + return olm.ErrNoKeyProvided + } + r := C.olm_unpickle_session( + (*C.OlmSession)(s.int), + unsafe.Pointer(unsafe.SliceData(key)), + C.size_t(len(key)), + unsafe.Pointer(unsafe.SliceData(pickled)), + C.size_t(len(pickled))) + runtime.KeepAlive(pickled) + runtime.KeepAlive(key) + if r == errorVal() { + return s.lastError() + } + return nil +} + +// Deprecated +func (s *Session) GobEncode() ([]byte, error) { + pickled, err := s.Pickle(pickleKey) + if err != nil { + return nil, err + } + length := base64.RawStdEncoding.DecodedLen(len(pickled)) + rawPickled := make([]byte, length) + _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) + return rawPickled, err +} + +// Deprecated +func (s *Session) GobDecode(rawPickled []byte) error { + if s == nil || s.int == nil { + *s = *NewBlankSession() + } + length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) + pickled := make([]byte, length) + base64.RawStdEncoding.Encode(pickled, rawPickled) + return s.Unpickle(pickled, pickleKey) +} + +// Deprecated +func (s *Session) MarshalJSON() ([]byte, error) { + pickled, err := s.Pickle(pickleKey) + if err != nil { + return nil, err + } + quotes := make([]byte, len(pickled)+2) + quotes[0] = '"' + quotes[len(quotes)-1] = '"' + copy(quotes[1:len(quotes)-1], pickled) + return quotes, nil +} + +// Deprecated +func (s *Session) UnmarshalJSON(data []byte) error { + if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { + return olm.ErrInputNotJSONString + } + if s == nil || s.int == nil { + *s = *NewBlankSession() + } + return s.Unpickle(data[1:len(data)-1], pickleKey) +} + +// Id returns an identifier for this Session. Will be the same for both ends +// of the conversation. +func (s *Session) ID() id.SessionID { + sessionID := make([]byte, s.idLen()) + r := C.olm_session_id( + (*C.OlmSession)(s.int), + unsafe.Pointer(unsafe.SliceData(sessionID)), + C.size_t(len(sessionID)), + ) + if r == errorVal() { + panic(s.lastError()) + } + return id.SessionID(sessionID) +} + +// HasReceivedMessage returns true if this session has received any message. +func (s *Session) HasReceivedMessage() bool { + switch C.olm_session_has_received_message((*C.OlmSession)(s.int)) { + case 0: + return false + default: + return true + } +} + +// MatchesInboundSession checks if the PRE_KEY message is for this in-bound +// Session. This can happen if multiple messages are sent to this Account +// before this Account sends a message in reply. Returns true if the session +// matches. Returns false if the session does not match. Returns error on +// failure. If the base64 couldn't be decoded then the error will be +// "INVALID_BASE64". If the message was for an unsupported protocol version +// then the error will be "BAD_MESSAGE_VERSION". If the message couldn't be +// decoded then then the error will be "BAD_MESSAGE_FORMAT". +func (s *Session) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { + if len(oneTimeKeyMsg) == 0 { + return false, olm.ErrEmptyInput + } + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) + r := C.olm_matches_inbound_session( + (*C.OlmSession)(s.int), + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(oneTimeKeyMsgCopy) + if r == 1 { + return true, nil + } else if r == 0 { + return false, nil + } else { // if r == errorVal() + return false, s.lastError() + } +} + +// MatchesInboundSessionFrom checks if the PRE_KEY message is for this in-bound +// Session. This can happen if multiple messages are sent to this Account +// before this Account sends a message in reply. Returns true if the session +// matches. Returns false if the session does not match. Returns error on +// failure. If the base64 couldn't be decoded then the error will be +// "INVALID_BASE64". If the message was for an unsupported protocol version +// then the error will be "BAD_MESSAGE_VERSION". If the message couldn't be +// decoded then then the error will be "BAD_MESSAGE_FORMAT". +func (s *Session) MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) { + if len(theirIdentityKey) == 0 || len(oneTimeKeyMsg) == 0 { + return false, olm.ErrEmptyInput + } + theirIdentityKeyCopy := []byte(theirIdentityKey) + oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) + r := C.olm_matches_inbound_session_from( + (*C.OlmSession)(s.int), + unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), + C.size_t(len(theirIdentityKeyCopy)), + unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), + C.size_t(len(oneTimeKeyMsgCopy)), + ) + runtime.KeepAlive(theirIdentityKeyCopy) + runtime.KeepAlive(oneTimeKeyMsgCopy) + if r == 1 { + return true, nil + } else if r == 0 { + return false, nil + } else { // if r == errorVal() + return false, s.lastError() + } +} + +// EncryptMsgType returns the type of the next message that Encrypt will +// return. Returns MsgTypePreKey if the message will be a PRE_KEY message. +// Returns MsgTypeMsg if the message will be a normal message. Returns error +// on failure. +func (s *Session) EncryptMsgType() id.OlmMsgType { + switch C.olm_encrypt_message_type((*C.OlmSession)(s.int)) { + case C.size_t(id.OlmMsgTypePreKey): + return id.OlmMsgTypePreKey + case C.size_t(id.OlmMsgTypeMsg): + return id.OlmMsgTypeMsg + default: + panic("olm_encrypt_message_type returned invalid result") + } +} + +// Encrypt encrypts a message using the Session. Returns the encrypted message +// as base64. +func (s *Session) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { + if len(plaintext) == 0 { + return 0, nil, olm.ErrEmptyInput + } + // Make the slice be at least length 1 + random := make([]byte, s.encryptRandomLen()+1) + _, err := rand.Read(random) + if err != nil { + // TODO can we just return err here? + return 0, nil, olm.ErrNotEnoughGoRandom + } + messageType := s.EncryptMsgType() + message := make([]byte, s.encryptMsgLen(len(plaintext))) + r := C.olm_encrypt( + (*C.OlmSession)(s.int), + unsafe.Pointer(unsafe.SliceData(plaintext)), + C.size_t(len(plaintext)), + unsafe.Pointer(unsafe.SliceData(random)), + C.size_t(len(random)), + unsafe.Pointer(unsafe.SliceData(message)), + C.size_t(len(message)), + ) + runtime.KeepAlive(plaintext) + runtime.KeepAlive(random) + if r == errorVal() { + return 0, nil, s.lastError() + } + return messageType, message[:r], nil +} + +// Decrypt decrypts a message using the Session. Returns the the plain-text on +// success. Returns error on failure. If the base64 couldn't be decoded then +// the error will be "INVALID_BASE64". If the message is for an unsupported +// version of the protocol then the error will be "BAD_MESSAGE_VERSION". If +// the message couldn't be decoded then the error will be BAD_MESSAGE_FORMAT". +// If the MAC on the message was invalid then the error will be +// "BAD_MESSAGE_MAC". +func (s *Session) Decrypt(message string, msgType id.OlmMsgType) ([]byte, error) { + if len(message) == 0 { + return nil, olm.ErrEmptyInput + } + decryptMaxPlaintextLen, err := s.decryptMaxPlaintextLen(message, msgType) + if err != nil { + return nil, err + } + messageCopy := []byte(message) + plaintext := make([]byte, decryptMaxPlaintextLen) + r := C.olm_decrypt( + (*C.OlmSession)(s.int), + C.size_t(msgType), + unsafe.Pointer(unsafe.SliceData(messageCopy)), + C.size_t(len(messageCopy)), + unsafe.Pointer(unsafe.SliceData(plaintext)), + C.size_t(len(plaintext)), + ) + runtime.KeepAlive(messageCopy) + if r == errorVal() { + return nil, s.lastError() + } + return plaintext[:r], nil +} + +// https://gitlab.matrix.org/matrix-org/olm/-/blob/3.2.8/include/olm/olm.h#L392-393 +const maxDescribeSize = 600 + +// Describe generates a string describing the internal state of an olm session for debugging and logging purposes. +func (s *Session) Describe() string { + desc := (*C.char)(C.malloc(C.size_t(maxDescribeSize))) + defer C.free(unsafe.Pointer(desc)) + C.meowlm_session_describe( + (*C.OlmSession)(s.int), + desc, + C.size_t(maxDescribeSize), + ) + return C.GoString(desc) +} diff --git a/mautrix-patched/crypto/machine.go b/mautrix-patched/crypto/machine.go new file mode 100644 index 00000000..619ac6b7 --- /dev/null +++ b/mautrix-patched/crypto/machine.go @@ -0,0 +1,839 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exsync" + "go.mau.fi/util/ptr" + + "go.mau.fi/util/exzerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/ssss" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// OlmMachine is the main struct for handling Matrix end-to-end encryption. +type OlmMachine struct { + Client *mautrix.Client + SSSS *ssss.Machine + Log *zerolog.Logger + + CryptoStore Store + StateStore StateStore + + backgroundCtx context.Context + cancelBackgroundCtx context.CancelFunc + + PlaintextMentions bool + MSC4392Relations bool + AllowEncryptedState bool + AllowBeeperRoomReroute bool + + // Never ask the server for keys automatically as a side effect during Megolm decryption. + DisableDecryptKeyFetching bool + keyFetchAttempted *exsync.Set[userSenderKeyTuple] + + // Don't mark outbound Olm sessions as shared for devices they were initially sent to. + DisableSharedGroupSessionTracking bool + + IgnorePostDecryptionParseErrors bool + + SendKeysMinTrust id.TrustState + ShareKeysMinTrust id.TrustState + + AllowKeyShare func(context.Context, *id.Device, event.RequestedKeyInfo) *KeyShareRejection + + account *OlmAccount + + roomKeyRequestFilled *sync.Map + keyVerificationTransactionState *sync.Map + + keyWaiters map[id.SessionID]chan struct{} + keyWaitersLock sync.Mutex + + // Optional callback which is called when we save a session to store + SessionReceived func(context.Context, id.RoomID, id.SessionID, uint32) + + devicesToUnwedge map[id.IdentityKey]bool + devicesToUnwedgeLock sync.Mutex + recentlyUnwedged map[id.IdentityKey]time.Time + recentlyUnwedgedLock sync.Mutex + olmHashSavePoints []time.Time + lastHashDelete time.Time + olmHashSavePointLock sync.Mutex + + olmLock sync.Mutex + megolmEncryptLock sync.Mutex + megolmDecryptLock sync.Mutex + + otkUploadLock sync.Mutex + lastOTKUpload time.Time + receivedOTKsForSelf atomic.Bool + + CrossSigningKeys *CrossSigningKeysCache + crossSigningPubkeys *CrossSigningPublicKeysCache + + crossSigningPubkeysFetched bool + ownDeviceKeysCache atomic.Pointer[mautrix.DeviceKeys] + + DeleteOutboundKeysOnAck bool + DontStoreOutboundKeys bool + DeletePreviousKeysOnReceive bool + RatchetKeysOnDecrypt bool + DeleteFullyUsedKeysOnDecrypt bool + DeleteKeysOnDeviceDelete bool + DisableRatchetTracking bool + + DisableDeviceChangeKeyRotation bool + + secretLock sync.Mutex + secretListeners map[string]chan<- string +} + +// StateStore is used by OlmMachine to get room state information that's needed for encryption. +type StateStore interface { + // IsEncrypted returns whether a room is encrypted. + IsEncrypted(context.Context, id.RoomID) (bool, error) + // GetEncryptionEvent returns the encryption event's content for an encrypted room. + GetEncryptionEvent(context.Context, id.RoomID) (*event.EncryptionEventContent, error) + // FindSharedRooms returns the encrypted rooms that another user is also in for a user ID. + FindSharedRooms(context.Context, id.UserID) ([]id.RoomID, error) +} + +// NewOlmMachine creates an OlmMachine with the given client, logger and stores. +func NewOlmMachine(client *mautrix.Client, log *zerolog.Logger, cryptoStore Store, stateStore StateStore) *OlmMachine { + if log == nil { + logPtr := zerolog.Nop() + log = &logPtr + } + mach := &OlmMachine{ + Client: client, + SSSS: ssss.NewSSSSMachine(client), + Log: log, + CryptoStore: cryptoStore, + StateStore: stateStore, + + SendKeysMinTrust: id.TrustStateUnset, + ShareKeysMinTrust: id.TrustStateCrossSignedTOFU, + + roomKeyRequestFilled: &sync.Map{}, + keyVerificationTransactionState: &sync.Map{}, + + keyWaiters: make(map[id.SessionID]chan struct{}), + + devicesToUnwedge: make(map[id.IdentityKey]bool), + recentlyUnwedged: make(map[id.IdentityKey]time.Time), + secretListeners: make(map[string]chan<- string), + + keyFetchAttempted: exsync.NewSet[userSenderKeyTuple](), + } + mach.backgroundCtx, mach.cancelBackgroundCtx = context.WithCancel(context.Background()) + mach.AllowKeyShare = mach.defaultAllowKeyShare + return mach +} + +func (mach *OlmMachine) machOrContextLog(ctx context.Context) *zerolog.Logger { + log := zerolog.Ctx(ctx) + if log.GetLevel() == zerolog.Disabled || log == zerolog.DefaultContextLogger { + return mach.Log + } + return log +} + +func (mach *OlmMachine) SetBackgroundCtx(ctx context.Context) { + mach.cancelBackgroundCtx() + mach.backgroundCtx, mach.cancelBackgroundCtx = context.WithCancel(ctx) +} + +// Load loads the Olm account information from the crypto store. If there's no olm account, a new one is created. +// This must be called before using the machine. +func (mach *OlmMachine) Load(ctx context.Context) (err error) { + mach.account, err = mach.CryptoStore.GetAccount(ctx) + if err != nil { + return + } + if mach.account == nil { + mach.account = NewOlmAccount() + } + zerolog.Ctx(ctx).Debug(). + Str("machine_ptr", fmt.Sprintf("%p", mach)). + Str("account_ptr", fmt.Sprintf("%p", mach.account.Internal)). + Str("olm_driver", olm.Driver). + Msg("Loaded olm account") + return nil +} + +func (mach *OlmMachine) Destroy() { + mach.Log.Debug(). + Str("machine_ptr", fmt.Sprintf("%p", mach)). + Str("account_ptr", fmt.Sprintf("%p", ptr.Val(mach.account).Internal)). + Msg("Destroying olm machine") + mach.cancelBackgroundCtx() + // TODO actually destroy something? +} + +func (mach *OlmMachine) saveAccount(ctx context.Context) error { + err := mach.CryptoStore.PutAccount(ctx, mach.account) + if err != nil { + mach.Log.Error().Err(err).Msg("Failed to save account") + } + return err +} + +func (mach *OlmMachine) KeyBackupVersion() id.KeyBackupVersion { + return mach.account.KeyBackupVersion +} + +func (mach *OlmMachine) SetKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) error { + mach.account.KeyBackupVersion = version + return mach.saveAccount(ctx) +} + +// FlushStore calls the Flush method of the CryptoStore. +func (mach *OlmMachine) FlushStore(ctx context.Context) error { + return mach.CryptoStore.Flush(ctx) +} + +func (mach *OlmMachine) timeTrace(ctx context.Context, thing string, expectedDuration time.Duration) func() { + start := time.Now() + return func() { + duration := time.Since(start) + if duration > expectedDuration { + zerolog.Ctx(ctx).Warn(). + Str("action", thing). + Dur("duration", duration). + Msg("Executing encryption function took longer than expected") + } + } +} + +// Deprecated: moved to SigningKey.Fingerprint +func Fingerprint(key id.SigningKey) string { + return key.Fingerprint() +} + +// Fingerprint returns the fingerprint of the Olm account that can be used for non-interactive verification. +func (mach *OlmMachine) Fingerprint() string { + return mach.account.SigningKey().Fingerprint() +} + +func (mach *OlmMachine) GetAccount() *OlmAccount { + return mach.account +} + +// OwnIdentity returns this device's id.Device struct +func (mach *OlmMachine) OwnIdentity() *id.Device { + return &id.Device{ + UserID: mach.Client.UserID, + DeviceID: mach.Client.DeviceID, + IdentityKey: mach.account.IdentityKey(), + SigningKey: mach.account.SigningKey(), + Trust: id.TrustStateVerified, + Deleted: false, + } +} + +type ASEventProcessor interface { + On(evtType event.Type, handler func(ctx context.Context, evt *event.Event)) + OnOTK(func(ctx context.Context, otk *mautrix.OTKCount)) + OnDeviceList(func(ctx context.Context, lists *mautrix.DeviceLists, since string)) + Dispatch(ctx context.Context, evt *event.Event) +} + +func (mach *OlmMachine) AddAppserviceListener(ep ASEventProcessor) { + // ToDeviceForwardedRoomKey and ToDeviceRoomKey should only be present inside encrypted to-device events + ep.On(event.ToDeviceEncrypted, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceRoomKeyRequest, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceRoomKeyWithheld, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceBeeperRoomKeyAck, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceOrgMatrixRoomKeyWithheld, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceVerificationRequest, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceVerificationStart, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceVerificationAccept, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceVerificationKey, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceVerificationMAC, mach.HandleToDeviceEvent) + ep.On(event.ToDeviceVerificationCancel, mach.HandleToDeviceEvent) + ep.OnOTK(mach.HandleOTKCounts) + ep.OnDeviceList(mach.HandleDeviceLists) + mach.Log.Debug().Msg("Added listeners for encryption data coming from appservice transactions") +} + +func (mach *OlmMachine) HandleDeviceLists(ctx context.Context, dl *mautrix.DeviceLists, since string) { + if len(dl.Changed) > 0 { + traceID := time.Now().Format("15:04:05.000000") + mach.Log.Debug(). + Str("trace_id", traceID). + Interface("changes", dl.Changed). + Msg("Device list changes in /sync") + mach.FetchKeys(ctx, dl.Changed, false) + mach.Log.Debug().Str("trace_id", traceID).Msg("Finished handling device list changes") + } +} + +func (mach *OlmMachine) otkCountIsForCrossSigningKey(otkCount *mautrix.OTKCount) bool { + if mach.crossSigningPubkeys == nil || otkCount.UserID != mach.Client.UserID { + return false + } + switch id.Ed25519(otkCount.DeviceID) { + case mach.crossSigningPubkeys.MasterKey, mach.crossSigningPubkeys.UserSigningKey, mach.crossSigningPubkeys.SelfSigningKey: + return true + } + return false +} + +func (mach *OlmMachine) HandleOTKCounts(ctx context.Context, otkCount *mautrix.OTKCount) { + receivedOTKsForSelf := mach.receivedOTKsForSelf.Load() + if (len(otkCount.UserID) > 0 && otkCount.UserID != mach.Client.UserID) || (len(otkCount.DeviceID) > 0 && otkCount.DeviceID != mach.Client.DeviceID) { + if otkCount.UserID != mach.Client.UserID || (!receivedOTKsForSelf && !mach.otkCountIsForCrossSigningKey(otkCount)) { + mach.Log.Warn(). + Str("target_user_id", otkCount.UserID.String()). + Str("target_device_id", otkCount.DeviceID.String()). + Msg("Dropping OTK counts targeted to someone else") + } + return + } else if !receivedOTKsForSelf { + mach.receivedOTKsForSelf.Store(true) + } + + minCount := mach.account.Internal.MaxNumberOfOneTimeKeys() / 2 + if otkCount.SignedCurve25519 < int(minCount) { + traceID := time.Now().Format("15:04:05.000000") + log := mach.Log.With().Str("trace_id", traceID).Logger() + ctx = log.WithContext(ctx) + log.Debug(). + Int("keys_left", otkCount.SignedCurve25519). + Msg("Sync response said we have less than 50 signed curve25519 keys left, sharing new ones...") + err := mach.ShareKeys(ctx, otkCount.SignedCurve25519) + if err != nil { + log.Error().Err(err).Msg("Failed to share keys") + } else { + log.Debug().Msg("Successfully shared keys") + } + } +} + +// ProcessSyncResponse processes a single /sync response. +// +// This can be easily registered into a mautrix client using .OnSync(): +// +// client.Syncer.(mautrix.ExtensibleSyncer).OnSync(c.crypto.ProcessSyncResponse) +func (mach *OlmMachine) ProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) bool { + mach.HandleDeviceLists(ctx, &resp.DeviceLists, since) + + for _, evt := range resp.ToDevice.Events { + evt.Type.Class = event.ToDeviceEventType + err := evt.Content.ParseRaw(evt.Type) + if err != nil { + mach.Log.Warn().Str("event_type", evt.Type.Type).Err(err).Msg("Failed to parse to-device event") + continue + } + mach.HandleToDeviceEvent(ctx, evt) + } + + mach.HandleOTKCounts(ctx, &resp.DeviceOTKCount) + mach.MarkOlmHashSavePoint(ctx) + return true +} + +// HandleMemberEvent handles a single membership event. +// +// Currently, this is not automatically called, so you must add a listener yourself: +// +// client.Syncer.(mautrix.ExtensibleSyncer).OnEventType(event.StateMember, c.crypto.HandleMemberEvent) +func (mach *OlmMachine) HandleMemberEvent(ctx context.Context, evt *event.Event) { + if isEncrypted, err := mach.StateStore.IsEncrypted(ctx, evt.RoomID); err != nil { + mach.machOrContextLog(ctx).Err(err).Stringer("room_id", evt.RoomID). + Msg("Failed to check if room is encrypted to handle member event") + return + } else if !isEncrypted { + return + } + content := evt.Content.AsMember() + if content == nil { + return + } + var prevContent *event.MemberEventContent + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent = evt.Unsigned.PrevContent.AsMember() + } + if prevContent == nil { + prevContent = &event.MemberEventContent{Membership: "unknown"} + } + if prevContent.Membership == content.Membership || + (prevContent.Membership == event.MembershipInvite && content.Membership == event.MembershipJoin) || + (prevContent.Membership == event.MembershipBan && content.Membership == event.MembershipLeave) || + (prevContent.Membership == event.MembershipLeave && content.Membership == event.MembershipBan) { + return + } + // TODO on joins and invites, it would be enough to mark the session as needing re-sharing to that user instead of deleting it + mach.Log.Trace(). + Str("room_id", evt.RoomID.String()). + Str("user_id", evt.GetStateKey()). + Str("prev_membership", string(prevContent.Membership)). + Str("new_membership", string(content.Membership)). + Msg("Got membership state change, invalidating group session in room") + err := mach.CryptoStore.RemoveOutboundGroupSession(ctx, evt.RoomID) + if err != nil { + mach.Log.Warn().Stringer("room_id", evt.RoomID).Msg("Failed to invalidate outbound group session") + } +} + +func (mach *OlmMachine) HandleEncryptedEvent(ctx context.Context, evt *event.Event) *DecryptedOlmEvent { + content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) + if !ok { + mach.machOrContextLog(ctx).Warn().Msg("Passed invalid event to encrypted handler") + return nil + } else if content.Algorithm == id.AlgorithmBeeperStreamV1 { + mach.machOrContextLog(ctx).Debug().Msg("Skipping beeper stream encrypted to-device event in Olm machine") + return nil + } + + decryptedEvt, err := mach.decryptOlmEvent(ctx, evt) + if err != nil { + mach.machOrContextLog(ctx).Error().Err(err).Msg("Failed to decrypt to-device event") + return nil + } + + log := mach.machOrContextLog(ctx).With(). + Str("decrypted_type", decryptedEvt.Type.Type). + Stringer("sender_device", ptr.Val(decryptedEvt.SenderDevice).DeviceID). + Stringer("sender_signing_key", decryptedEvt.Keys.Ed25519). + Logger() + log.Trace().Msg("Successfully decrypted to-device event") + + switch decryptedContent := decryptedEvt.Content.Parsed.(type) { + case *event.RoomKeyEventContent: + mach.receiveRoomKey(ctx, decryptedEvt, decryptedContent) + log.Trace().Msg("Handled room key event") + case *event.ForwardedRoomKeyEventContent: + if mach.importForwardedRoomKey(ctx, decryptedEvt, decryptedContent) { + if ch, ok := mach.roomKeyRequestFilled.Load(decryptedContent.SessionID); ok { + // close channel to notify listener that the key was received + close(ch.(chan struct{})) + } + } + log.Trace().Msg("Handled forwarded room key event") + case *event.DummyEventContent: + log.Debug().Msg("Received encrypted dummy event") + case *event.SecretSendEventContent: + mach.receiveSecret(ctx, decryptedEvt, decryptedContent) + log.Trace().Msg("Handled secret send event") + default: + log.Debug().Msg("Unhandled encrypted to-device event") + return decryptedEvt + } + return nil +} + +const olmHashSavePointCount = 5 +const olmHashDeleteMinInterval = 10 * time.Minute +const minSavePointInterval = 1 * time.Minute + +// MarkOlmHashSavePoint marks the current time as a save point for olm hashes and deletes old hashes if needed. +// +// This should be called after all to-device events in a sync have been processed. +// The function will then delete old olm hashes after enough syncs have happened +// (such that it's unlikely for the olm messages to repeat). +func (mach *OlmMachine) MarkOlmHashSavePoint(ctx context.Context) { + mach.olmHashSavePointLock.Lock() + defer mach.olmHashSavePointLock.Unlock() + if len(mach.olmHashSavePoints) > 0 && time.Since(mach.olmHashSavePoints[len(mach.olmHashSavePoints)-1]) < minSavePointInterval { + return + } + mach.olmHashSavePoints = append(mach.olmHashSavePoints, time.Now()) + if len(mach.olmHashSavePoints) > olmHashSavePointCount { + sp := mach.olmHashSavePoints[0] + mach.olmHashSavePoints = mach.olmHashSavePoints[1:] + if time.Since(mach.lastHashDelete) > olmHashDeleteMinInterval { + err := mach.CryptoStore.DeleteOldOlmHashes(ctx, sp) + mach.lastHashDelete = time.Now() + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to delete old olm hashes") + } + } + } +} + +// HandleToDeviceEvent handles a single to-device event. This is automatically called by ProcessSyncResponse, so you +// don't need to add any custom handlers if you use that method. +func (mach *OlmMachine) HandleToDeviceEvent(ctx context.Context, evt *event.Event) { + if len(evt.ToUserID) > 0 && (evt.ToUserID != mach.Client.UserID || evt.ToDeviceID != mach.Client.DeviceID) { + // TODO This log probably needs to be silence-able if someone wants to use encrypted appservices with multiple e2ee sessions + mach.Log.Debug(). + Str("target_user_id", evt.ToUserID.String()). + Str("target_device_id", evt.ToDeviceID.String()). + Msg("Dropping to-device event targeted to someone else") + return + } + traceID := time.Now().Format("15:04:05.000000") + // TODO use context log? + log := mach.Log.With(). + Str("trace_id", traceID). + Str("sender", evt.Sender.String()). + Str("type", evt.Type.Type). + Logger() + ctx = log.WithContext(ctx) + if evt.Type != event.ToDeviceEncrypted { + log.Debug().Msg("Starting handling to-device event") + } + switch content := evt.Content.Parsed.(type) { + case *event.EncryptedEventContent: + mach.HandleEncryptedEvent(ctx, evt) + return + case *event.RoomKeyRequestEventContent: + go mach.HandleRoomKeyRequest(ctx, evt.Sender, content) + case *event.BeeperRoomKeyAckEventContent: + mach.HandleBeeperRoomKeyAck(ctx, evt.Sender, content) + case *event.RoomKeyWithheldEventContent: + mach.HandleRoomKeyWithheld(ctx, content) + case *event.SecretRequestEventContent: + if content.Action == event.SecretRequestRequest { + mach.HandleSecretRequest(ctx, evt.Sender, content) + log.Trace().Msg("Handled secret request event") + } + default: + deviceID, _ := evt.Content.Raw["device_id"].(string) + log.Debug().Str("maybe_device_id", deviceID).Msg("Unhandled to-device event") + return + } + log.Debug().Msg("Finished handling to-device event") +} + +// GetOrFetchDevice attempts to retrieve the device identity for the given device from the store +// and if it's not found it asks the server for it. +func (mach *OlmMachine) GetOrFetchDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*id.Device, error) { + device, err := mach.CryptoStore.GetDevice(ctx, userID, deviceID) + if err != nil { + return nil, fmt.Errorf("failed to get sender device from store: %w", err) + } else if device != nil { + return device, nil + } + if usersToDevices, err := mach.FetchKeys(ctx, []id.UserID{userID}, true); err != nil { + return nil, fmt.Errorf("failed to fetch keys: %w", err) + } else if devices, ok := usersToDevices[userID]; ok { + if device, ok = devices[deviceID]; ok { + return device, nil + } + return nil, fmt.Errorf("didn't get identity for device %s of %s", deviceID, userID) + } + return nil, fmt.Errorf("didn't get any devices for %s", userID) +} + +// GetOrFetchDeviceByKey attempts to retrieve the device identity for the device with the given identity key from the +// store and if it's not found it asks the server for it. This returns nil if the server doesn't return a device with +// the given identity key. +func (mach *OlmMachine) GetOrFetchDeviceByKey(ctx context.Context, userID id.UserID, identityKey id.IdentityKey) (*id.Device, error) { + deviceIdentity, err := mach.CryptoStore.FindDeviceByKey(ctx, userID, identityKey) + if err != nil || deviceIdentity != nil { + return deviceIdentity, err + } + mach.machOrContextLog(ctx).Debug(). + Str("user_id", userID.String()). + Str("identity_key", identityKey.String()). + Msg("Didn't find identity in crypto store, fetching from server") + devices := mach.LoadDevices(ctx, userID) + for _, device := range devices { + if device.IdentityKey == identityKey { + return device, nil + } + } + return nil, nil +} + +// SendEncryptedToDevice sends an Olm-encrypted event to the given user device. +func (mach *OlmMachine) SendEncryptedToDevice(ctx context.Context, device *id.Device, evtType event.Type, content event.Content) error { + if err := mach.createOutboundSessions(ctx, map[id.UserID]map[id.DeviceID]*id.Device{ + device.UserID: { + device.DeviceID: device, + }, + }); err != nil { + return err + } + + mach.olmLock.Lock() + defer mach.olmLock.Unlock() + + olmSess, err := mach.CryptoStore.GetLatestSession(ctx, device.IdentityKey) + if err != nil { + return err + } + if olmSess == nil { + return fmt.Errorf("didn't find created outbound session for device %s of %s", device.DeviceID, device.UserID) + } + + encrypted := mach.encryptOlmEvent(ctx, olmSess, device, evtType, content) + encryptedContent := &event.Content{Parsed: &encrypted} + + mach.machOrContextLog(ctx).Debug(). + Str("decrypted_type", evtType.Type). + Str("to_user_id", device.UserID.String()). + Str("to_device_id", device.DeviceID.String()). + Str("to_identity_key", device.IdentityKey.String()). + Str("olm_session_id", olmSess.ID().String()). + Msg("Sending encrypted to-device event") + _, err = mach.Client.SendToDevice(ctx, event.ToDeviceEncrypted, + &mautrix.ReqSendToDevice{ + Messages: map[id.UserID]map[id.DeviceID]*event.Content{ + device.UserID: { + device.DeviceID: encryptedContent, + }, + }, + }, + ) + + return err +} + +func (mach *OlmMachine) createGroupSession(ctx context.Context, senderKey id.SenderKey, signingKey id.Ed25519, roomID id.RoomID, sessionID id.SessionID, sessionKey string, maxAge time.Duration, maxMessages int, isScheduled bool) error { + log := zerolog.Ctx(ctx) + igs, err := NewInboundGroupSession(senderKey, signingKey, roomID, sessionKey, maxAge, maxMessages, isScheduled) + if err != nil { + return fmt.Errorf("failed to create inbound group session: %w", err) + } else if igs.ID() != sessionID { + log.Warn(). + Str("expected_session_id", sessionID.String()). + Str("actual_session_id", igs.ID().String()). + Msg("Mismatched session ID while creating inbound group session") + return fmt.Errorf("mismatched session ID while creating inbound group session") + } + err = mach.CryptoStore.PutGroupSession(ctx, igs) + if err != nil { + log.Err(err).Stringer("session_id", sessionID).Msg("Failed to store new inbound group session") + return fmt.Errorf("failed to store new inbound group session: %w", err) + } + mach.MarkSessionReceived(ctx, roomID, sessionID, igs.Internal.FirstKnownIndex()) + log.Debug(). + Str("session_id", sessionID.String()). + Str("sender_key", senderKey.String()). + Str("max_age", maxAge.String()). + Int("max_messages", maxMessages). + Bool("is_scheduled", isScheduled). + Msg("Received inbound group session") + return nil +} + +func (mach *OlmMachine) MarkSessionReceived(ctx context.Context, roomID id.RoomID, id id.SessionID, firstKnownIndex uint32) { + if mach.SessionReceived != nil { + mach.SessionReceived(ctx, roomID, id, firstKnownIndex) + } + + mach.keyWaitersLock.Lock() + ch, ok := mach.keyWaiters[id] + if ok { + close(ch) + delete(mach.keyWaiters, id) + } + mach.keyWaitersLock.Unlock() +} + +// WaitForSession waits for the given Megolm session to arrive. +func (mach *OlmMachine) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool { + mach.keyWaitersLock.Lock() + ch, ok := mach.keyWaiters[sessionID] + if !ok { + ch = make(chan struct{}) + mach.keyWaiters[sessionID] = ch + } + mach.keyWaitersLock.Unlock() + // Handle race conditions where a session appears between the failed decryption and WaitForSession call. + sess, err := mach.CryptoStore.GetGroupSession(ctx, roomID, sessionID) + if sess != nil || errors.Is(err, ErrGroupSessionWithheld) { + return true + } + select { + case <-ch: + return true + case <-time.After(timeout): + sess, err = mach.CryptoStore.GetGroupSession(ctx, roomID, sessionID) + // Check if the session somehow appeared in the store without telling us + // We accept withheld sessions as received, as then the decryption attempt will show the error. + return sess != nil || errors.Is(err, ErrGroupSessionWithheld) + case <-ctx.Done(): + return false + } +} + +func (mach *OlmMachine) receiveRoomKey(ctx context.Context, evt *DecryptedOlmEvent, content *event.RoomKeyEventContent) { + log := zerolog.Ctx(ctx).With(). + Str("algorithm", string(content.Algorithm)). + Str("session_id", content.SessionID.String()). + Str("room_id", content.RoomID.String()). + Logger() + if content.Algorithm != id.AlgorithmMegolmV1 || evt.Keys.Ed25519 == "" { + log.Debug().Msg("Ignoring weird room key") + return + } + + config, err := mach.StateStore.GetEncryptionEvent(ctx, content.RoomID) + if err != nil { + log.Error().Err(err).Msg("Failed to get encryption event for room") + } + var maxAge time.Duration + var maxMessages int + if config != nil { + maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond + if maxAge == 0 { + maxAge = 7 * 24 * time.Hour + } + maxMessages = config.RotationPeriodMessages + if maxMessages == 0 { + maxMessages = 100 + } + } + if content.MaxAge != 0 { + maxAge = time.Duration(content.MaxAge) * time.Millisecond + } + if content.MaxMessages != 0 { + maxMessages = content.MaxMessages + } + if mach.DeletePreviousKeysOnReceive && !content.IsScheduled { + log.Debug().Msg("Redacting previous megolm sessions from sender in room") + sessionIDs, err := mach.CryptoStore.RedactGroupSessions(ctx, content.RoomID, evt.SenderKey, "received new key from device") + if err != nil { + log.Err(err).Msg("Failed to redact previous megolm sessions") + } else { + log.Info(). + Array("session_ids", exzerolog.ArrayOfStrs(sessionIDs)). + Msg("Redacted previous megolm sessions") + } + } + err = mach.createGroupSession(ctx, evt.SenderKey, evt.Keys.Ed25519, content.RoomID, content.SessionID, content.SessionKey, maxAge, maxMessages, content.IsScheduled) + if err != nil { + log.Err(err).Msg("Failed to create inbound group session") + } +} + +func (mach *OlmMachine) HandleRoomKeyWithheld(ctx context.Context, content *event.RoomKeyWithheldEventContent) { + if content.Algorithm != id.AlgorithmMegolmV1 { + zerolog.Ctx(ctx).Debug().Interface("content", content).Msg("Non-megolm room key withheld event") + return + } + // TODO log if there's a conflict? (currently ignored) + err := mach.CryptoStore.PutWithheldGroupSession(ctx, *content) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to save room key withheld event") + } +} + +func (mach *OlmMachine) getKeysForOlmMessage(ctx context.Context) *mautrix.DeviceKeys { + if keys := mach.ownDeviceKeysCache.Load(); keys != nil { + return keys + } + keys := mach.account.getInitialKeys(mach.Client.UserID, mach.Client.DeviceID) + csKeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to get own cross signing keys") + return keys + } else if csKeys == nil { + mach.ownDeviceKeysCache.Store(keys) + return keys + } + sigs, err := mach.CryptoStore.GetSignaturesForKeyBy(ctx, mach.Client.UserID, keys.Keys.GetEd25519(mach.Client.DeviceID), mach.Client.UserID) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to get signatures for own device keys") + return keys + } + selfSig, ok := sigs[csKeys.SelfSigningKey] + if ok { + keys.Signatures[mach.Client.UserID][id.NewKeyID(id.KeyAlgorithmEd25519, csKeys.SelfSigningKey.String())] = selfSig + } + mach.ownDeviceKeysCache.Store(keys) + return keys +} + +// ShareKeys uploads necessary keys to the server. +// +// If the Olm account hasn't been shared, the account keys will be uploaded. +// If currentOTKCount is less than half of the limit (100 / 2 = 50), enough one-time keys will be uploaded so exactly +// half of the limit is filled. +func (mach *OlmMachine) ShareKeys(ctx context.Context, currentOTKCount int) error { + log := mach.machOrContextLog(ctx) + start := time.Now() + mach.otkUploadLock.Lock() + defer mach.otkUploadLock.Unlock() + if mach.lastOTKUpload.Add(1*time.Minute).After(start) || (currentOTKCount < 0 && mach.account.Shared) { + log.Debug().Msg("Checking OTK count from server due to suspiciously close share keys requests or negative OTK count") + resp, err := mach.Client.UploadKeys(ctx, &mautrix.ReqUploadKeys{}) + if err != nil { + return fmt.Errorf("failed to check current OTK counts: %w", err) + } + log.Debug(). + Int("input_count", currentOTKCount). + Int("server_count", resp.OneTimeKeyCounts.SignedCurve25519). + Msg("Fetched current OTK count from server") + currentOTKCount = resp.OneTimeKeyCounts.SignedCurve25519 + } + var deviceKeys *mautrix.DeviceKeys + if !mach.account.Shared { + deviceKeys = mach.account.getInitialKeys(mach.Client.UserID, mach.Client.DeviceID) + err := mach.CryptoStore.PutDevice(ctx, mach.Client.UserID, &id.Device{ + UserID: mach.Client.UserID, + DeviceID: mach.Client.DeviceID, + IdentityKey: deviceKeys.Keys.GetCurve25519(mach.Client.DeviceID), + SigningKey: deviceKeys.Keys.GetEd25519(mach.Client.DeviceID), + }) + if err != nil { + return fmt.Errorf("failed to save initial keys: %w", err) + } + log.Debug().Msg("Going to upload initial account keys") + } + oneTimeKeys := mach.account.getOneTimeKeys(mach.Client.UserID, mach.Client.DeviceID, currentOTKCount) + if len(oneTimeKeys) == 0 && deviceKeys == nil { + log.Debug().Msg("No one-time keys nor device keys got when trying to share keys") + return nil + } + // Save the keys before sending the upload request in case there is a + // network failure. + if err := mach.saveAccount(ctx); err != nil { + return err + } + req := &mautrix.ReqUploadKeys{ + DeviceKeys: deviceKeys, + OneTimeKeys: oneTimeKeys, + } + log.Debug().Int("count", len(oneTimeKeys)).Msg("Uploading one-time keys") + _, err := mach.Client.UploadKeys(ctx, req) + if err != nil { + return err + } + mach.lastOTKUpload = time.Now() + mach.account.Internal.MarkKeysAsPublished() + mach.account.Shared = true + return mach.saveAccount(ctx) +} + +func (mach *OlmMachine) ExpiredKeyDeleteLoop(ctx context.Context) { + log := mach.Log.With().Str("action", "redact expired sessions").Logger() + for { + sessionIDs, err := mach.CryptoStore.RedactExpiredGroupSessions(ctx) + if err != nil { + log.Err(err).Msg("Failed to redact expired megolm sessions") + } else if len(sessionIDs) > 0 { + log.Info().Array("session_ids", exzerolog.ArrayOfStrs(sessionIDs)).Msg("Redacted expired megolm sessions") + } else { + log.Debug().Msg("Didn't find any expired megolm sessions") + } + select { + case <-ctx.Done(): + log.Debug().Msg("Loop stopped") + return + case <-time.After(24 * time.Hour): + } + } +} diff --git a/mautrix-patched/crypto/machine_bench_test.go b/mautrix-patched/crypto/machine_bench_test.go new file mode 100644 index 00000000..fd40d795 --- /dev/null +++ b/mautrix-patched/crypto/machine_bench_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto_test + +import ( + "context" + "fmt" + "math/rand/v2" + "testing" + + "github.com/rs/zerolog" + globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mockserver" +) + +func randomDeviceCount(r *rand.Rand) int { + k := 1 + for k < 10 && r.IntN(3) > 0 { + k++ + } + return k +} + +func BenchmarkOlmMachine_ShareGroupSession(b *testing.B) { + globallog.Logger = zerolog.Nop() + server := mockserver.Create(b) + server.PopOTKs = false + server.MemoryStore = false + var i int + var shareTargets []id.UserID + r := rand.New(rand.NewPCG(293, 0)) + var totalDeviceCount int + for i = 1; i < 1000; i++ { + userID := id.UserID(fmt.Sprintf("@user%d:localhost", i)) + deviceCount := randomDeviceCount(r) + for j := 0; j < deviceCount; j++ { + client, _ := server.Login(b, nil, userID, id.DeviceID(fmt.Sprintf("u%d_d%d", i, j))) + mach := client.Crypto.(*cryptohelper.CryptoHelper).Machine() + keysCache, err := mach.GenerateCrossSigningKeys() + require.NoError(b, err) + err = mach.PublishCrossSigningKeys(context.TODO(), keysCache, nil) + require.NoError(b, err) + } + totalDeviceCount += deviceCount + shareTargets = append(shareTargets, userID) + } + for b.Loop() { + client, _ := server.Login(b, nil, id.UserID(fmt.Sprintf("@benchuser%d:localhost", i)), id.DeviceID(fmt.Sprintf("u%d_d1", i))) + mach := client.Crypto.(*cryptohelper.CryptoHelper).Machine() + keysCache, err := mach.GenerateCrossSigningKeys() + require.NoError(b, err) + err = mach.PublishCrossSigningKeys(context.TODO(), keysCache, nil) + require.NoError(b, err) + err = mach.ShareGroupSession(context.TODO(), "!room:localhost", shareTargets) + require.NoError(b, err) + i++ + } + fmt.Println(totalDeviceCount, "devices total") +} diff --git a/mautrix-patched/crypto/machine_test.go b/mautrix-patched/crypto/machine_test.go new file mode 100644 index 00000000..872c3ac4 --- /dev/null +++ b/mautrix-patched/crypto/machine_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2020 Nikos Filippakis +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type mockStateStore struct{} + +func (mockStateStore) IsEncrypted(context.Context, id.RoomID) (bool, error) { + return true, nil +} + +func (mockStateStore) GetEncryptionEvent(context.Context, id.RoomID) (*event.EncryptionEventContent, error) { + return &event.EncryptionEventContent{ + RotationPeriodMessages: 3, + }, nil +} + +func (mockStateStore) FindSharedRooms(context.Context, id.UserID) ([]id.RoomID, error) { + return []id.RoomID{"room1"}, nil +} + +func newMachine(t *testing.T, userID id.UserID) *OlmMachine { + client, err := mautrix.NewClient("http://localhost", userID, "token") + require.NoError(t, err, "Error creating client") + client.DeviceID = "device1" + + gobStore := NewMemoryStore(nil) + require.NoError(t, err, "Error creating Gob store") + + machine := NewOlmMachine(client, nil, gobStore, mockStateStore{}) + err = machine.Load(context.TODO()) + require.NoError(t, err, "Error creating account") + + return machine +} + +func TestRatchetMegolmSession(t *testing.T) { + mach := newMachine(t, "user1") + outSess, err := mach.newOutboundGroupSession(context.TODO(), "meow") + assert.NoError(t, err) + inSess, err := mach.CryptoStore.GetGroupSession(context.TODO(), "meow", outSess.ID()) + require.NoError(t, err) + assert.Equal(t, uint32(0), inSess.Internal.FirstKnownIndex()) + err = inSess.RatchetTo(10) + assert.NoError(t, err) + assert.Equal(t, uint32(10), inSess.Internal.FirstKnownIndex()) +} + +func TestOlmMachineOlmMegolmSessions(t *testing.T) { + machineOut := newMachine(t, "user1") + machineIn := newMachine(t, "user2") + + // generate OTKs for receiving machine + otks := machineIn.account.getOneTimeKeys("user2", "device2", 0) + var otk mautrix.OneTimeKey + for _, otkTmp := range otks { + // take first OTK + otk = otkTmp + break + } + machineIn.account.Internal.MarkKeysAsPublished() + + // create outbound olm session for sending machine using OTK + olmSession, err := machineOut.account.Internal.NewOutboundSession(machineIn.account.IdentityKey(), otk.Key) + require.NoError(t, err, "Error creating outbound olm session") + + // store sender device identity in receiving machine store + machineIn.CryptoStore.PutDevices(context.TODO(), "user1", map[id.DeviceID]*id.Device{ + "device1": { + UserID: "user1", + DeviceID: "device1", + IdentityKey: machineOut.account.IdentityKey(), + SigningKey: machineOut.account.SigningKey(), + }, + }) + + // create & store outbound megolm session for sending the event later + megolmOutSession, err := machineOut.newOutboundGroupSession(context.TODO(), "room1") + assert.NoError(t, err) + megolmOutSession.Shared = true + machineOut.CryptoStore.AddOutboundGroupSession(context.TODO(), megolmOutSession) + + // encrypt m.room_key event with olm session + deviceIdentity := &id.Device{ + UserID: "user2", + DeviceID: "device2", + IdentityKey: machineIn.account.IdentityKey(), + SigningKey: machineIn.account.SigningKey(), + } + wrapped := wrapSession(olmSession) + content := machineOut.encryptOlmEvent(context.TODO(), wrapped, deviceIdentity, event.ToDeviceRoomKey, megolmOutSession.ShareContent()) + + senderKey := machineOut.account.IdentityKey() + signingKey := machineOut.account.SigningKey() + + for _, content := range content.OlmCiphertext { + // decrypt olm ciphertext + decrypted, err := machineIn.decryptAndParseOlmCiphertext(context.TODO(), &event.Event{ + Type: event.ToDeviceEncrypted, + Sender: "user1", + }, senderKey, content.Type, content.Body) + require.NoError(t, err, "Error decrypting olm ciphertext") + + // store room key in new inbound group session + roomKeyEvt := decrypted.Content.AsRoomKey() + igs, err := NewInboundGroupSession(senderKey, signingKey, "room1", roomKeyEvt.SessionKey, 0, 0, false) + require.NoError(t, err, "Error creating inbound group session") + err = machineIn.CryptoStore.PutGroupSession(context.TODO(), igs) + require.NoError(t, err, "Error storing inbound group session") + } + + // encrypt event with megolm session in sending machine + eventContent := map[string]string{"hello": "world"} + encryptedEvtContent, err := machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) + require.NoError(t, err, "Error encrypting megolm event") + assert.Equal(t, 1, megolmOutSession.MessageCount) + + encryptedEvt := &event.Event{ + Content: event.Content{Parsed: encryptedEvtContent}, + Type: event.EventEncrypted, + ID: "event1", + RoomID: "room1", + Sender: "user1", + } + + // decrypt event on receiving machine and confirm + decryptedEvt, err := machineIn.DecryptMegolmEvent(context.TODO(), encryptedEvt) + require.NoError(t, err, "Error decrypting megolm event") + assert.Equal(t, event.EventMessage, decryptedEvt.Type) + assert.Equal(t, "world", decryptedEvt.Content.Raw["hello"]) + + machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) + assert.False(t, megolmOutSession.Expired(), "Megolm outbound session expired before 3rd message") + machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) + assert.True(t, megolmOutSession.Expired(), "Megolm outbound session not expired after 3rd message") +} diff --git a/mautrix-patched/crypto/olm/README.md b/mautrix-patched/crypto/olm/README.md new file mode 100644 index 00000000..7d8086c0 --- /dev/null +++ b/mautrix-patched/crypto/olm/README.md @@ -0,0 +1,4 @@ +# Go olm bindings +Based on [Dhole/go-olm](https://github.com/Dhole/go-olm) + +The original project is licensed under the Apache 2.0 license. diff --git a/mautrix-patched/crypto/olm/account.go b/mautrix-patched/crypto/olm/account.go new file mode 100644 index 00000000..863abfb7 --- /dev/null +++ b/mautrix-patched/crypto/olm/account.go @@ -0,0 +1,116 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +import ( + "maunium.net/go/mautrix/id" +) + +type Account interface { + // Pickle returns an Account as a base64 string. Encrypts the Account using the + // supplied key. + Pickle(key []byte) ([]byte, error) + + // Unpickle loads an Account from a pickled base64 string. Decrypts the + // Account using the supplied key. Returns error on failure. + Unpickle(pickled, key []byte) error + + // IdentityKeysJSON returns the public parts of the identity keys for the Account. + IdentityKeysJSON() ([]byte, error) + + // IdentityKeys returns the public parts of the Ed25519 and Curve25519 identity + // keys for the Account. + IdentityKeys() (id.Ed25519, id.Curve25519, error) + + // Sign returns the signature of a message using the ed25519 key for this + // Account. + Sign(message []byte) ([]byte, error) + + // OneTimeKeys returns the public parts of the unpublished one time keys for + // the Account. + // + // The returned data is a struct with the single value "Curve25519", which is + // itself an object mapping key id to base64-encoded Curve25519 key. For + // example: + // + // { + // Curve25519: { + // "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo", + // "AAAAAB": "LRvjo46L1X2vx69sS9QNFD29HWulxrmW11Up5AfAjgU" + // } + // } + OneTimeKeys() (map[string]id.Curve25519, error) + + // MarkKeysAsPublished marks the current set of one time keys as being + // published. + MarkKeysAsPublished() + + // MaxNumberOfOneTimeKeys returns the largest number of one time keys this + // Account can store. + MaxNumberOfOneTimeKeys() uint + + // GenOneTimeKeys generates a number of new one time keys. If the total + // number of keys stored by this Account exceeds MaxNumberOfOneTimeKeys + // then the old keys are discarded. + GenOneTimeKeys(num uint) error + + // NewOutboundSession creates a new out-bound session for sending messages to a + // given curve25519 identityKey and oneTimeKey. Returns error on failure. If the + // keys couldn't be decoded as base64 then the error will be "INVALID_BASE64" + NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (Session, error) + + // NewInboundSession creates a new in-bound session for sending/receiving + // messages from an incoming PRE_KEY message. Returns error on failure. If + // the base64 couldn't be decoded then the error will be "INVALID_BASE64". If + // the message was for an unsupported protocol version then the error will be + // "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the + // error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one + // time key then the error will be "BAD_MESSAGE_KEY_ID". + NewInboundSession(oneTimeKeyMsg string) (Session, error) + + // NewInboundSessionFrom creates a new in-bound session for sending/receiving + // messages from an incoming PRE_KEY message. Returns error on failure. If + // the base64 couldn't be decoded then the error will be "INVALID_BASE64". If + // the message was for an unsupported protocol version then the error will be + // "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the + // error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one + // time key then the error will be "BAD_MESSAGE_KEY_ID". + NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (Session, error) + + // RemoveOneTimeKeys removes the one time keys that the session used from the + // Account. Returns error on failure. If the Account doesn't have any + // matching one time keys then the error will be "BAD_MESSAGE_KEY_ID". + // + // Note: this must only be called after successfully decrypting a message with the session, + // as otherwise an attacker can cause the client to drop any one-time key by sending an + // invalid message with a valid one-time key id. + RemoveOneTimeKeys(s Session) error +} + +var Driver = "none" + +var InitBlankAccount func() Account +var InitNewAccount func() (Account, error) +var InitNewAccountFromPickled func(pickled, key []byte) (Account, error) + +// NewAccount creates a new Account. +func NewAccount() (Account, error) { + return InitNewAccount() +} + +func NewBlankAccount() Account { + return InitBlankAccount() +} + +// AccountFromPickled loads an Account from a pickled base64 string. Decrypts +// the Account using the supplied key. Returns error on failure. If the key +// doesn't match the one used to encrypt the Account then the error will be +// "BAD_ACCOUNT_KEY". If the base64 couldn't be decoded then the error will be +// "INVALID_BASE64". +func AccountFromPickled(pickled, key []byte) (Account, error) { + return InitNewAccountFromPickled(pickled, key) +} diff --git a/mautrix-patched/crypto/olm/account_test.go b/mautrix-patched/crypto/olm/account_test.go new file mode 100644 index 00000000..736dc900 --- /dev/null +++ b/mautrix-patched/crypto/olm/account_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build !goolm + +package olm_test + +import ( + "bytes" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/crypto/ed25519" + "maunium.net/go/mautrix/crypto/goolm/account" + "maunium.net/go/mautrix/crypto/libolm" + "maunium.net/go/mautrix/crypto/olm" +) + +func ensureAccountsEqual(t *testing.T, a, b olm.Account) { + t.Helper() + + assert.Equal(t, a.MaxNumberOfOneTimeKeys(), b.MaxNumberOfOneTimeKeys()) + + aEd25519, aCurve25519, err := a.IdentityKeys() + require.NoError(t, err) + bEd25519, bCurve25519, err := b.IdentityKeys() + require.NoError(t, err) + assert.Equal(t, aEd25519, bEd25519) + assert.Equal(t, aCurve25519, bCurve25519) + + aIdentityKeysJSON, err := a.IdentityKeysJSON() + require.NoError(t, err) + bIdentityKeysJSON, err := b.IdentityKeysJSON() + require.NoError(t, err) + assert.JSONEq(t, string(aIdentityKeysJSON), string(bIdentityKeysJSON)) + + aOTKs, err := a.OneTimeKeys() + require.NoError(t, err) + bOTKs, err := b.OneTimeKeys() + require.NoError(t, err) + assert.Equal(t, aOTKs, bOTKs) +} + +// TestAccount_UnpickleLibolmToGoolm tests creating an account from libolm, +// pickling it, and importing it into goolm. +func TestAccount_UnpickleLibolmToGoolm(t *testing.T) { + libolmAccount, err := libolm.NewAccount() + require.NoError(t, err) + + require.NoError(t, libolmAccount.GenOneTimeKeys(50)) + + libolmPickled, err := libolmAccount.Pickle([]byte("test")) + require.NoError(t, err) + + goolmAccount, err := account.AccountFromPickled(libolmPickled, []byte("test")) + require.NoError(t, err) + + ensureAccountsEqual(t, libolmAccount, goolmAccount) + + goolmPickled, err := goolmAccount.Pickle([]byte("test")) + require.NoError(t, err) + assert.Equal(t, libolmPickled, goolmPickled) +} + +// TestAccount_UnpickleGoolmToLibolm tests creating an account from goolm, +// pickling it, and importing it into libolm. +func TestAccount_UnpickleGoolmToLibolm(t *testing.T) { + goolmAccount, err := account.NewAccount() + require.NoError(t, err) + + require.NoError(t, goolmAccount.GenOneTimeKeys(50)) + + goolmPickled, err := goolmAccount.Pickle([]byte("test")) + require.NoError(t, err) + + libolmAccount, err := libolm.AccountFromPickled(bytes.Clone(goolmPickled), []byte("test")) + require.NoError(t, err) + + ensureAccountsEqual(t, libolmAccount, goolmAccount) + + libolmPickled, err := libolmAccount.Pickle([]byte("test")) + require.NoError(t, err) + assert.Equal(t, goolmPickled, libolmPickled) +} + +func FuzzAccount_Sign(f *testing.F) { + f.Add([]byte("anything")) + + libolmAccount := exerrors.Must(libolm.NewAccount()) + goolmAccount := exerrors.Must(account.AccountFromPickled(exerrors.Must(libolmAccount.Pickle([]byte("test"))), []byte("test"))) + + f.Fuzz(func(t *testing.T, message []byte) { + if len(message) == 0 { + t.Skip("empty message is not supported") + } + + libolmSignature, err := libolmAccount.Sign(bytes.Clone(message)) + require.NoError(t, err) + goolmSignature, err := goolmAccount.Sign(bytes.Clone(message)) + require.NoError(t, err) + assert.Equal(t, goolmSignature, libolmSignature) + + goolmSignatureBytes, err := base64.RawStdEncoding.DecodeString(string(goolmSignature)) + require.NoError(t, err) + libolmSignatureBytes, err := base64.RawStdEncoding.DecodeString(string(libolmSignature)) + require.NoError(t, err) + + libolmEd25519, _, err := libolmAccount.IdentityKeys() + require.NoError(t, err) + + assert.True(t, ed25519.Verify(ed25519.PublicKey(libolmEd25519.Bytes()), message, libolmSignatureBytes)) + assert.True(t, ed25519.Verify(ed25519.PublicKey(libolmEd25519.Bytes()), message, goolmSignatureBytes)) + + assert.True(t, goolmAccount.IdKeys.Ed25519.Verify(bytes.Clone(message), libolmSignatureBytes)) + assert.True(t, goolmAccount.IdKeys.Ed25519.Verify(bytes.Clone(message), goolmSignatureBytes)) + }) +} diff --git a/mautrix-patched/crypto/olm/errors.go b/mautrix-patched/crypto/olm/errors.go new file mode 100644 index 00000000..9e522b2a --- /dev/null +++ b/mautrix-patched/crypto/olm/errors.go @@ -0,0 +1,76 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +import "errors" + +// Those are the most common used errors +var ( + ErrBadSignature = errors.New("bad signature") + ErrBadMAC = errors.New("the message couldn't be decrypted (bad mac)") + ErrBadMessageFormat = errors.New("the message couldn't be decoded") + ErrBadVerification = errors.New("bad verification") + ErrWrongProtocolVersion = errors.New("wrong protocol version") + ErrEmptyInput = errors.New("empty input") + ErrNoKeyProvided = errors.New("no key provided") + ErrBadMessageKeyID = errors.New("the message references an unknown key ID") + ErrUnknownMessageIndex = errors.New("attempt to decode a message whose index is earlier than our earliest known session key") + ErrMsgIndexTooHigh = errors.New("message index too high") + ErrProtocolViolation = errors.New("not protocol message order") + ErrMessageKeyNotFound = errors.New("message key not found") + ErrChainTooHigh = errors.New("chain index too high") + ErrBadInput = errors.New("bad input") + ErrUnknownOlmPickleVersion = errors.New("unknown olm pickle version") + ErrUnknownJSONPickleVersion = errors.New("unknown JSON pickle version") + ErrInputToSmall = errors.New("input too small (truncated?)") +) + +// Error codes from go-olm +var ( + ErrNotEnoughGoRandom = errors.New("couldn't get enough randomness from crypto/rand") + ErrInputNotJSONString = errors.New("input doesn't look like a JSON string") +) + +// Error codes from olm code +var ( + ErrLibolmInvalidBase64 = errors.New("the input base64 was invalid") + + ErrLibolmNotEnoughRandom = errors.New("not enough entropy was supplied") + ErrLibolmOutputBufferTooSmall = errors.New("supplied output buffer is too small") + ErrLibolmBadAccountKey = errors.New("the supplied account key is invalid") + ErrLibolmCorruptedPickle = errors.New("the pickled object couldn't be decoded") + ErrLibolmBadSessionKey = errors.New("attempt to initialise an inbound group session from an invalid session key") + ErrLibolmBadLegacyAccountPickle = errors.New("attempt to unpickle an account which uses pickle version 1") +) + +// Deprecated: use variables prefixed with Err +var ( + EmptyInput = ErrEmptyInput + BadSignature = ErrBadSignature + InvalidBase64 = ErrLibolmInvalidBase64 + BadMessageKeyID = ErrBadMessageKeyID + BadMessageFormat = ErrBadMessageFormat + BadMessageVersion = ErrWrongProtocolVersion + BadMessageMAC = ErrBadMAC + UnknownPickleVersion = ErrUnknownOlmPickleVersion + NotEnoughRandom = ErrLibolmNotEnoughRandom + OutputBufferTooSmall = ErrLibolmOutputBufferTooSmall + BadAccountKey = ErrLibolmBadAccountKey + CorruptedPickle = ErrLibolmCorruptedPickle + BadSessionKey = ErrLibolmBadSessionKey + UnknownMessageIndex = ErrUnknownMessageIndex + BadLegacyAccountPickle = ErrLibolmBadLegacyAccountPickle + InputBufferTooSmall = ErrInputToSmall + NoKeyProvided = ErrNoKeyProvided + + NotEnoughGoRandom = ErrNotEnoughGoRandom + InputNotJSONString = ErrInputNotJSONString + + ErrBadVersion = ErrUnknownJSONPickleVersion + ErrWrongPickleVersion = ErrUnknownJSONPickleVersion + ErrRatchetNotAvailable = ErrUnknownMessageIndex +) diff --git a/mautrix-patched/crypto/olm/groupsession_test.go b/mautrix-patched/crypto/olm/groupsession_test.go new file mode 100644 index 00000000..9dbfb9e5 --- /dev/null +++ b/mautrix-patched/crypto/olm/groupsession_test.go @@ -0,0 +1,50 @@ +//go:build !goolm + +package olm_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/libolm" +) + +// TestEncryptDecrypt_GoolmToLibolm tests encryption where goolm encrypts and libolm decrypts +func TestEncryptDecrypt_GoolmToLibolm(t *testing.T) { + goolmOutbound, err := session.NewMegolmOutboundSession() + require.NoError(t, err) + + libolmInbound, err := libolm.NewInboundGroupSession([]byte(goolmOutbound.Key())) + require.NoError(t, err) + + for i := 0; i < 10; i++ { + ciphertext, err := goolmOutbound.Encrypt([]byte(fmt.Sprintf("message %d", i))) + require.NoError(t, err) + + plaintext, msgIdx, err := libolmInbound.Decrypt(ciphertext) + assert.NoError(t, err) + assert.Equal(t, []byte(fmt.Sprintf("message %d", i)), plaintext) + assert.Equal(t, goolmOutbound.MessageIndex()-1, msgIdx) + } +} + +func TestEncryptDecrypt_LibolmToGoolm(t *testing.T) { + libolmOutbound, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + goolmInbound, err := session.NewMegolmInboundSession([]byte(libolmOutbound.Key())) + require.NoError(t, err) + + for i := 0; i < 10; i++ { + ciphertext, err := libolmOutbound.Encrypt([]byte(fmt.Sprintf("message %d", i))) + require.NoError(t, err) + + plaintext, msgIdx, err := goolmInbound.Decrypt(ciphertext) + assert.NoError(t, err) + assert.Equal(t, []byte(fmt.Sprintf("message %d", i)), plaintext) + assert.Equal(t, libolmOutbound.MessageIndex()-1, msgIdx) + } +} diff --git a/mautrix-patched/crypto/olm/inboundgroupsession.go b/mautrix-patched/crypto/olm/inboundgroupsession.go new file mode 100644 index 00000000..8839b48c --- /dev/null +++ b/mautrix-patched/crypto/olm/inboundgroupsession.go @@ -0,0 +1,80 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +import "maunium.net/go/mautrix/id" + +type InboundGroupSession interface { + // Pickle returns an InboundGroupSession as a base64 string. Encrypts the + // InboundGroupSession using the supplied key. + Pickle(key []byte) ([]byte, error) + + // Unpickle loads an [InboundGroupSession] from a pickled base64 string. + // Decrypts the [InboundGroupSession] using the supplied key. + Unpickle(pickled, key []byte) error + + // Decrypt decrypts a message using the [InboundGroupSession]. Returns the + // plain-text and message index on success. Returns error on failure. If + // the base64 couldn't be decoded then the error will be "INVALID_BASE64". + // If the message is for an unsupported version of the protocol then the + // error will be "BAD_MESSAGE_VERSION". If the message couldn't be decoded + // then the error will be BAD_MESSAGE_FORMAT". If the MAC on the message + // was invalid then the error will be "BAD_MESSAGE_MAC". If we do not have + // a session key corresponding to the message's index (ie, it was sent + // before the session key was shared with us) the error will be + // "OLM_UNKNOWN_MESSAGE_INDEX". + Decrypt(message []byte) ([]byte, uint, error) + + // ID returns a base64-encoded identifier for this session. + ID() id.SessionID + + // FirstKnownIndex returns the first message index we know how to decrypt. + FirstKnownIndex() uint32 + + // IsVerified check if the session has been verified as a valid session. + // (A session is verified either because the original session share was + // signed, or because we have subsequently successfully decrypted a + // message.) + IsVerified() bool + + // Export returns the base64-encoded ratchet key for this session, at the + // given index, in a format which can be used by + // InboundGroupSession.InboundGroupSessionImport(). Encrypts the + // InboundGroupSession using the supplied key. Returns error on failure. + // if we do not have a session key corresponding to the given index (ie, it + // was sent before the session key was shared with us) the error will be + // "OLM_UNKNOWN_MESSAGE_INDEX". + Export(messageIndex uint32) ([]byte, error) +} + +var InitInboundGroupSessionFromPickled func(pickled, key []byte) (InboundGroupSession, error) +var InitNewInboundGroupSession func(sessionKey []byte) (InboundGroupSession, error) +var InitInboundGroupSessionImport func(sessionKey []byte) (InboundGroupSession, error) +var InitBlankInboundGroupSession func() InboundGroupSession + +// InboundGroupSessionFromPickled loads an InboundGroupSession from a pickled +// base64 string. Decrypts the InboundGroupSession using the supplied key. +// Returns error on failure. +func InboundGroupSessionFromPickled(pickled, key []byte) (InboundGroupSession, error) { + return InitInboundGroupSessionFromPickled(pickled, key) +} + +// NewInboundGroupSession creates a new inbound group session from a key +// exported from OutboundGroupSession.Key(). Returns error on failure. +func NewInboundGroupSession(sessionKey []byte) (InboundGroupSession, error) { + return InitNewInboundGroupSession(sessionKey) +} + +// InboundGroupSessionImport imports an inbound group session from a previous +// export. Returns error on failure. +func InboundGroupSessionImport(sessionKey []byte) (InboundGroupSession, error) { + return InitInboundGroupSessionImport(sessionKey) +} + +func NewBlankInboundGroupSession() InboundGroupSession { + return InitBlankInboundGroupSession() +} diff --git a/mautrix-patched/crypto/olm/olm.go b/mautrix-patched/crypto/olm/olm.go new file mode 100644 index 00000000..fa2345e1 --- /dev/null +++ b/mautrix-patched/crypto/olm/olm.go @@ -0,0 +1,20 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +var GetVersion func() (major, minor, patch uint8) +var SetPickleKeyImpl func(key []byte) + +// Version returns the version number of the olm library. +func Version() (major, minor, patch uint8) { + return GetVersion() +} + +// SetPickleKey sets the global pickle key used when encoding structs with Gob or JSON. +func SetPickleKey(key []byte) { + SetPickleKeyImpl(key) +} diff --git a/mautrix-patched/crypto/olm/outboundgroupsession.go b/mautrix-patched/crypto/olm/outboundgroupsession.go new file mode 100644 index 00000000..7e582b7e --- /dev/null +++ b/mautrix-patched/crypto/olm/outboundgroupsession.go @@ -0,0 +1,57 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +import "maunium.net/go/mautrix/id" + +type OutboundGroupSession interface { + // Pickle returns a Session as a base64 string. Encrypts the Session using + // the supplied key. + Pickle(key []byte) ([]byte, error) + + // Unpickle loads an [OutboundGroupSession] from a pickled base64 string. + // Decrypts the [OutboundGroupSession] using the supplied key. + Unpickle(pickled, key []byte) error + + // Encrypt encrypts a message using the [OutboundGroupSession]. Returns the + // encrypted message as base64. + Encrypt(plaintext []byte) ([]byte, error) + + // ID returns a base64-encoded identifier for this session. + ID() id.SessionID + + // MessageIndex returns the message index for this session. Each message + // is sent with an increasing index; this returns the index for the next + // message. + MessageIndex() uint + + // Key returns the base64-encoded current ratchet key for this session. + Key() string +} + +var InitNewOutboundGroupSessionFromPickled func(pickled, key []byte) (OutboundGroupSession, error) +var InitNewOutboundGroupSession func() (OutboundGroupSession, error) +var InitNewBlankOutboundGroupSession func() OutboundGroupSession + +// OutboundGroupSessionFromPickled loads an OutboundGroupSession from a pickled +// base64 string. Decrypts the OutboundGroupSession using the supplied key. +// Returns error on failure. If the key doesn't match the one used to encrypt +// the OutboundGroupSession then the error will be "BAD_SESSION_KEY". If the +// base64 couldn't be decoded then the error will be "INVALID_BASE64". +func OutboundGroupSessionFromPickled(pickled, key []byte) (OutboundGroupSession, error) { + return InitNewOutboundGroupSessionFromPickled(pickled, key) +} + +// NewOutboundGroupSession creates a new outbound group session. +func NewOutboundGroupSession() (OutboundGroupSession, error) { + return InitNewOutboundGroupSession() +} + +// NewBlankOutboundGroupSession initialises an empty [OutboundGroupSession]. +func NewBlankOutboundGroupSession() OutboundGroupSession { + return InitNewBlankOutboundGroupSession() +} diff --git a/mautrix-patched/crypto/olm/outboundgroupsession_test.go b/mautrix-patched/crypto/olm/outboundgroupsession_test.go new file mode 100644 index 00000000..afd0383f --- /dev/null +++ b/mautrix-patched/crypto/olm/outboundgroupsession_test.go @@ -0,0 +1,135 @@ +//go:build !goolm + +package olm_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/libolm" +) + +func TestMegolmOutboundSessionPickle_RoundtripThroughGoolm(t *testing.T) { + libolmSession, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + libolmPickled, err := libolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + goolmSession, err := session.MegolmOutboundSessionFromPickled(libolmPickled, []byte("test")) + require.NoError(t, err) + + goolmPickled, err := goolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + assert.Equal(t, libolmPickled, goolmPickled, "pickled versions are not the same") + + libolmSession2, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + err = libolmSession2.Unpickle(bytes.Clone(goolmPickled), []byte("test")) + require.NoError(t, err) + + assert.Equal(t, libolmSession.Key(), libolmSession2.Key()) +} + +func TestMegolmOutboundSessionPickle_RoundtripThroughLibolm(t *testing.T) { + goolmSession, err := session.NewMegolmOutboundSession() + require.NoError(t, err) + + goolmPickled, err := goolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + libolmSession, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + err = libolmSession.Unpickle(bytes.Clone(goolmPickled), []byte("test")) + require.NoError(t, err) + + libolmPickled, err := libolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + assert.Equal(t, goolmPickled, libolmPickled, "pickled versions are not the same") + + goolmSession2, err := session.MegolmOutboundSessionFromPickled(libolmPickled, []byte("test")) + require.NoError(t, err) + + assert.Equal(t, goolmSession.Key(), goolmSession2.Key()) + assert.Equal(t, goolmSession.SigningKey.PrivateKey, goolmSession2.SigningKey.PrivateKey) +} + +func TestMegolmOutboundSessionPickleLibolm(t *testing.T) { + libolmSession, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + libolmPickled, err := libolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + goolmSession, err := session.MegolmOutboundSessionFromPickled(bytes.Clone(libolmPickled), []byte("test")) + require.NoError(t, err) + goolmPickled, err := goolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + assert.Equal(t, libolmPickled, goolmPickled, "pickled versions are not the same") + assert.Equal(t, goolmSession.SigningKey.PrivateKey.PubKey(), goolmSession.SigningKey.PublicKey) + + // Ensure that the key export is the same and that the pickle is the same + assert.Equal(t, libolmSession.Key(), goolmSession.Key(), "keys are not the same") +} + +func TestMegolmOutboundSessionPickleGoolm(t *testing.T) { + goolmSession, err := session.NewMegolmOutboundSession() + require.NoError(t, err) + goolmPickled, err := goolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + libolmSession, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + err = libolmSession.Unpickle(bytes.Clone(goolmPickled), []byte("test")) + require.NoError(t, err) + libolmPickled, err := libolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + assert.Equal(t, libolmPickled, goolmPickled, "pickled versions are not the same") + assert.Equal(t, goolmSession.SigningKey.PrivateKey.PubKey(), goolmSession.SigningKey.PublicKey) + + // Ensure that the key export is the same and that the pickle is the same + assert.Equal(t, libolmSession.Key(), goolmSession.Key(), "keys are not the same") +} + +func FuzzMegolmOutboundSession_Encrypt(f *testing.F) { + f.Add([]byte("anything")) + + f.Fuzz(func(t *testing.T, plaintext []byte) { + if len(plaintext) == 0 { + t.Skip("empty plaintext is not supported") + } + + libolmSession, err := libolm.NewOutboundGroupSession() + require.NoError(t, err) + libolmPickled, err := libolmSession.Pickle([]byte("test")) + require.NoError(t, err) + + goolmSession, err := session.MegolmOutboundSessionFromPickled(bytes.Clone(libolmPickled), []byte("test")) + require.NoError(t, err) + + assert.Equal(t, libolmSession.Key(), goolmSession.Key()) + + // Encrypt the plaintext ten times because the ratchet increments. + for i := 0; i < 10; i++ { + assert.EqualValues(t, i, libolmSession.MessageIndex()) + assert.EqualValues(t, i, goolmSession.MessageIndex()) + + libolmEncrypted, err := libolmSession.Encrypt(plaintext) + require.NoError(t, err) + + goolmEncrypted, err := goolmSession.Encrypt(plaintext) + require.NoError(t, err) + + assert.Equal(t, libolmEncrypted, goolmEncrypted) + + assert.EqualValues(t, i+1, libolmSession.MessageIndex()) + assert.EqualValues(t, i+1, goolmSession.MessageIndex()) + } + }) +} diff --git a/mautrix-patched/crypto/olm/pk.go b/mautrix-patched/crypto/olm/pk.go new file mode 100644 index 00000000..b671bc33 --- /dev/null +++ b/mautrix-patched/crypto/olm/pk.go @@ -0,0 +1,41 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +import ( + "maunium.net/go/mautrix/id" +) + +// PKSigning is an interface for signing messages. +type PKSigning interface { + // Seed returns the seed of the key. + Seed() []byte + + // PublicKey returns the public key. + PublicKey() id.Ed25519 + + // Sign creates a signature for the given message using this key. + Sign(message []byte) ([]byte, error) + + // SignJSON creates a signature for the given object after encoding it to + // canonical JSON. + SignJSON(obj any) (string, error) +} + +var InitNewPKSigning func() (PKSigning, error) +var InitNewPKSigningFromSeed func(seed []byte) (PKSigning, error) + +// NewPKSigning creates a new [PKSigning] object, containing a key pair for +// signing messages. +func NewPKSigning() (PKSigning, error) { + return InitNewPKSigning() +} + +// NewPKSigningFromSeed creates a new PKSigning object using the given seed. +func NewPKSigningFromSeed(seed []byte) (PKSigning, error) { + return InitNewPKSigningFromSeed(seed) +} diff --git a/mautrix-patched/crypto/olm/pk_test.go b/mautrix-patched/crypto/olm/pk_test.go new file mode 100644 index 00000000..19dab226 --- /dev/null +++ b/mautrix-patched/crypto/olm/pk_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Only run this test if goolm is disabled (that is, libolm is used). + +//go:build !goolm + +package olm_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/goolm/pk" + "maunium.net/go/mautrix/crypto/libolm" +) + +func FuzzSign(f *testing.F) { + seed := []byte("Quohboh3ka3ooghequier9lee8Bahwoh") + goolmPkSigning, err := pk.NewSigningFromSeed(seed) + require.NoError(f, err) + + libolmPkSigning, err := libolm.NewPKSigningFromSeed(seed) + require.NoError(f, err) + + f.Add([]byte("message")) + + f.Fuzz(func(t *testing.T, message []byte) { + // libolm breaks with empty messages, so don't perform differential + // fuzzing on that. + if len(message) == 0 { + return + } + + libolmResult, libolmErr := libolmPkSigning.Sign(message) + goolmResult, goolmErr := goolmPkSigning.Sign(message) + + assert.Equal(t, goolmErr, libolmErr) + assert.Equal(t, goolmResult, libolmResult) + }) +} diff --git a/mautrix-patched/crypto/olm/session.go b/mautrix-patched/crypto/olm/session.go new file mode 100644 index 00000000..c4b91ffc --- /dev/null +++ b/mautrix-patched/crypto/olm/session.go @@ -0,0 +1,83 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package olm + +import "maunium.net/go/mautrix/id" + +type Session interface { + // Pickle returns a Session as a base64 string. Encrypts the Session using + // the supplied key. + Pickle(key []byte) ([]byte, error) + + // Unpickle loads a Session from a pickled base64 string. Decrypts the + // Session using the supplied key. + Unpickle(pickled, key []byte) error + + // ID returns an identifier for this Session. Will be the same for both + // ends of the conversation. + ID() id.SessionID + + // HasReceivedMessage returns true if this session has received any + // message. + HasReceivedMessage() bool + + // MatchesInboundSession checks if the PRE_KEY message is for this in-bound + // Session. This can happen if multiple messages are sent to this Account + // before this Account sends a message in reply. Returns true if the + // session matches. Returns false if the session does not match. Returns + // error on failure. If the base64 couldn't be decoded then the error will + // be "INVALID_BASE64". If the message was for an unsupported protocol + // version then the error will be "BAD_MESSAGE_VERSION". If the message + // couldn't be decoded then then the error will be "BAD_MESSAGE_FORMAT". + MatchesInboundSession(oneTimeKeyMsg string) (bool, error) + + // MatchesInboundSessionFrom checks if the PRE_KEY message is for this + // in-bound Session. This can happen if multiple messages are sent to this + // Account before this Account sends a message in reply. Returns true if + // the session matches. Returns false if the session does not match. + // Returns error on failure. If the base64 couldn't be decoded then the + // error will be "INVALID_BASE64". If the message was for an unsupported + // protocol version then the error will be "BAD_MESSAGE_VERSION". If the + // message couldn't be decoded then then the error will be + // "BAD_MESSAGE_FORMAT". + MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) + + // EncryptMsgType returns the type of the next message that Encrypt will + // return. Returns MsgTypePreKey if the message will be a PRE_KEY message. + // Returns MsgTypeMsg if the message will be a normal message. + EncryptMsgType() id.OlmMsgType + + // Encrypt encrypts a message using the Session. Returns the encrypted + // message as base64. + Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) + + // Decrypt decrypts a message using the Session. Returns the plain-text on + // success. Returns error on failure. If the base64 couldn't be decoded + // then the error will be "INVALID_BASE64". If the message is for an + // unsupported version of the protocol then the error will be + // "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error + // will be BAD_MESSAGE_FORMAT". If the MAC on the message was invalid then + // the error will be "BAD_MESSAGE_MAC". + Decrypt(message string, msgType id.OlmMsgType) ([]byte, error) + + // Describe generates a string describing the internal state of an olm + // session for debugging and logging purposes. + Describe() string +} + +var InitSessionFromPickled func(pickled, key []byte) (Session, error) +var InitNewBlankSession func() Session + +// SessionFromPickled loads a Session from a pickled base64 string. Decrypts +// the Session using the supplied key. Returns error on failure. +func SessionFromPickled(pickled, key []byte) (Session, error) { + return InitSessionFromPickled(pickled, key) +} + +func NewBlankSession() Session { + return InitNewBlankSession() +} diff --git a/mautrix-patched/crypto/olm/session_test.go b/mautrix-patched/crypto/olm/session_test.go new file mode 100644 index 00000000..9cbac675 --- /dev/null +++ b/mautrix-patched/crypto/olm/session_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build !goolm + +package olm_test + +import ( + "bytes" + "fmt" + "math/rand/v2" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exerrors" + "golang.org/x/exp/maps" + + "maunium.net/go/mautrix/crypto/goolm/account" + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/libolm" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +func TestBlankSession(t *testing.T) { + libolmSession := libolm.NewBlankSession() + session := session.NewOlmSession() + + assert.Equal(t, libolmSession.ID(), session.ID()) + assert.Equal(t, libolmSession.HasReceivedMessage(), session.HasReceivedMessage()) + assert.Equal(t, libolmSession.EncryptMsgType(), session.EncryptMsgType()) + assert.Equal(t, libolmSession.Describe(), session.Describe()) + + libolmPickled, err := libolmSession.Pickle([]byte("test")) + assert.NoError(t, err) + goolmPickled, err := session.Pickle([]byte("test")) + assert.NoError(t, err) + assert.Equal(t, goolmPickled, libolmPickled) +} + +func TestSessionPickle(t *testing.T) { + pickledDataFromLibOlm := []byte("icDKYm0b4aO23WgUuOxdpPoxC0UlEOYPVeuduNH3IkpFsmnWx5KuEOpxGiZw5IuB/sSn2RZUCTiJ90IvgC7AClkYGHep9O8lpiqQX73XVKD9okZDCAkBc83eEq0DKYC7HBkGRAU/4T6QPIBBY3UK4QZwULLE/fLsi3j4YZBehMtnlsqgHK0q1bvX4cRznZItVKR4ro0O9EAk6LLxJtSnRu5elSUk7YXT") + pickleKey := []byte("secret_key") + + goolmSession, err := session.OlmSessionFromPickled(bytes.Clone(pickledDataFromLibOlm), pickleKey) + assert.NoError(t, err) + + libolmSession, err := libolm.SessionFromPickled(bytes.Clone(pickledDataFromLibOlm), pickleKey) + assert.NoError(t, err) + + goolmPickled, err := goolmSession.Pickle(pickleKey) + require.NoError(t, err) + assert.Equal(t, pickledDataFromLibOlm, goolmPickled) + + libolmPickled, err := libolmSession.Pickle(pickleKey) + require.NoError(t, err) + assert.Equal(t, pickledDataFromLibOlm, libolmPickled) +} + +func TestSession_EncryptDecrypt(t *testing.T) { + combos := [][2]olm.Account{ + {exerrors.Must(libolm.NewAccount()), exerrors.Must(libolm.NewAccount())}, + {exerrors.Must(account.NewAccount()), exerrors.Must(account.NewAccount())}, + {exerrors.Must(libolm.NewAccount()), exerrors.Must(account.NewAccount())}, + {exerrors.Must(account.NewAccount()), exerrors.Must(libolm.NewAccount())}, + } + + for _, combo := range combos { + receiver, sender := combo[0], combo[1] + require.NoError(t, receiver.GenOneTimeKeys(50)) + require.NoError(t, sender.GenOneTimeKeys(50)) + + _, receiverCurve25519, err := receiver.IdentityKeys() + require.NoError(t, err) + accountAOTKs, err := receiver.OneTimeKeys() + require.NoError(t, err) + + senderSession, err := sender.NewOutboundSession(receiverCurve25519, accountAOTKs[maps.Keys(accountAOTKs)[0]]) + require.NoError(t, err) + + // Send a couple pre-key messages from sender -> receiver. + var receiverSession olm.Session + for i := 0; i < 10; i++ { + msgType, ciphertext, err := senderSession.Encrypt([]byte(fmt.Sprintf("prekey %d", i))) + require.NoError(t, err) + assert.Equal(t, id.OlmMsgTypePreKey, msgType) + + receiverSession, err = receiver.NewInboundSession(string(ciphertext)) + require.NoError(t, err) + + decrypted, err := receiverSession.Decrypt(string(ciphertext), msgType) + require.NoError(t, err) + assert.Equal(t, []byte(fmt.Sprintf("prekey %d", i)), decrypted) + } + + // Send some messages from receiver -> sender. + for i := 0; i < 10; i++ { + msgType, ciphertext, err := receiverSession.Encrypt([]byte(fmt.Sprintf("response %d", i))) + require.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + + decrypted, err := senderSession.Decrypt(string(ciphertext), msgType) + require.NoError(t, err) + assert.Equal(t, []byte(fmt.Sprintf("response %d", i)), decrypted) + } + + // Send some more messages from sender -> receiver + for i := 0; i < 10; i++ { + msgType, ciphertext, err := senderSession.Encrypt([]byte(fmt.Sprintf("%d", i))) + require.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + + decrypted, err := receiverSession.Decrypt(string(ciphertext), msgType) + require.NoError(t, err) + assert.Equal(t, []byte(fmt.Sprintf("%d", i)), decrypted) + } + + // Misordered messages + messages := make([][]byte, 10) + plainMessages := make([]string, 10) + for i := 0; i < 10; i++ { + plainMessages[i] = fmt.Sprintf("meow%d", i) + msgType, ciphertext, err := senderSession.Encrypt([]byte(plainMessages[i])) + require.NoError(t, err) + assert.Equal(t, id.OlmMsgTypeMsg, msgType) + messages[i] = ciphertext + + } + rand.Shuffle(len(messages), func(i, j int) { + messages[i], messages[j] = messages[j], messages[i] + plainMessages[i], plainMessages[j] = plainMessages[j], plainMessages[i] + }) + for i, ciphertext := range messages { + decrypted, err := receiverSession.Decrypt(string(ciphertext), id.OlmMsgTypeMsg) + require.NoError(t, err) + assert.Equal(t, []byte(plainMessages[i]), decrypted) + } + } +} diff --git a/mautrix-patched/crypto/registergoolm.go b/mautrix-patched/crypto/registergoolm.go new file mode 100644 index 00000000..6b5b65fd --- /dev/null +++ b/mautrix-patched/crypto/registergoolm.go @@ -0,0 +1,11 @@ +//go:build goolm + +package crypto + +import ( + "maunium.net/go/mautrix/crypto/goolm" +) + +func init() { + goolm.Register() +} diff --git a/mautrix-patched/crypto/registerlibolm.go b/mautrix-patched/crypto/registerlibolm.go new file mode 100644 index 00000000..ef78b6b5 --- /dev/null +++ b/mautrix-patched/crypto/registerlibolm.go @@ -0,0 +1,9 @@ +//go:build !goolm + +package crypto + +import "maunium.net/go/mautrix/crypto/libolm" + +func init() { + libolm.Register() +} diff --git a/mautrix-patched/crypto/sessions.go b/mautrix-patched/crypto/sessions.go new file mode 100644 index 00000000..e93a8f52 --- /dev/null +++ b/mautrix-patched/crypto/sessions.go @@ -0,0 +1,290 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "errors" + "fmt" + "time" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/event" + + "maunium.net/go/mautrix/id" +) + +var ( + ErrSessionNotShared = errors.New("session has not been shared") + ErrSessionExpired = errors.New("session has expired") +) + +// Deprecated: use variables prefixed with Err +var ( + SessionNotShared = ErrSessionNotShared + SessionExpired = ErrSessionExpired +) + +// OlmSessionList is a list of OlmSessions. +// It implements sort.Interface so that the session with recent successful decryptions comes first. +type OlmSessionList []*OlmSession + +func (o OlmSessionList) Len() int { + return len(o) +} + +func (o OlmSessionList) Less(i, j int) bool { + return o[i].LastDecryptedTime.After(o[j].LastEncryptedTime) +} + +func (o OlmSessionList) Swap(i, j int) { + o[i], o[j] = o[j], o[i] +} + +type OlmSession struct { + Internal olm.Session + ExpirationMixin + id id.SessionID +} + +func (session *OlmSession) ID() id.SessionID { + if session.id == "" { + session.id = session.Internal.ID() + } + return session.id +} + +func (session *OlmSession) Describe() string { + return session.Internal.Describe() +} + +func wrapSession(session olm.Session) *OlmSession { + return &OlmSession{ + Internal: session, + ExpirationMixin: ExpirationMixin{ + TimeMixin: TimeMixin{ + CreationTime: time.Now(), + LastEncryptedTime: time.Now(), + LastDecryptedTime: time.Now(), + }, + }, + } +} + +func (account *OlmAccount) NewInboundSessionFrom(senderKey id.Curve25519, ciphertext string) (*OlmSession, error) { + session, err := account.Internal.NewInboundSessionFrom(&senderKey, ciphertext) + if err != nil { + return nil, err + } + return wrapSession(session), nil +} + +func (session *OlmSession) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { + session.LastEncryptedTime = time.Now() + return session.Internal.Encrypt(plaintext) +} + +func (session *OlmSession) Decrypt(ciphertext string, msgType id.OlmMsgType) ([]byte, error) { + msg, err := session.Internal.Decrypt(ciphertext, msgType) + if err == nil { + session.LastDecryptedTime = time.Now() + } + return msg, err +} + +type RatchetSafety struct { + NextIndex uint `json:"next_index"` + MissedIndices []uint `json:"missed_indices,omitempty"` + LostIndices []uint `json:"lost_indices,omitempty"` +} + +type InboundGroupSession struct { + Internal olm.InboundGroupSession + + SigningKey id.Ed25519 + SenderKey id.Curve25519 + RoomID id.RoomID + + ForwardingChains []string + RatchetSafety RatchetSafety + + ReceivedAt time.Time + MaxAge int64 + MaxMessages int + IsScheduled bool + KeyBackupVersion id.KeyBackupVersion + KeySource id.KeySource + + id id.SessionID +} + +func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomID id.RoomID, sessionKey string, maxAge time.Duration, maxMessages int, isScheduled bool) (*InboundGroupSession, error) { + igs, err := olm.NewInboundGroupSession([]byte(sessionKey)) + if err != nil { + return nil, err + } + return &InboundGroupSession{ + Internal: igs, + SigningKey: signingKey, + SenderKey: senderKey, + RoomID: roomID, + ForwardingChains: []string{}, + ReceivedAt: time.Now().UTC(), + MaxAge: maxAge.Milliseconds(), + MaxMessages: maxMessages, + IsScheduled: isScheduled, + KeySource: id.KeySourceDirect, + }, nil +} + +func (igs *InboundGroupSession) ID() id.SessionID { + if igs.id == "" { + igs.id = igs.Internal.ID() + } + return igs.id +} + +func (igs *InboundGroupSession) RatchetTo(index uint32) error { + exported, err := igs.Internal.Export(index) + if err != nil { + return err + } + imported, err := olm.InboundGroupSessionImport(exported) + if err != nil { + return err + } + igs.Internal = imported + return nil +} + +func (igs *InboundGroupSession) export() (*ExportedSession, error) { + key, err := igs.Internal.Export(igs.Internal.FirstKnownIndex()) + if err != nil { + return nil, fmt.Errorf("failed to export session: %w", err) + } + return &ExportedSession{ + Algorithm: id.AlgorithmMegolmV1, + ForwardingChains: igs.ForwardingChains, + RoomID: igs.RoomID, + SenderKey: igs.SenderKey, + SenderClaimedKeys: SenderClaimedKeys{Ed25519: igs.SigningKey}, + SessionID: igs.ID(), + SessionKey: string(key), + }, nil +} + +type OGSState int + +const ( + OGSNotShared OGSState = iota + OGSAlreadyShared + OGSIgnored +) + +type UserDevice struct { + UserID id.UserID + DeviceID id.DeviceID +} + +type OutboundGroupSession struct { + Internal olm.OutboundGroupSession + + ExpirationMixin + MaxMessages int + MessageCount int + + Users map[UserDevice]OGSState + RoomID id.RoomID + Shared bool + + id id.SessionID + content *event.RoomKeyEventContent +} + +func NewOutboundGroupSession(roomID id.RoomID, encryptionContent *event.EncryptionEventContent) (*OutboundGroupSession, error) { + internal, err := olm.NewOutboundGroupSession() + if err != nil { + return nil, err + } + ogs := &OutboundGroupSession{ + Internal: internal, + ExpirationMixin: ExpirationMixin{ + TimeMixin: TimeMixin{ + CreationTime: time.Now(), + LastEncryptedTime: time.Now(), + }, + MaxAge: 7 * 24 * time.Hour, + }, + MaxMessages: 100, + Shared: false, + Users: make(map[UserDevice]OGSState), + RoomID: roomID, + } + if encryptionContent != nil { + // Clamp rotation period to prevent unreasonable values + // Similar to https://github.com/matrix-org/matrix-rust-sdk/blob/matrix-sdk-crypto-0.7.1/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs#L415-L441 + if encryptionContent.RotationPeriodMillis != 0 { + ogs.MaxAge = time.Duration(encryptionContent.RotationPeriodMillis) * time.Millisecond + ogs.MaxAge = min(max(ogs.MaxAge, 1*time.Hour), 365*24*time.Hour) + } + if encryptionContent.RotationPeriodMessages != 0 { + ogs.MaxMessages = min(max(encryptionContent.RotationPeriodMessages, 1), 10000) + } + } + return ogs, nil +} + +func (ogs *OutboundGroupSession) ShareContent() event.Content { + if ogs.content == nil { + ogs.content = &event.RoomKeyEventContent{ + Algorithm: id.AlgorithmMegolmV1, + RoomID: ogs.RoomID, + SessionID: ogs.ID(), + SessionKey: ogs.Internal.Key(), + } + } + return event.Content{Parsed: ogs.content} +} + +func (ogs *OutboundGroupSession) ID() id.SessionID { + if ogs.id == "" { + ogs.id = ogs.Internal.ID() + } + return ogs.id +} + +func (ogs *OutboundGroupSession) Expired() bool { + return ogs.MessageCount >= ogs.MaxMessages || ogs.ExpirationMixin.Expired() +} + +func (ogs *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { + if !ogs.Shared { + return nil, ErrSessionNotShared + } else if ogs.Expired() { + return nil, ErrSessionExpired + } + ogs.MessageCount++ + ogs.LastEncryptedTime = time.Now() + return ogs.Internal.Encrypt(plaintext) +} + +type TimeMixin struct { + CreationTime time.Time + LastEncryptedTime time.Time + LastDecryptedTime time.Time +} + +type ExpirationMixin struct { + TimeMixin + MaxAge time.Duration +} + +func (exp *ExpirationMixin) Expired() bool { + if exp.MaxAge == 0 { + return false + } + return exp.CreationTime.Add(exp.MaxAge).Before(time.Now()) +} diff --git a/mautrix-patched/crypto/sharing.go b/mautrix-patched/crypto/sharing.go new file mode 100644 index 00000000..03df34ab --- /dev/null +++ b/mautrix-patched/crypto/sharing.go @@ -0,0 +1,190 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "time" + + "go.mau.fi/util/ptr" + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Callback function to process a received secret. +// +// Returning true or an error will immediately return from the wait loop, returning false will continue waiting for new responses. +type SecretReceiverFunc func(string) (bool, error) + +func (mach *OlmMachine) GetOrRequestSecret(ctx context.Context, name id.Secret, receiver SecretReceiverFunc, timeout time.Duration) (err error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // always offer our stored secret first, if any + secret, err := mach.CryptoStore.GetSecret(ctx, name) + if err != nil { + return err + } else if secret != "" { + if ok, err := receiver(secret); ok || err != nil { + return err + } + } + + requestID, secretChan := random.String(64), make(chan string, 5) + mach.secretLock.Lock() + mach.secretListeners[requestID] = secretChan + mach.secretLock.Unlock() + defer func() { + mach.secretLock.Lock() + delete(mach.secretListeners, requestID) + mach.secretLock.Unlock() + }() + + // request secret from any device + err = mach.sendToOneDevice(ctx, mach.Client.UserID, id.DeviceID("*"), event.ToDeviceSecretRequest, &event.SecretRequestEventContent{ + Action: event.SecretRequestRequest, + RequestID: requestID, + Name: name, + RequestingDeviceID: mach.Client.DeviceID, + }) + if err != nil { + return + } + + // best effort cancel request from all devices when returning + defer func() { + go mach.sendToOneDevice(context.Background(), mach.Client.UserID, id.DeviceID("*"), event.ToDeviceSecretRequest, &event.SecretRequestEventContent{ + Action: event.SecretRequestCancellation, + RequestID: requestID, + RequestingDeviceID: mach.Client.DeviceID, + }) + }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case secret = <-secretChan: + if ok, err := receiver(secret); err != nil { + return err + } else if ok { + return mach.CryptoStore.PutSecret(ctx, name, secret) + } + } + } +} + +func (mach *OlmMachine) HandleSecretRequest(ctx context.Context, userID id.UserID, content *event.SecretRequestEventContent) { + log := mach.machOrContextLog(ctx).With(). + Stringer("user_id", userID). + Stringer("requesting_device_id", content.RequestingDeviceID). + Stringer("action", content.Action). + Str("request_id", content.RequestID). + Stringer("secret", content.Name). + Logger() + + log.Trace().Msg("Handling secret request") + + if content.Action == event.SecretRequestCancellation { + log.Trace().Msg("Secret request cancellation is unimplemented, ignoring") + return + } else if content.Action != event.SecretRequestRequest { + log.Warn().Msg("Ignoring unknown secret request action") + return + } + + // immediately ignore requests from other users + if userID != mach.Client.UserID || content.RequestingDeviceID == "" { + log.Debug().Msg("Secret request was not from our own device, ignoring") + return + } + + if content.RequestingDeviceID == mach.Client.DeviceID { + log.Debug().Msg("Secret request was from this device, ignoring") + return + } + + device, err := mach.GetOrFetchDevice(ctx, mach.Client.UserID, content.RequestingDeviceID) + if err != nil { + log.Err(err).Msg("Failed to get or fetch requesting device") + return + } + trust, err := mach.ResolveTrustContext(ctx, device) + if err != nil { + log.Err(err).Msg("Failed to check if requesting device is verified") + return + } + + if trust < id.TrustStateCrossSignedVerified { + log.Warn().Stringer("trust_level", trust).Msg("Requesting device is not verified, ignoring request") + return + } + + secret, err := mach.CryptoStore.GetSecret(ctx, content.Name) + if err != nil { + log.Err(err).Msg("Failed to get secret from store") + return + } else if secret != "" { + log.Debug().Msg("Responding to secret request") + mach.SendEncryptedToDevice(ctx, device, event.ToDeviceSecretSend, event.Content{ + Parsed: event.SecretSendEventContent{ + RequestID: content.RequestID, + Secret: secret, + }, + }) + } else { + log.Debug().Msg("No stored secret found, secret request ignored") + } +} + +func (mach *OlmMachine) receiveSecret(ctx context.Context, evt *DecryptedOlmEvent, content *event.SecretSendEventContent) { + log := mach.machOrContextLog(ctx).With(). + Stringer("sender", evt.Sender). + Stringer("sender_key", evt.SenderKey). + Stringer("sender_device", ptr.Val(evt.SenderDevice).DeviceID). + Str("request_id", content.RequestID). + Logger() + + log.Trace().Msg("Handling secret send request") + + // immediately ignore secrets from other users + if evt.Sender != mach.Client.UserID { + log.Warn().Msg("Secret send was not from our own device") + return + } else if content.Secret == "" { + log.Warn().Msg("We were sent an empty secret") + return + } else if evt.SenderDevice == nil { + log.Warn().Msg("We were sent a secret from an unknown device") + return + } + + // https://spec.matrix.org/v1.10/client-server-api/#msecretsend + // "The recipient must ensure... that the device is a verified device owned by the recipient" + if !mach.IsDeviceTrusted(ctx, evt.SenderDevice) { + log.Warn().Msg("Sender device is not verified, rejecting secret") + return + } + + mach.secretLock.Lock() + secretChan := mach.secretListeners[content.RequestID] + mach.secretLock.Unlock() + + if secretChan == nil { + log.Warn().Msg("We were sent a secret we didn't request") + return + } + + // secret channel is buffered and we don't want to block + // at worst we drop _some_ of the responses + select { + case secretChan <- content.Secret: + default: + } +} diff --git a/mautrix-patched/crypto/signatures/signatures.go b/mautrix-patched/crypto/signatures/signatures.go new file mode 100644 index 00000000..072d9147 --- /dev/null +++ b/mautrix-patched/crypto/signatures/signatures.go @@ -0,0 +1,101 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package signatures + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/crypto/goolm/crypto" + "maunium.net/go/mautrix/id" +) + +var ( + ErrEmptyInput = errors.New("empty input") + ErrSignatureNotFound = errors.New("input JSON doesn't contain signature from specified device") +) + +// Signatures represents a set of signatures for some data from multiple users +// and keys. +type Signatures map[id.UserID]map[id.KeyID]string + +// NewSingleSignature creates a new [Signatures] object with a single +// signature. +func NewSingleSignature(userID id.UserID, algorithm id.KeyAlgorithm, keyID string, signature string) Signatures { + return Signatures{ + userID: { + id.NewKeyID(algorithm, keyID): signature, + }, + } +} + +// VerifySignature verifies an Ed25519 signature. +func VerifySignature(message []byte, key id.Ed25519, signature []byte) (ok bool, err error) { + if len(message) == 0 || len(key) == 0 || len(signature) == 0 { + return false, ErrEmptyInput + } + keyDecoded, err := base64.RawStdEncoding.DecodeString(key.String()) + if err != nil { + return false, err + } + publicKey := crypto.Ed25519PublicKey(keyDecoded) + return publicKey.Verify(message, signature), nil +} + +// VerifySignatureJSON verifies the signature in the given JSON object "obj" +// as described in [Appendix 3] of the Matrix Spec. +// +// This function is a wrapper over [Utility.VerifySignatureJSON] that creates +// and destroys the [Utility] object transparently. +// +// If the "obj" is not already a [json.RawMessage], it will re-encoded as JSON +// for the verification, so "json" tags will be honored. +// +// [Appendix 3]: https://spec.matrix.org/v1.9/appendices/#signing-json +func VerifySignatureJSON(obj any, userID id.UserID, keyName string, key id.Ed25519) (bool, error) { + var err error + objJSON, ok := obj.(json.RawMessage) + if !ok { + objJSON, err = canonicaljson.Marshal(obj) + if err != nil { + return false, err + } + } else { + // Canonicalize later may mutate the data, so clone the input + objJSON = bytes.Clone(objJSON) + } + + sig := gjson.GetBytes(objJSON, exgjson.Path("signatures", string(userID), fmt.Sprintf("ed25519:%s", keyName))) + if !sig.Exists() || sig.Type != gjson.String { + return false, ErrSignatureNotFound + } + objJSON, err = sjson.DeleteBytes(objJSON, "unsigned") + if err != nil { + return false, err + } + objJSON, err = sjson.DeleteBytes(objJSON, "signatures") + if err != nil { + return false, err + } + err = canonicaljson.Canonicalize(&objJSON) + if err != nil { + return false, fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) + } + sigBytes, err := base64.RawStdEncoding.DecodeString(sig.Str) + if err != nil { + return false, err + } + return VerifySignature(objJSON, key, sigBytes) +} diff --git a/mautrix-patched/crypto/sql_store.go b/mautrix-patched/crypto/sql_store.go new file mode 100644 index 00000000..413c2094 --- /dev/null +++ b/mautrix-patched/crypto/sql_store.go @@ -0,0 +1,983 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/goolm/libolmpickle" + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/crypto/sql_store_upgrade" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var PostgresArrayWrapper func(any) interface { + driver.Valuer + sql.Scanner +} + +// SQLCryptoStore is an implementation of a crypto Store for a database backend. +type SQLCryptoStore struct { + DB *dbutil.Database + + AccountID string + DeviceID id.DeviceID + SyncToken string + PickleKey []byte + Account *OlmAccount + + olmSessionCache map[id.SenderKey]map[id.SessionID]*OlmSession + olmSessionCacheLock sync.Mutex +} + +var _ Store = (*SQLCryptoStore)(nil) + +// NewSQLCryptoStore initializes a new crypto Store using the given database, for a device's crypto material. +// The stored material will be encrypted with the given key. +func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, accountID string, deviceID id.DeviceID, pickleKey []byte) *SQLCryptoStore { + store := &SQLCryptoStore{ + DB: db.Child(sql_store_upgrade.VersionTableName, sql_store_upgrade.Table, log), + PickleKey: pickleKey, + AccountID: accountID, + DeviceID: deviceID, + } + store.InitFields() + return store +} + +func (store *SQLCryptoStore) InitFields() { + store.olmSessionCache = make(map[id.SenderKey]map[id.SessionID]*OlmSession) +} + +// Flush does nothing for this implementation as data is already persisted in the database. +func (store *SQLCryptoStore) Flush(_ context.Context) error { + return nil +} + +// PutNextBatch stores the next sync batch token for the current account. +func (store *SQLCryptoStore) PutNextBatch(ctx context.Context, nextBatch string) error { + store.SyncToken = nextBatch + _, err := store.DB.Exec(ctx, `UPDATE crypto_account SET sync_token=$1 WHERE account_id=$2`, store.SyncToken, store.AccountID) + return err +} + +// GetNextBatch retrieves the next sync batch token for the current account. +func (store *SQLCryptoStore) GetNextBatch(ctx context.Context) (string, error) { + if store.SyncToken == "" { + err := store.DB. + QueryRow(ctx, "SELECT sync_token FROM crypto_account WHERE account_id=$1", store.AccountID). + Scan(&store.SyncToken) + if !errors.Is(err, sql.ErrNoRows) { + return "", err + } + } + return store.SyncToken, nil +} + +var _ mautrix.SyncStore = (*SQLCryptoStore)(nil) + +func (store *SQLCryptoStore) SaveFilterID(ctx context.Context, _ id.UserID, _ string) error { + return nil +} +func (store *SQLCryptoStore) LoadFilterID(ctx context.Context, _ id.UserID) (string, error) { + return "", nil +} + +func (store *SQLCryptoStore) SaveNextBatch(ctx context.Context, _ id.UserID, nextBatchToken string) error { + err := store.PutNextBatch(ctx, nextBatchToken) + if err != nil { + return fmt.Errorf("unable to store batch: %w", err) + } + return nil +} + +func (store *SQLCryptoStore) LoadNextBatch(ctx context.Context, _ id.UserID) (string, error) { + nb, err := store.GetNextBatch(ctx) + if err != nil { + return "", fmt.Errorf("unable to load batch: %w", err) + } + return nb, nil +} + +func (store *SQLCryptoStore) FindDeviceID(ctx context.Context) (deviceID id.DeviceID, err error) { + err = store.DB.QueryRow(ctx, "SELECT device_id FROM crypto_account WHERE account_id=$1", store.AccountID).Scan(&deviceID) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return +} + +// PutAccount stores an OlmAccount in the database. +func (store *SQLCryptoStore) PutAccount(ctx context.Context, account *OlmAccount) error { + store.Account = account + bytes, err := account.Internal.Pickle(store.PickleKey) + if err != nil { + return err + } + _, err = store.DB.Exec(ctx, ` + INSERT INTO crypto_account (device_id, shared, sync_token, account, account_id, key_backup_version) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (account_id) DO UPDATE SET shared=excluded.shared, sync_token=excluded.sync_token, + account=excluded.account, account_id=excluded.account_id, + key_backup_version=excluded.key_backup_version + `, store.DeviceID, account.Shared, store.SyncToken, bytes, store.AccountID, account.KeyBackupVersion) + return err +} + +// GetAccount retrieves an OlmAccount from the database. +func (store *SQLCryptoStore) GetAccount(ctx context.Context) (*OlmAccount, error) { + if store.Account == nil { + row := store.DB.QueryRow(ctx, "SELECT shared, sync_token, account, key_backup_version FROM crypto_account WHERE account_id=$1", store.AccountID) + acc := &OlmAccount{Internal: olm.NewBlankAccount()} + var accountBytes []byte + err := row.Scan(&acc.Shared, &store.SyncToken, &accountBytes, &acc.KeyBackupVersion) + if err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, err + } + err = acc.Internal.Unpickle(accountBytes, store.PickleKey) + if err != nil { + return nil, err + } + store.Account = acc + } + return store.Account, nil +} + +// HasSession returns whether there is an Olm session for the given sender key. +func (store *SQLCryptoStore) HasSession(ctx context.Context, key id.SenderKey) bool { + store.olmSessionCacheLock.Lock() + cache, ok := store.olmSessionCache[key] + store.olmSessionCacheLock.Unlock() + if ok && len(cache) > 0 { + return true + } + var sessionID id.SessionID + err := store.DB.QueryRow(ctx, "SELECT session_id FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 LIMIT 1", + key, store.AccountID).Scan(&sessionID) + if errors.Is(err, sql.ErrNoRows) { + return false + } + return len(sessionID) > 0 +} + +// GetSessions returns all the known Olm sessions for a sender key. +func (store *SQLCryptoStore) GetSessions(ctx context.Context, key id.SenderKey) (OlmSessionList, error) { + rows, err := store.DB.Query(ctx, "SELECT session_id, session, created_at, last_encrypted, last_decrypted FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 ORDER BY last_decrypted DESC", + key, store.AccountID) + if err != nil { + return nil, err + } + list := OlmSessionList{} + store.olmSessionCacheLock.Lock() + defer store.olmSessionCacheLock.Unlock() + cache := store.getOlmSessionCache(key) + for rows.Next() { + sess := OlmSession{Internal: olm.NewBlankSession()} + var sessionBytes []byte + var sessionID id.SessionID + err = rows.Scan(&sessionID, &sessionBytes, &sess.CreationTime, &sess.LastEncryptedTime, &sess.LastDecryptedTime) + if err != nil { + return nil, err + } else if existing, ok := cache[sessionID]; ok { + list = append(list, existing) + } else { + err = sess.Internal.Unpickle(sessionBytes, store.PickleKey) + if err != nil { + return nil, err + } + list = append(list, &sess) + cache[sess.ID()] = &sess + } + } + return list, nil +} + +func (store *SQLCryptoStore) getOlmSessionCache(key id.SenderKey) map[id.SessionID]*OlmSession { + data, ok := store.olmSessionCache[key] + if !ok { + data = make(map[id.SessionID]*OlmSession) + store.olmSessionCache[key] = data + } + return data +} + +// GetLatestSession retrieves the Olm session for a given sender key from the database that had the most recent successful decryption. +func (store *SQLCryptoStore) GetLatestSession(ctx context.Context, key id.SenderKey) (*OlmSession, error) { + store.olmSessionCacheLock.Lock() + defer store.olmSessionCacheLock.Unlock() + + row := store.DB.QueryRow(ctx, "SELECT session_id, session, created_at, last_encrypted, last_decrypted FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 ORDER BY last_decrypted DESC LIMIT 1", + key, store.AccountID) + + sess := OlmSession{Internal: olm.NewBlankSession()} + var sessionBytes []byte + var sessionID id.SessionID + + err := row.Scan(&sessionID, &sessionBytes, &sess.CreationTime, &sess.LastEncryptedTime, &sess.LastDecryptedTime) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + + cache := store.getOlmSessionCache(key) + if oldSess, ok := cache[sessionID]; ok { + return oldSess, nil + } else if err = sess.Internal.Unpickle(sessionBytes, store.PickleKey); err != nil { + return nil, err + } else { + cache[sessionID] = &sess + return &sess, nil + } +} + +// GetNewestSessionCreationTS gets the creation timestamp of the most recently created session with the given sender key. +// This will exclude sessions that have never been used to encrypt or decrypt a message. +func (store *SQLCryptoStore) GetNewestSessionCreationTS(ctx context.Context, key id.SenderKey) (createdAt time.Time, err error) { + err = store.DB.QueryRow(ctx, "SELECT created_at FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 AND (last_encrypted <> created_at OR last_decrypted <> created_at) ORDER BY created_at DESC LIMIT 1", + key, store.AccountID).Scan(&createdAt) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return +} + +// AddSession persists an Olm session for a sender in the database. +func (store *SQLCryptoStore) AddSession(ctx context.Context, key id.SenderKey, session *OlmSession) error { + store.olmSessionCacheLock.Lock() + defer store.olmSessionCacheLock.Unlock() + sessionBytes, err := session.Internal.Pickle(store.PickleKey) + if err != nil { + return err + } + _, err = store.DB.Exec(ctx, "INSERT INTO crypto_olm_session (session_id, sender_key, session, created_at, last_encrypted, last_decrypted, account_id) VALUES ($1, $2, $3, $4, $5, $6, $7)", + session.ID(), key, sessionBytes, session.CreationTime, session.LastEncryptedTime, session.LastDecryptedTime, store.AccountID) + store.getOlmSessionCache(key)[session.ID()] = session + return err +} + +// UpdateSession replaces the Olm session for a sender in the database. +func (store *SQLCryptoStore) UpdateSession(ctx context.Context, _ id.SenderKey, session *OlmSession) error { + sessionBytes, err := session.Internal.Pickle(store.PickleKey) + if err != nil { + return err + } + _, err = store.DB.Exec(ctx, "UPDATE crypto_olm_session SET session=$1, last_encrypted=$2, last_decrypted=$3 WHERE session_id=$4 AND account_id=$5", + sessionBytes, session.LastEncryptedTime, session.LastDecryptedTime, session.ID(), store.AccountID) + return err +} + +func (store *SQLCryptoStore) DeleteSession(ctx context.Context, _ id.SenderKey, session *OlmSession) error { + _, err := store.DB.Exec(ctx, "DELETE FROM crypto_olm_session WHERE session_id=$1 AND account_id=$2", session.ID(), store.AccountID) + return err +} + +func (store *SQLCryptoStore) PutOlmHash(ctx context.Context, messageHash [32]byte, receivedAt time.Time) error { + _, err := store.DB.Exec(ctx, "INSERT INTO crypto_olm_message_hash (account_id, received_at, message_hash) VALUES ($1, $2, $3) ON CONFLICT (message_hash) DO NOTHING", store.AccountID, receivedAt.UnixMilli(), messageHash[:]) + return err +} + +func (store *SQLCryptoStore) GetOlmHash(ctx context.Context, messageHash [32]byte) (receivedAt time.Time, err error) { + var receivedAtInt int64 + err = store.DB.QueryRow(ctx, "SELECT received_at FROM crypto_olm_message_hash WHERE message_hash=$1", messageHash[:]).Scan(&receivedAtInt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return + } + receivedAt = time.UnixMilli(receivedAtInt) + return +} + +func (store *SQLCryptoStore) DeleteOldOlmHashes(ctx context.Context, beforeTS time.Time) error { + _, err := store.DB.Exec(ctx, "DELETE FROM crypto_olm_message_hash WHERE account_id = $1 AND received_at < $2", store.AccountID, beforeTS.UnixMilli()) + return err +} + +func datePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +// PutGroupSession stores an inbound Megolm group session for a room, sender and session. +func (store *SQLCryptoStore) PutGroupSession(ctx context.Context, session *InboundGroupSession) error { + sessionBytes, err := session.Internal.Pickle(store.PickleKey) + if err != nil { + return err + } + if session.ForwardingChains == nil { + session.ForwardingChains = []string{} + } + forwardingChains := strings.Join(session.ForwardingChains, ",") + ratchetSafety, err := json.Marshal(&session.RatchetSafety) + if err != nil { + return fmt.Errorf("failed to marshal ratchet safety info: %w", err) + } + zerolog.Ctx(ctx).Debug(). + Stringer("session_id", session.ID()). + Str("account_id", store.AccountID). + Stringer("sender_key", session.SenderKey). + Stringer("signing_key", session.SigningKey). + Stringer("room_id", session.RoomID). + Time("received_at", session.ReceivedAt). + Int64("max_age", session.MaxAge). + Int("max_messages", session.MaxMessages). + Bool("is_scheduled", session.IsScheduled). + Stringer("key_backup_version", session.KeyBackupVersion). + Stringer("key_source", session.KeySource). + Msg("Upserting megolm inbound group session") + _, err = store.DB.Exec(ctx, ` + INSERT INTO crypto_megolm_inbound_session ( + session_id, sender_key, signing_key, room_id, session, forwarding_chains, + ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source, account_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (session_id, account_id) DO UPDATE + SET withheld_code=NULL, withheld_reason=NULL, sender_key=excluded.sender_key, signing_key=excluded.signing_key, + room_id=excluded.room_id, session=excluded.session, forwarding_chains=excluded.forwarding_chains, + ratchet_safety=excluded.ratchet_safety, received_at=excluded.received_at, + max_age=excluded.max_age, max_messages=excluded.max_messages, is_scheduled=excluded.is_scheduled, + key_backup_version=excluded.key_backup_version, key_source=excluded.key_source + `, + session.ID(), session.SenderKey, session.SigningKey, session.RoomID, sessionBytes, forwardingChains, + ratchetSafety, datePtr(session.ReceivedAt), dbutil.NumPtr(session.MaxAge), dbutil.NumPtr(session.MaxMessages), + session.IsScheduled, session.KeyBackupVersion, session.KeySource, store.AccountID, + ) + return err +} + +// GetGroupSession retrieves an inbound Megolm group session for a room, sender and session. +func (store *SQLCryptoStore) GetGroupSession(ctx context.Context, roomID id.RoomID, sessionID id.SessionID) (*InboundGroupSession, error) { + var senderKey, signingKey, forwardingChains, withheldCode, withheldReason sql.NullString + var sessionBytes, ratchetSafetyBytes []byte + var receivedAt sql.NullTime + var maxAge, maxMessages sql.NullInt64 + var isScheduled bool + var version id.KeyBackupVersion + var keySource id.KeySource + err := store.DB.QueryRow(ctx, ` + SELECT sender_key, signing_key, session, forwarding_chains, withheld_code, withheld_reason, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source + FROM crypto_megolm_inbound_session + WHERE room_id=$1 AND session_id=$2 AND account_id=$3`, + roomID, sessionID, store.AccountID, + ).Scan(&senderKey, &signingKey, &sessionBytes, &forwardingChains, &withheldCode, &withheldReason, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } else if withheldCode.Valid { + return nil, &event.RoomKeyWithheldEventContent{ + RoomID: roomID, + Algorithm: id.AlgorithmMegolmV1, + SessionID: sessionID, + SenderKey: id.Curve25519(senderKey.String), + Code: event.RoomKeyWithheldCode(withheldCode.String), + Reason: withheldReason.String, + } + } + igs, chains, rs, err := store.postScanInboundGroupSession(sessionBytes, ratchetSafetyBytes, forwardingChains.String) + if err != nil { + return nil, err + } + return &InboundGroupSession{ + Internal: igs, + SigningKey: id.Ed25519(signingKey.String), + SenderKey: id.Curve25519(senderKey.String), + RoomID: roomID, + ForwardingChains: chains, + RatchetSafety: rs, + ReceivedAt: receivedAt.Time, + MaxAge: maxAge.Int64, + MaxMessages: int(maxMessages.Int64), + IsScheduled: isScheduled, + KeyBackupVersion: version, + KeySource: keySource, + }, nil +} + +func (store *SQLCryptoStore) RedactGroupSession(ctx context.Context, _ id.RoomID, sessionID id.SessionID, reason string) error { + _, err := store.DB.Exec(ctx, ` + UPDATE crypto_megolm_inbound_session + SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL + WHERE session_id=$3 AND account_id=$4 AND session IS NOT NULL + `, event.RoomKeyWithheldBeeperRedacted, "Session redacted: "+reason, sessionID, store.AccountID) + return err +} + +func (store *SQLCryptoStore) RedactGroupSessions(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, reason string) ([]id.SessionID, error) { + if roomID == "" && senderKey == "" { + return nil, fmt.Errorf("room ID or sender key must be provided for redacting sessions") + } + res, err := store.DB.Query(ctx, ` + UPDATE crypto_megolm_inbound_session + SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL + WHERE (room_id=$3 OR $3='') AND (sender_key=$4 OR $4='') AND account_id=$5 + AND session IS NOT NULL AND is_scheduled=false AND received_at IS NOT NULL + RETURNING session_id + `, event.RoomKeyWithheldBeeperRedacted, "Session redacted: "+reason, roomID, senderKey, store.AccountID) + return dbutil.NewRowIterWithError(res, dbutil.ScanSingleColumn[id.SessionID], err).AsList() +} + +func (store *SQLCryptoStore) RedactExpiredGroupSessions(ctx context.Context) ([]id.SessionID, error) { + var query string + switch store.DB.Dialect { + case dbutil.Postgres: + query = ` + UPDATE crypto_megolm_inbound_session + SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL + WHERE account_id=$3 AND session IS NOT NULL AND is_scheduled=false + AND received_at IS NOT NULL and max_age IS NOT NULL + AND received_at + 2 * (max_age * interval '1 millisecond') < now() + RETURNING session_id + ` + case dbutil.SQLite: + query = ` + UPDATE crypto_megolm_inbound_session + SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL + WHERE account_id=$3 AND session IS NOT NULL AND is_scheduled=false + AND received_at IS NOT NULL and max_age IS NOT NULL + AND unixepoch(received_at) + (2 * max_age / 1000) < unixepoch(date('now')) + RETURNING session_id + ` + default: + return nil, fmt.Errorf("unsupported dialect") + } + res, err := store.DB.Query(ctx, query, event.RoomKeyWithheldBeeperRedacted, "Session redacted: expired", store.AccountID) + return dbutil.NewRowIterWithError(res, dbutil.ScanSingleColumn[id.SessionID], err).AsList() +} + +func (store *SQLCryptoStore) RedactOutdatedGroupSessions(ctx context.Context) ([]id.SessionID, error) { + res, err := store.DB.Query(ctx, ` + UPDATE crypto_megolm_inbound_session + SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL + WHERE account_id=$3 AND session IS NOT NULL AND received_at IS NULL + RETURNING session_id + `, event.RoomKeyWithheldBeeperRedacted, "Session redacted: outdated", store.AccountID) + return dbutil.NewRowIterWithError(res, dbutil.ScanSingleColumn[id.SessionID], err).AsList() +} + +func (store *SQLCryptoStore) PutWithheldGroupSession(ctx context.Context, content event.RoomKeyWithheldEventContent) error { + _, err := store.DB.Exec(ctx, ` + INSERT INTO crypto_megolm_inbound_session (session_id, sender_key, room_id, withheld_code, withheld_reason, received_at, account_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (session_id, account_id) DO NOTHING + `, content.SessionID, content.SenderKey, content.RoomID, content.Code, content.Reason, time.Now().UTC(), store.AccountID) + return err +} + +func (store *SQLCryptoStore) GetWithheldGroupSession(ctx context.Context, roomID id.RoomID, sessionID id.SessionID) (*event.RoomKeyWithheldEventContent, error) { + var senderKey, code, reason sql.NullString + err := store.DB.QueryRow(ctx, ` + SELECT withheld_code, withheld_reason, sender_key FROM crypto_megolm_inbound_session + WHERE room_id=$1 AND session_id=$2 AND account_id=$3`, + roomID, sessionID, store.AccountID, + ).Scan(&code, &reason, &senderKey) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil || !code.Valid { + return nil, err + } + return &event.RoomKeyWithheldEventContent{ + RoomID: roomID, + Algorithm: id.AlgorithmMegolmV1, + SessionID: sessionID, + SenderKey: id.Curve25519(senderKey.String), + Code: event.RoomKeyWithheldCode(code.String), + Reason: reason.String, + }, nil +} + +func (store *SQLCryptoStore) postScanInboundGroupSession(sessionBytes, ratchetSafetyBytes []byte, forwardingChains string) (igs olm.InboundGroupSession, chains []string, safety RatchetSafety, err error) { + igs = olm.NewBlankInboundGroupSession() + err = igs.Unpickle(sessionBytes, store.PickleKey) + if err != nil { + return + } + if forwardingChains != "" { + chains = strings.Split(forwardingChains, ",") + } else { + chains = []string{} + } + var rs RatchetSafety + if len(ratchetSafetyBytes) > 0 { + err = json.Unmarshal(ratchetSafetyBytes, &rs) + if err != nil { + err = fmt.Errorf("failed to unmarshal ratchet safety info: %w", err) + } + } + return +} + +func (store *SQLCryptoStore) scanInboundGroupSession(rows dbutil.Scannable) (*InboundGroupSession, error) { + var roomID id.RoomID + var signingKey, senderKey, forwardingChains sql.NullString + var sessionBytes, ratchetSafetyBytes []byte + var receivedAt sql.NullTime + var maxAge, maxMessages sql.NullInt64 + var isScheduled bool + var version id.KeyBackupVersion + var keySource id.KeySource + err := rows.Scan(&roomID, &senderKey, &signingKey, &sessionBytes, &forwardingChains, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource) + if err != nil { + return nil, err + } + igs, chains, rs, err := store.postScanInboundGroupSession(sessionBytes, ratchetSafetyBytes, forwardingChains.String) + if err != nil { + return nil, err + } + return &InboundGroupSession{ + Internal: igs, + SigningKey: id.Ed25519(signingKey.String), + SenderKey: id.Curve25519(senderKey.String), + RoomID: roomID, + ForwardingChains: chains, + RatchetSafety: rs, + ReceivedAt: receivedAt.Time, + MaxAge: maxAge.Int64, + MaxMessages: int(maxMessages.Int64), + IsScheduled: isScheduled, + KeyBackupVersion: version, + KeySource: keySource, + }, nil +} + +func (store *SQLCryptoStore) GetGroupSessionsForRoom(ctx context.Context, roomID id.RoomID) dbutil.RowIter[*InboundGroupSession] { + rows, err := store.DB.Query(ctx, ` + SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source + FROM crypto_megolm_inbound_session WHERE room_id=$1 AND account_id=$2 AND session IS NOT NULL`, + roomID, store.AccountID, + ) + return dbutil.NewRowIterWithError(rows, store.scanInboundGroupSession, err) +} + +func (store *SQLCryptoStore) GetAllGroupSessions(ctx context.Context) dbutil.RowIter[*InboundGroupSession] { + rows, err := store.DB.Query(ctx, ` + SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source + FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL`, + store.AccountID, + ) + return dbutil.NewRowIterWithError(rows, store.scanInboundGroupSession, err) +} + +func (store *SQLCryptoStore) GetGroupSessionsWithoutKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] { + rows, err := store.DB.Query(ctx, ` + SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source + FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL AND key_backup_version != $2`, + store.AccountID, version, + ) + return dbutil.NewRowIterWithError(rows, store.scanInboundGroupSession, err) +} + +// AddOutboundGroupSession stores an outbound Megolm session, along with the information about the room and involved devices. +func (store *SQLCryptoStore) AddOutboundGroupSession(ctx context.Context, session *OutboundGroupSession) error { + sessionBytes, err := session.Internal.Pickle(store.PickleKey) + if err != nil { + return err + } + _, err = store.DB.Exec(ctx, ` + INSERT INTO crypto_megolm_outbound_session + (room_id, session_id, session, shared, max_messages, message_count, max_age, created_at, last_used, account_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (account_id, room_id) DO UPDATE + SET session_id=excluded.session_id, session=excluded.session, shared=excluded.shared, + max_messages=excluded.max_messages, message_count=excluded.message_count, max_age=excluded.max_age, + created_at=excluded.created_at, last_used=excluded.last_used, account_id=excluded.account_id + `, session.RoomID, session.ID(), sessionBytes, session.Shared, session.MaxMessages, session.MessageCount, + session.MaxAge.Milliseconds(), session.CreationTime, session.LastEncryptedTime, store.AccountID) + return err +} + +// UpdateOutboundGroupSession replaces an outbound Megolm session with for same room and session ID. +func (store *SQLCryptoStore) UpdateOutboundGroupSession(ctx context.Context, session *OutboundGroupSession) error { + sessionBytes, err := session.Internal.Pickle(store.PickleKey) + if err != nil { + return err + } + _, err = store.DB.Exec(ctx, "UPDATE crypto_megolm_outbound_session SET session=$1, message_count=$2, last_used=$3 WHERE room_id=$4 AND session_id=$5 AND account_id=$6", + sessionBytes, session.MessageCount, session.LastEncryptedTime, session.RoomID, session.ID(), store.AccountID) + return err +} + +// GetOutboundGroupSession retrieves the outbound Megolm session for the given room ID. +func (store *SQLCryptoStore) GetOutboundGroupSession(ctx context.Context, roomID id.RoomID) (*OutboundGroupSession, error) { + var ogs OutboundGroupSession + var sessionBytes []byte + var maxAgeMS int64 + err := store.DB.QueryRow(ctx, ` + SELECT session, shared, max_messages, message_count, max_age, created_at, last_used + FROM crypto_megolm_outbound_session WHERE room_id=$1 AND account_id=$2`, + roomID, store.AccountID, + ).Scan(&sessionBytes, &ogs.Shared, &ogs.MaxMessages, &ogs.MessageCount, &maxAgeMS, &ogs.CreationTime, &ogs.LastEncryptedTime) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + intOGS := olm.NewBlankOutboundGroupSession() + err = intOGS.Unpickle(sessionBytes, store.PickleKey) + if err != nil { + return nil, err + } + ogs.Internal = intOGS + ogs.RoomID = roomID + ogs.MaxAge = time.Duration(maxAgeMS) * time.Millisecond + return &ogs, nil +} + +// RemoveOutboundGroupSession removes the outbound Megolm session for the given room ID. +func (store *SQLCryptoStore) RemoveOutboundGroupSession(ctx context.Context, roomID id.RoomID) error { + _, err := store.DB.Exec(ctx, "DELETE FROM crypto_megolm_outbound_session WHERE room_id=$1 AND account_id=$2", + roomID, store.AccountID) + return err +} + +func (store *SQLCryptoStore) MarkOutboundGroupSessionShared(ctx context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) error { + _, err := store.DB.Exec(ctx, "INSERT INTO crypto_megolm_outbound_session_shared (user_id, identity_key, session_id) VALUES ($1, $2, $3)", userID, identityKey, sessionID) + return err +} + +func (store *SQLCryptoStore) IsOutboundGroupSessionShared(ctx context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) (shared bool, err error) { + err = store.DB.QueryRow(ctx, `SELECT TRUE FROM crypto_megolm_outbound_session_shared WHERE user_id=$1 AND identity_key=$2 AND session_id=$3`, + userID, identityKey, sessionID).Scan(&shared) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return +} + +// ValidateMessageIndex returns whether the given event information match the ones stored in the database +// for the given sender key, session ID and index. If the index hasn't been stored, this will store it. +func (store *SQLCryptoStore) ValidateMessageIndex(ctx context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { + if eventID == "" && timestamp == 0 { + var notOK bool + const validateEmptyQuery = ` + SELECT EXISTS(SELECT 1 FROM crypto_message_index WHERE session_id=$1 AND "index"=$2) + ` + err := store.DB.QueryRow(ctx, validateEmptyQuery, sessionID, index).Scan(¬OK) + if notOK { + zerolog.Ctx(ctx).Debug(). + Uint("message_index", index). + Msg("Rejecting event without event ID and timestamp due to already knowing them") + } + return !notOK, err + } + + const validateQuery = ` + INSERT INTO crypto_message_index (session_id, "index", event_id, timestamp) + VALUES ($1, $2, $3, $4) + -- have to update something so that RETURNING * always returns the row + ON CONFLICT (session_id, "index") DO UPDATE SET timestamp=crypto_message_index.timestamp + RETURNING event_id, timestamp + ` + var expectedEventID id.EventID + var expectedTimestamp int64 + err := store.DB.QueryRow(ctx, validateQuery, sessionID, index, eventID, timestamp).Scan(&expectedEventID, &expectedTimestamp) + if err != nil { + return false, err + } else if expectedEventID != eventID || expectedTimestamp != timestamp { + zerolog.Ctx(ctx).Debug(). + Uint("message_index", index). + Str("expected_event_id", expectedEventID.String()). + Int64("expected_timestamp", expectedTimestamp). + Int64("actual_timestamp", timestamp). + Msg("Rejecting different event with duplicate message index") + return false, nil + } + return true, nil +} + +func scanDevice(rows dbutil.Scannable) (*id.Device, error) { + var device id.Device + err := rows.Scan(&device.UserID, &device.DeviceID, &device.IdentityKey, &device.SigningKey, &device.Trust, &device.Deleted, &device.Name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + return &device, nil +} + +// GetDevices returns a map of device IDs to device identities, including the identity and signing keys, for a given user ID. +func (store *SQLCryptoStore) GetDevices(ctx context.Context, userID id.UserID) (map[id.DeviceID]*id.Device, error) { + var ignore id.UserID + err := store.DB.QueryRow(ctx, "SELECT user_id FROM crypto_tracked_user WHERE user_id=$1", userID).Scan(&ignore) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + + rows, err := store.DB.Query(ctx, "SELECT user_id, device_id, identity_key, signing_key, trust, deleted, name FROM crypto_device WHERE user_id=$1 AND deleted=false", userID) + data := make(map[id.DeviceID]*id.Device) + err = dbutil.NewRowIterWithError(rows, scanDevice, err).Iter(func(device *id.Device) (bool, error) { + data[device.DeviceID] = device + return true, nil + }) + if err != nil { + return nil, err + } + return data, nil +} + +// GetDevice returns the device dentity for a given user and device ID. +func (store *SQLCryptoStore) GetDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*id.Device, error) { + return scanDevice(store.DB.QueryRow(ctx, ` + SELECT user_id, device_id, identity_key, signing_key, trust, deleted, name + FROM crypto_device WHERE user_id=$1 AND device_id=$2`, + userID, deviceID, + )) +} + +// FindDeviceByKey finds a specific device by its sender key. +func (store *SQLCryptoStore) FindDeviceByKey(ctx context.Context, userID id.UserID, identityKey id.IdentityKey) (*id.Device, error) { + return scanDevice(store.DB.QueryRow(ctx, ` + SELECT user_id, device_id, identity_key, signing_key, trust, deleted, name + FROM crypto_device WHERE user_id=$1 AND identity_key=$2`, + userID, identityKey, + )) +} + +const deviceInsertQuery = ` +INSERT INTO crypto_device (user_id, device_id, identity_key, signing_key, trust, deleted, name) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (user_id, device_id) DO UPDATE + SET identity_key=excluded.identity_key, deleted=excluded.deleted, trust=excluded.trust, name=excluded.name +` + +var deviceMassInsertTemplate = strings.ReplaceAll(deviceInsertQuery, "($1, $2, $3, $4, $5, $6, $7)", "%s") + +// PutDevice stores a single device for a user, replacing it if it exists already. +func (store *SQLCryptoStore) PutDevice(ctx context.Context, userID id.UserID, device *id.Device) error { + _, err := store.DB.Exec(ctx, deviceInsertQuery, + userID, device.DeviceID, device.IdentityKey, device.SigningKey, device.Trust, device.Deleted, device.Name) + return err +} + +const trackedUserUpsertQuery = ` +INSERT INTO crypto_tracked_user (user_id, devices_outdated) +VALUES ($1, false) +ON CONFLICT (user_id) DO UPDATE + SET devices_outdated = EXCLUDED.devices_outdated +` + +// PutDevices stores the device identity information for the given user ID. +func (store *SQLCryptoStore) PutDevices(ctx context.Context, userID id.UserID, devices map[id.DeviceID]*id.Device) error { + return store.DB.DoTxn(ctx, nil, func(ctx context.Context) error { + _, err := store.DB.Exec(ctx, trackedUserUpsertQuery, userID) + if err != nil { + return fmt.Errorf("failed to upsert user to tracked users list: %w", err) + } + + _, err = store.DB.Exec(ctx, "UPDATE crypto_device SET deleted=true WHERE user_id=$1", userID) + if err != nil { + return fmt.Errorf("failed to delete old devices: %w", err) + } + if len(devices) == 0 { + return nil + } + deviceBatchLen := 5 // how many devices will be inserted per query + deviceIDs := make([]id.DeviceID, 0, len(devices)) + for deviceID := range devices { + deviceIDs = append(deviceIDs, deviceID) + } + const valueStringFormat = "($1, $%d, $%d, $%d, $%d, $%d, $%d)" + for batchDeviceIdx := 0; batchDeviceIdx < len(deviceIDs); batchDeviceIdx += deviceBatchLen { + var batchDevices []id.DeviceID + if batchDeviceIdx+deviceBatchLen < len(deviceIDs) { + batchDevices = deviceIDs[batchDeviceIdx : batchDeviceIdx+deviceBatchLen] + } else { + batchDevices = deviceIDs[batchDeviceIdx:] + } + values := make([]interface{}, 1, len(devices)*6+1) + values[0] = userID + valueStrings := make([]string, 0, len(devices)) + i := 2 + for _, deviceID := range batchDevices { + identity := devices[deviceID] + values = append(values, deviceID, identity.IdentityKey, identity.SigningKey, identity.Trust, identity.Deleted, identity.Name) + valueStrings = append(valueStrings, fmt.Sprintf(valueStringFormat, i, i+1, i+2, i+3, i+4, i+5)) + i += 6 + } + valueString := strings.Join(valueStrings, ",") + _, err = store.DB.Exec(ctx, fmt.Sprintf(deviceMassInsertTemplate, valueString), values...) + if err != nil { + return fmt.Errorf("failed to insert new devices: %w", err) + } + } + return nil + }) +} + +func userIDsToParams(users []id.UserID) (placeholders string, params []any) { + queryString := make([]string, len(users)) + params = make([]any, len(users)) + for i, user := range users { + queryString[i] = fmt.Sprintf("$%d", i+1) + params[i] = user + } + placeholders = strings.Join(queryString, ",") + return +} + +// FilterTrackedUsers finds all the user IDs out of the given ones for which the database contains identity information. +func (store *SQLCryptoStore) FilterTrackedUsers(ctx context.Context, users []id.UserID) ([]id.UserID, error) { + var rows dbutil.Rows + var err error + if store.DB.Dialect == dbutil.Postgres && PostgresArrayWrapper != nil { + rows, err = store.DB.Query(ctx, "SELECT user_id FROM crypto_tracked_user WHERE user_id = ANY($1)", PostgresArrayWrapper(users)) + } else { + placeholders, params := userIDsToParams(users) + rows, err = store.DB.Query(ctx, "SELECT user_id FROM crypto_tracked_user WHERE user_id IN ("+placeholders+")", params...) + } + return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() +} + +// MarkTrackedUsersOutdated flags that the device list for given users are outdated. +func (store *SQLCryptoStore) MarkTrackedUsersOutdated(ctx context.Context, users []id.UserID) (err error) { + for chunk := range slices.Chunk(users, 1000) { + if store.DB.Dialect == dbutil.Postgres && PostgresArrayWrapper != nil { + _, err = store.DB.Exec(ctx, "UPDATE crypto_tracked_user SET devices_outdated = true WHERE user_id = ANY($1)", PostgresArrayWrapper(chunk)) + } else { + placeholders, params := userIDsToParams(chunk) + _, err = store.DB.Exec(ctx, "UPDATE crypto_tracked_user SET devices_outdated = true WHERE user_id IN ("+placeholders+")", params...) + } + } + return +} + +// GetOutdatedTrackerUsers gets all tracked users whose devices need to be updated. +func (store *SQLCryptoStore) GetOutdatedTrackedUsers(ctx context.Context) ([]id.UserID, error) { + rows, err := store.DB.Query(ctx, "SELECT user_id FROM crypto_tracked_user WHERE devices_outdated = TRUE") + return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() +} + +// PutCrossSigningKey stores a cross-signing key of some user along with its usage. +func (store *SQLCryptoStore) PutCrossSigningKey(ctx context.Context, userID id.UserID, usage id.CrossSigningUsage, key id.Ed25519) error { + _, err := store.DB.Exec(ctx, ` + INSERT INTO crypto_cross_signing_keys (user_id, usage, key, first_seen_key) VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, usage) DO UPDATE SET key=excluded.key + `, userID, usage, key, key) + return err +} + +// GetCrossSigningKeys retrieves a user's stored cross-signing keys. +func (store *SQLCryptoStore) GetCrossSigningKeys(ctx context.Context, userID id.UserID) (map[id.CrossSigningUsage]id.CrossSigningKey, error) { + rows, err := store.DB.Query(ctx, "SELECT usage, key, first_seen_key FROM crypto_cross_signing_keys WHERE user_id=$1", userID) + if err != nil { + return nil, err + } + data := make(map[id.CrossSigningUsage]id.CrossSigningKey) + for rows.Next() { + var usage id.CrossSigningUsage + var key, first id.Ed25519 + err = rows.Scan(&usage, &key, &first) + if err != nil { + return nil, err + } + data[usage] = id.CrossSigningKey{Key: key, First: first} + } + + return data, nil +} + +// PutSignature stores a signature of a cross-signing or device key along with the signer's user ID and key. +func (store *SQLCryptoStore) PutSignature(ctx context.Context, signedUserID id.UserID, signedKey id.Ed25519, signerUserID id.UserID, signerKey id.Ed25519, signature string) error { + _, err := store.DB.Exec(ctx, ` + INSERT INTO crypto_cross_signing_signatures (signed_user_id, signed_key, signer_user_id, signer_key, signature) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (signed_user_id, signed_key, signer_user_id, signer_key) DO UPDATE SET signature=excluded.signature + `, signedUserID, signedKey, signerUserID, signerKey, signature) + return err +} + +// GetSignaturesForKeyBy retrieves the stored signatures for a given cross-signing or device key, by the given signer. +func (store *SQLCryptoStore) GetSignaturesForKeyBy(ctx context.Context, userID id.UserID, key id.Ed25519, signerID id.UserID) (map[id.Ed25519]string, error) { + rows, err := store.DB.Query(ctx, "SELECT signer_key, signature FROM crypto_cross_signing_signatures WHERE signed_user_id=$1 AND signed_key=$2 AND signer_user_id=$3", userID, key, signerID) + if err != nil { + return nil, err + } + data := make(map[id.Ed25519]string) + for rows.Next() { + var signerKey id.Ed25519 + var signature string + err = rows.Scan(&signerKey, &signature) + if err != nil { + return nil, err + } + data[signerKey] = signature + } + + return data, nil +} + +// IsKeySignedBy returns whether a cross-signing or device key is signed by the given signer. +func (store *SQLCryptoStore) IsKeySignedBy(ctx context.Context, signedUserID id.UserID, signedKey id.Ed25519, signerUserID id.UserID, signerKey id.Ed25519) (isSigned bool, err error) { + q := `SELECT EXISTS( + SELECT 1 FROM crypto_cross_signing_signatures + WHERE signed_user_id=$1 AND signed_key=$2 AND signer_user_id=$3 AND signer_key=$4 + )` + err = store.DB.QueryRow(ctx, q, signedUserID, signedKey, signerUserID, signerKey).Scan(&isSigned) + return +} + +// DropSignaturesByKey deletes the signatures made by the given user and key from the store. It returns the number of signatures deleted. +func (store *SQLCryptoStore) DropSignaturesByKey(ctx context.Context, userID id.UserID, key id.Ed25519) (int64, error) { + res, err := store.DB.Exec(ctx, "DELETE FROM crypto_cross_signing_signatures WHERE signer_user_id=$1 AND signer_key=$2", userID, key) + if err != nil { + return 0, err + } + count, err := res.RowsAffected() + if err != nil { + return 0, err + } + return count, nil +} + +func (store *SQLCryptoStore) PutSecret(ctx context.Context, name id.Secret, value string) error { + bytes, err := libolmpickle.Pickle(store.PickleKey, []byte(value)) + if err != nil { + return err + } + _, err = store.DB.Exec(ctx, ` + INSERT INTO crypto_secrets (account_id, name, secret) VALUES ($1, $2, $3) + ON CONFLICT (account_id, name) DO UPDATE SET secret=excluded.secret + `, store.AccountID, name, bytes) + return err +} + +func (store *SQLCryptoStore) GetSecret(ctx context.Context, name id.Secret) (value string, err error) { + var bytes []byte + err = store.DB.QueryRow(ctx, `SELECT secret FROM crypto_secrets WHERE account_id=$1 AND name=$2`, store.AccountID, name).Scan(&bytes) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } else if err != nil { + return "", err + } + bytes, err = libolmpickle.Unpickle(store.PickleKey, bytes) + return string(bytes), err +} + +func (store *SQLCryptoStore) DeleteSecret(ctx context.Context, name id.Secret) (err error) { + _, err = store.DB.Exec(ctx, "DELETE FROM crypto_secrets WHERE account_id=$1 AND name=$2", store.AccountID, name) + return +} diff --git a/mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql b/mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql new file mode 100644 index 00000000..a5f1d04c --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql @@ -0,0 +1,126 @@ +-- v0 -> v20 (compatible with v20+): Latest revision +CREATE TABLE crypto_account ( + account_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + shared BOOLEAN NOT NULL, + sync_token TEXT NOT NULL, + account bytea NOT NULL, + key_backup_version TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE crypto_message_index ( + session_id CHAR(43), + "index" INTEGER, + event_id TEXT NOT NULL, + timestamp BIGINT NOT NULL, + PRIMARY KEY (session_id, "index") +); + +CREATE TABLE crypto_tracked_user ( + user_id TEXT PRIMARY KEY, + devices_outdated BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE crypto_device ( + user_id TEXT, + device_id TEXT, + identity_key CHAR(43) NOT NULL, + signing_key CHAR(43) NOT NULL, + trust SMALLINT NOT NULL, + deleted BOOLEAN NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (user_id, device_id) +); + +CREATE TABLE crypto_olm_session ( + account_id TEXT, + session_id CHAR(43), + sender_key CHAR(43) NOT NULL, + session bytea NOT NULL, + created_at timestamp NOT NULL, + last_decrypted timestamp NOT NULL, + last_encrypted timestamp NOT NULL, + PRIMARY KEY (account_id, session_id) +); +CREATE INDEX crypto_olm_session_sender_key_idx ON crypto_olm_session (account_id, sender_key); + +CREATE TABLE crypto_olm_message_hash ( + account_id TEXT NOT NULL, + received_at BIGINT NOT NULL, + message_hash bytea NOT NULL PRIMARY KEY, + + CONSTRAINT crypto_olm_message_hash_account_fkey FOREIGN KEY (account_id) + REFERENCES crypto_account (account_id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX crypto_olm_message_hash_account_idx ON crypto_olm_message_hash (account_id); + +CREATE TABLE crypto_megolm_inbound_session ( + account_id TEXT, + session_id CHAR(43), + sender_key CHAR(43) NOT NULL, + signing_key CHAR(43), + room_id TEXT NOT NULL, + session bytea, + forwarding_chains bytea, + withheld_code TEXT, + withheld_reason TEXT, + ratchet_safety jsonb, + received_at timestamp, + max_age BIGINT, + max_messages INTEGER, + is_scheduled BOOLEAN NOT NULL DEFAULT false, + key_backup_version TEXT NOT NULL DEFAULT '', + key_source TEXT NOT NULL DEFAULT '', + PRIMARY KEY (account_id, session_id) +); +-- Useful index to find keys that need backing up +CREATE INDEX crypto_megolm_inbound_session_backup_idx ON crypto_megolm_inbound_session(account_id, key_backup_version) WHERE session IS NOT NULL; + +CREATE TABLE crypto_megolm_outbound_session ( + account_id TEXT, + room_id TEXT, + session_id CHAR(43) NOT NULL UNIQUE, + session bytea NOT NULL, + shared BOOLEAN NOT NULL, + max_messages INTEGER NOT NULL, + message_count INTEGER NOT NULL, + max_age BIGINT NOT NULL, + created_at timestamp NOT NULL, + last_used timestamp NOT NULL, + PRIMARY KEY (account_id, room_id) +); + +CREATE TABLE crypto_megolm_outbound_session_shared ( + user_id TEXT NOT NULL, + identity_key CHAR(43) NOT NULL, + session_id CHAR(43) NOT NULL, + + PRIMARY KEY (user_id, identity_key, session_id) +); + +CREATE TABLE crypto_cross_signing_keys ( + user_id TEXT, + usage TEXT, + key CHAR(43) NOT NULL, + + first_seen_key CHAR(43) NOT NULL, + + PRIMARY KEY (user_id, usage) +); + +CREATE TABLE crypto_cross_signing_signatures ( + signed_user_id TEXT, + signed_key TEXT, + signer_user_id TEXT, + signer_key TEXT, + signature CHAR(88) NOT NULL, + PRIMARY KEY (signed_user_id, signed_key, signer_user_id, signer_key) +); + +CREATE TABLE crypto_secrets ( + account_id TEXT NOT NULL, + name TEXT NOT NULL, + secret bytea NOT NULL, + + PRIMARY KEY (account_id, name) +); diff --git a/mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql b/mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql new file mode 100644 index 00000000..dc25b155 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql @@ -0,0 +1,16 @@ +-- v4: Add tables for cross-signing keys +CREATE TABLE IF NOT EXISTS crypto_cross_signing_keys ( + user_id VARCHAR(255) NOT NULL, + usage VARCHAR(20) NOT NULL, + key CHAR(43) NOT NULL, + PRIMARY KEY (user_id, usage) +); + +CREATE TABLE IF NOT EXISTS crypto_cross_signing_signatures ( + signed_user_id VARCHAR(255) NOT NULL, + signed_key VARCHAR(255) NOT NULL, + signer_user_id VARCHAR(255) NOT NULL, + signer_key VARCHAR(255) NOT NULL, + signature CHAR(88) NOT NULL, + PRIMARY KEY (signed_user_id, signed_key, signer_user_id, signer_key) +) diff --git a/mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql b/mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql new file mode 100644 index 00000000..868f87e8 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql @@ -0,0 +1,31 @@ +-- v5: Switch from VARCHAR(255) to TEXT +-- only: postgres + +ALTER TABLE crypto_account ALTER COLUMN device_id TYPE TEXT; +ALTER TABLE crypto_account ALTER COLUMN account_id TYPE TEXT; + +ALTER TABLE crypto_device ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE crypto_device ALTER COLUMN device_id TYPE TEXT; +ALTER TABLE crypto_device ALTER COLUMN name TYPE TEXT; + +ALTER TABLE crypto_megolm_inbound_session ALTER COLUMN room_id TYPE TEXT; +ALTER TABLE crypto_megolm_inbound_session ALTER COLUMN account_id TYPE TEXT; +ALTER TABLE crypto_megolm_inbound_session ALTER COLUMN withheld_code TYPE TEXT; + +ALTER TABLE crypto_megolm_outbound_session ALTER COLUMN room_id TYPE TEXT; +ALTER TABLE crypto_megolm_outbound_session ALTER COLUMN account_id TYPE TEXT; + +ALTER TABLE crypto_message_index ALTER COLUMN event_id TYPE TEXT; + +ALTER TABLE crypto_olm_session ALTER COLUMN account_id TYPE TEXT; + +ALTER TABLE crypto_tracked_user ALTER COLUMN user_id TYPE TEXT; + +ALTER TABLE crypto_cross_signing_keys ALTER COLUMN user_id TYPE TEXT; +ALTER TABLE crypto_cross_signing_keys ALTER COLUMN usage TYPE TEXT; + +ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signed_user_id TYPE TEXT; +ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signed_key TYPE TEXT; +ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signer_user_id TYPE TEXT; +ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signer_key TYPE TEXT; +ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signature TYPE TEXT; diff --git a/mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql b/mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql new file mode 100644 index 00000000..af5168f1 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql @@ -0,0 +1,6 @@ +-- v6: Split last_used into last_encrypted and last_decrypted for Olm sessions +ALTER TABLE crypto_olm_session RENAME COLUMN last_used TO last_decrypted; +ALTER TABLE crypto_olm_session ADD COLUMN last_encrypted timestamp; +UPDATE crypto_olm_session SET last_encrypted=last_decrypted; +-- only: postgres (too complicated on SQLite) +ALTER TABLE crypto_olm_session ALTER COLUMN last_encrypted SET NOT NULL; diff --git a/mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql b/mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql new file mode 100644 index 00000000..fd71f44b --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql @@ -0,0 +1,4 @@ +-- v7: Update trust state values +UPDATE crypto_device SET trust=300 WHERE trust=1; -- verified +UPDATE crypto_device SET trust=-100 WHERE trust=2; -- blacklisted +UPDATE crypto_device SET trust=0 WHERE trust=3; -- ignored -> unset diff --git a/mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql b/mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql new file mode 100644 index 00000000..80c22f19 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql @@ -0,0 +1,5 @@ +-- v8: Add expired field to cross signing keys +ALTER TABLE crypto_cross_signing_keys ADD COLUMN first_seen_key CHAR(43); +UPDATE crypto_cross_signing_keys SET first_seen_key=key; +-- only: postgres +ALTER TABLE crypto_cross_signing_keys ALTER COLUMN first_seen_key SET NOT NULL; diff --git a/mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql b/mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql new file mode 100644 index 00000000..144f8a3c --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql @@ -0,0 +1,2 @@ +-- v9: Change outbound megolm session max_age column to milliseconds +UPDATE crypto_megolm_outbound_session SET max_age=max_age/1000000; diff --git a/mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql b/mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql new file mode 100644 index 00000000..6dabc2df --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql @@ -0,0 +1,6 @@ +-- v10: Add metadata for detecting when megolm sessions are safe to delete +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN ratchet_safety jsonb; +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN received_at timestamp; +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN max_age BIGINT; +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN max_messages INTEGER; +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN is_scheduled BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql b/mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql new file mode 100644 index 00000000..f0f0ba5b --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql @@ -0,0 +1,2 @@ +-- v11: Add devices_outdated field to crypto_tracked_user +ALTER TABLE crypto_tracked_user ADD COLUMN devices_outdated BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql b/mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql new file mode 100644 index 00000000..d9f30ee7 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql @@ -0,0 +1,5 @@ +-- v12 (compatible with v9+): Add crypto_secrets table +CREATE TABLE IF NOT EXISTS crypto_secrets ( + name TEXT PRIMARY KEY NOT NULL, + secret bytea NOT NULL +); diff --git a/mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql b/mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql new file mode 100644 index 00000000..ea69f3cf --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql @@ -0,0 +1,9 @@ +-- v13 (compatible with v9+): Add crypto_megolm_outbound_session_shared table + +CREATE TABLE IF NOT EXISTS crypto_megolm_outbound_session_shared ( + user_id TEXT NOT NULL, + identity_key CHAR(43) NOT NULL, + session_id CHAR(43) NOT NULL, + + PRIMARY KEY (user_id, identity_key, session_id) +); diff --git a/mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql b/mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql new file mode 100644 index 00000000..e5236b62 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql @@ -0,0 +1,4 @@ +-- v14 (compatible with v9+): Add key_backup_version column to account and igs + +ALTER TABLE crypto_account ADD COLUMN key_backup_version TEXT NOT NULL DEFAULT ''; +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN key_backup_version TEXT NOT NULL DEFAULT ''; diff --git a/mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql b/mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql new file mode 100644 index 00000000..d49cffae --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql @@ -0,0 +1,21 @@ +-- v15: Fix crypto_secrets table +CREATE TABLE crypto_secrets_new ( + account_id TEXT NOT NULL, + name TEXT NOT NULL, + secret bytea NOT NULL, + + PRIMARY KEY (account_id, name) +); + +INSERT INTO crypto_secrets_new (account_id, name, secret) +SELECT '', name, secret +FROM crypto_secrets; + +DROP TABLE crypto_secrets; + +ALTER TABLE crypto_secrets_new RENAME TO crypto_secrets; + +-- only: sqlite +UPDATE crypto_secrets SET account_id=(SELECT account_id FROM crypto_account ORDER BY rowid DESC LIMIT 1); +-- only: postgres +UPDATE crypto_secrets SET account_id=(SELECT account_id FROM crypto_account LIMIT 1); diff --git a/mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql b/mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql new file mode 100644 index 00000000..f0c3a0c5 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql @@ -0,0 +1,2 @@ +-- v16 (compatible with v15+): Add index to crypto_olm_sessions to speedup lookups by sender_key +CREATE INDEX crypto_olm_session_sender_key_idx ON crypto_olm_session (account_id, sender_key); diff --git a/mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql b/mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql new file mode 100644 index 00000000..525bbb52 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql @@ -0,0 +1,11 @@ +-- v17 (compatible with v15+): Add table for decrypted Olm message hashes +CREATE TABLE crypto_olm_message_hash ( + account_id TEXT NOT NULL, + received_at BIGINT NOT NULL, + message_hash bytea NOT NULL PRIMARY KEY, + + CONSTRAINT crypto_olm_message_hash_account_fkey FOREIGN KEY (account_id) + REFERENCES crypto_account (account_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX crypto_olm_message_hash_account_idx ON crypto_olm_message_hash (account_id); diff --git a/mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql b/mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql new file mode 100644 index 00000000..da26da0f --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql @@ -0,0 +1,2 @@ +-- v18 (compatible with v15+): Add an index to the megolm_inbound_session table to make finding sessions to backup faster +CREATE INDEX crypto_megolm_inbound_session_backup_idx ON crypto_megolm_inbound_session(account_id, key_backup_version) WHERE session IS NOT NULL; diff --git a/mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql b/mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql new file mode 100644 index 00000000..f624222f --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql @@ -0,0 +1,2 @@ +-- v19 (compatible with v15+): Store megolm session source +ALTER TABLE crypto_megolm_inbound_session ADD COLUMN key_source TEXT NOT NULL DEFAULT ''; diff --git a/mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go b/mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go new file mode 100644 index 00000000..f4db082a --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package sql_store_upgrade + +import ( + "context" + + "go.mau.fi/util/dbutil" +) + +var DropCryptoMessageIndexForRewrite = false + +func init() { + const rewritePostgres = ` + ALTER TABLE crypto_message_index DROP CONSTRAINT crypto_message_index_pkey; + ALTER TABLE crypto_message_index DROP COLUMN sender_key; + ALTER TABLE crypto_message_index ADD PRIMARY KEY (session_id, "index"); + ` + const createNewSQLite = ` + CREATE TABLE new_crypto_message_index ( + session_id CHAR(43), + "index" INTEGER, + event_id TEXT NOT NULL, + timestamp BIGINT NOT NULL, + PRIMARY KEY (session_id, "index") + ); + ` + const migrateSQLite = ` + INSERT INTO new_crypto_message_index (session_id, "index", event_id, timestamp) + SELECT session_id, "index", event_id, timestamp FROM crypto_message_index; + ` + const dropSQLite = ` + DROP TABLE crypto_message_index; + ALTER TABLE new_crypto_message_index RENAME TO crypto_message_index; + ` + Table.Register(-1, 20, 20, "Remove sender_key from crypto_message_index", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) (err error) { + switch db.Dialect { + case dbutil.Postgres: + _, err = db.Exec(ctx, rewritePostgres) + case dbutil.SQLite: + if DropCryptoMessageIndexForRewrite { + _, err = db.Exec(ctx, createNewSQLite+dropSQLite) + } else { + _, err = db.Exec(ctx, createNewSQLite+migrateSQLite+dropSQLite) + } + default: + err = dbutil.ErrUnsupportedDialect + } + return + }) +} diff --git a/mautrix-patched/crypto/sql_store_upgrade/upgrade.go b/mautrix-patched/crypto/sql_store_upgrade/upgrade.go new file mode 100644 index 00000000..10c0c0c0 --- /dev/null +++ b/mautrix-patched/crypto/sql_store_upgrade/upgrade.go @@ -0,0 +1,29 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package sql_store_upgrade + +import ( + "context" + "embed" + "fmt" + + "go.mau.fi/util/dbutil" +) + +var Table dbutil.UpgradeTable + +const VersionTableName = "crypto_version" + +//go:embed *.sql +var fs embed.FS + +func init() { + Table.Register(-1, 3, 0, "Unsupported version", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error { + return fmt.Errorf("upgrading from versions 1 and 2 of the crypto store is no longer supported in mautrix-go v0.12+") + }) + Table.RegisterFS(fs) +} diff --git a/mautrix-patched/crypto/ssss/client.go b/mautrix-patched/crypto/ssss/client.go new file mode 100644 index 00000000..865be4a0 --- /dev/null +++ b/mautrix-patched/crypto/ssss/client.go @@ -0,0 +1,125 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package ssss + +import ( + "context" + "errors" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// Machine contains utility methods for interacting with SSSS data on the server. +type Machine struct { + Client *mautrix.Client +} + +func NewSSSSMachine(client *mautrix.Client) *Machine { + return &Machine{ + Client: client, + } +} + +type DefaultSecretStorageKeyContent struct { + KeyID string `json:"key"` +} + +// GetDefaultKeyID retrieves the default key ID for this account from SSSS. +func (mach *Machine) GetDefaultKeyID(ctx context.Context) (string, error) { + var data DefaultSecretStorageKeyContent + err := mach.Client.GetAccountData(ctx, event.AccountDataSecretStorageDefaultKey.Type, &data) + if errors.Is(err, mautrix.MNotFound) { + return "", ErrNoDefaultKeyAccountDataEvent + } else if err != nil { + return "", fmt.Errorf("failed to get default key account data from server: %w", err) + } + if len(data.KeyID) == 0 { + return "", ErrNoKeyFieldInAccountDataEvent + } + return data.KeyID, nil +} + +// SetDefaultKeyID sets the default key ID for this account on the server. +func (mach *Machine) SetDefaultKeyID(ctx context.Context, keyID string) error { + return mach.Client.SetAccountData(ctx, event.AccountDataSecretStorageDefaultKey.Type, &DefaultSecretStorageKeyContent{keyID}) +} + +// GetKeyData gets the details about the given key ID. +func (mach *Machine) GetKeyData(ctx context.Context, keyID string) (keyData *KeyMetadata, err error) { + keyData = &KeyMetadata{} + err = mach.Client.GetAccountData(ctx, fmt.Sprintf("%s.%s", event.AccountDataSecretStorageKey.Type, keyID), keyData) + return +} + +// SetKeyData stores SSSS key metadata on the server. +func (mach *Machine) SetKeyData(ctx context.Context, keyID string, keyData *KeyMetadata) error { + return mach.Client.SetAccountData(ctx, fmt.Sprintf("%s.%s", event.AccountDataSecretStorageKey.Type, keyID), keyData) +} + +// GetDefaultKeyData gets the details about the default key ID (see GetDefaultKeyID). +func (mach *Machine) GetDefaultKeyData(ctx context.Context) (keyID string, keyData *KeyMetadata, err error) { + keyID, err = mach.GetDefaultKeyID(ctx) + if err != nil { + return + } + keyData, err = mach.GetKeyData(ctx, keyID) + return +} + +// GetDecryptedAccountData gets the account data event with the given event type and decrypts it using the given key. +func (mach *Machine) GetDecryptedAccountData(ctx context.Context, eventType event.Type, key *Key) ([]byte, error) { + var encData EncryptedAccountDataEventContent + err := mach.Client.GetAccountData(ctx, eventType.Type, &encData) + if err != nil { + return nil, err + } + return encData.Decrypt(eventType.Type, key) +} + +// SetEncryptedAccountData encrypts the given data with the given keys and stores it on the server. +func (mach *Machine) SetEncryptedAccountData(ctx context.Context, eventType event.Type, data []byte, keys ...*Key) error { + if len(keys) == 0 { + return ErrNoKeyGiven + } + encrypted := make(map[string]EncryptedKeyData, len(keys)) + for _, key := range keys { + encrypted[key.ID] = key.Encrypt(eventType.Type, data) + } + return mach.Client.SetAccountData(ctx, eventType.Type, &EncryptedAccountDataEventContent{Encrypted: encrypted}) +} + +// SetEncryptedAccountDataWithMetadata encrypts the given data with the given keys and stores it, +// alongside the unencrypted metadata, on the server. +func (mach *Machine) SetEncryptedAccountDataWithMetadata(ctx context.Context, eventType event.Type, data []byte, metadata map[string]any, keys ...*Key) error { + if len(keys) == 0 { + return ErrNoKeyGiven + } + encrypted := make(map[string]EncryptedKeyData, len(keys)) + for _, key := range keys { + encrypted[key.ID] = key.Encrypt(eventType.Type, data) + } + return mach.Client.SetAccountData(ctx, eventType.Type, &EncryptedAccountDataEventContent{ + Encrypted: encrypted, + Metadata: metadata, + }) +} + +// GenerateAndUploadKey generates a new SSSS key and stores the metadata on the server. +func (mach *Machine) GenerateAndUploadKey(ctx context.Context, passphrase string) (key *Key, err error) { + key, err = NewKey(passphrase) + if err != nil { + return nil, fmt.Errorf("failed to generate new key: %w", err) + } + + err = mach.SetKeyData(ctx, key.ID, key.Metadata) + if err != nil { + err = fmt.Errorf("failed to upload key: %w", err) + } + return key, err +} diff --git a/mautrix-patched/crypto/ssss/key.go b/mautrix-patched/crypto/ssss/key.go new file mode 100644 index 00000000..763a3253 --- /dev/null +++ b/mautrix-patched/crypto/ssss/key.go @@ -0,0 +1,131 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package ssss + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/crypto/utils" +) + +// Key represents a SSSS private key and related metadata. +type Key struct { + ID string `json:"-"` + Key []byte `json:"-"` + Metadata *KeyMetadata `json:"-"` +} + +// NewKey generates a new SSSS key, optionally based on the given passphrase. +// +// Errors are only returned if crypto/rand runs out of randomness. +func NewKey(passphrase string) (*Key, error) { + if len(passphrase) > 0 { + // There's a passphrase. We need to generate a salt for it, set the metadata + // and then compute the key using the passphrase and the metadata. + passphraseMeta := NewPassphraseMetadata() + ssssKey, err := passphraseMeta.GetKey(passphrase) + if err != nil { + return nil, fmt.Errorf("failed to get key from passphrase: %w", err) + } + return WrapKey(ssssKey, passphraseMeta) + } + // No passphrase, just generate a random key + return WrapKey(random.Bytes(32), nil) +} + +func WrapKey(ssssKey []byte, passphraseMeta *PassphraseMetadata) (*Key, error) { + if len(ssssKey) != 32 { + return nil, fmt.Errorf("%w: must be 32 bytes", ErrInvalidRecoveryKey) + } + // We don't support any other algorithms currently. + keyData := KeyMetadata{ + Algorithm: AlgorithmAESHMACSHA2, + Passphrase: passphraseMeta, + } + + // Generate a random ID for the key. It's what identifies the key in account data. + keyIDBytes := random.Bytes(24) + + // We store a certain hash in the key metadata so that clients can check if the user entered the correct key. + ivBytes := random.Bytes(utils.AESCTRIVLength) + keyData.IV = base64.RawStdEncoding.EncodeToString(ivBytes) + macBytes, err := keyData.calculateHash(ssssKey) + if err != nil { + // This should never happen because we just generated the IV and key. + return nil, fmt.Errorf("failed to calculate hash: %w", err) + } + keyData.MAC = base64.RawStdEncoding.EncodeToString(macBytes) + + return &Key{ + Key: ssssKey, + ID: base64.RawStdEncoding.EncodeToString(keyIDBytes), + Metadata: &keyData, + }, nil +} + +// RecoveryKey gets the recovery key for this SSSS key. +func (key *Key) RecoveryKey() string { + return utils.EncodeBase58RecoveryKey(key.Key) +} + +// Encrypt encrypts the given data with this key. +func (key *Key) Encrypt(eventType string, data []byte) EncryptedKeyData { + aesKey, hmacKey := utils.DeriveKeysSHA256(key.Key, eventType) + + iv := utils.GenA256CTRIV() + // For some reason, keys in secret storage are base64 encoded before encrypting. + // Even more confusingly, it's a part of each key type's spec rather than the secrets spec. + // Key backup (`m.megolm_backup.v1`): https://spec.matrix.org/v1.9/client-server-api/#recovery-key + // Cross-signing (master, etc): https://spec.matrix.org/v1.9/client-server-api/#cross-signing (the very last paragraph) + // It's also not clear whether unpadded base64 is the right option, but assuming it is because everything else is unpadded. + payload := make([]byte, base64.RawStdEncoding.EncodedLen(len(data))) + base64.RawStdEncoding.Encode(payload, data) + utils.XorA256CTR(payload, aesKey, iv) + + return EncryptedKeyData{ + Ciphertext: base64.RawStdEncoding.EncodeToString(payload), + IV: base64.RawStdEncoding.EncodeToString(iv[:]), + MAC: utils.HMACSHA256B64(payload, hmacKey), + } +} + +// Decrypt decrypts the given encrypted data with this key. +func (key *Key) Decrypt(eventType string, data EncryptedKeyData) ([]byte, error) { + var ivBytes [utils.AESCTRIVLength]byte + decodedIV, _ := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.IV, "=")) + copy(ivBytes[:], decodedIV) + + payload, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.Ciphertext, "=")) + if err != nil { + return nil, err + } + + mac, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.MAC, "=")) + if err != nil { + return nil, err + } + + // derive the AES and HMAC keys for the requested event type using the SSSS key + aesKey, hmacKey := utils.DeriveKeysSHA256(key.Key, eventType) + + // compare the stored MAC with the one we calculated from the ciphertext + h := hmac.New(sha256.New, hmacKey[:]) + h.Write(payload) + if !hmac.Equal(h.Sum(nil), mac) { + return nil, ErrKeyDataMACMismatch + } + + utils.XorA256CTR(payload, aesKey, ivBytes) + decryptedDecoded, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(string(payload), "=")) + return decryptedDecoded, err +} diff --git a/mautrix-patched/crypto/ssss/key_test.go b/mautrix-patched/crypto/ssss/key_test.go new file mode 100644 index 00000000..5fd879db --- /dev/null +++ b/mautrix-patched/crypto/ssss/key_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package ssss_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/ssss" + "maunium.net/go/mautrix/event" +) + +const key1CrossSigningMasterKey = ` +{ + "encrypted": { + "gEJqbfSEMnP5JXXcukpXEX1l0aI3MDs0": { + "iv": "BpKP9nQJTE9jrsAssoxPqQ==", + "ciphertext": "fNRiiiidezjerTgV+G6pUtmeF3izzj5re/mVvY0hO2kM6kYGrxLuIu2ej80=", + "mac": "/gWGDGMyOLmbJp+aoSLh5JxCs0AdS6nAhjzpe+9G2Q0=" + } + } +} +` + +var key1CrossSigningMasterKeyDecrypted = []byte{ + 0x68, 0xf9, 0x7f, 0xd1, 0x92, 0x2e, 0xec, 0xf6, + 0xb8, 0x2b, 0xb8, 0x90, 0xd2, 0x4d, 0x06, 0x52, + 0x98, 0x4e, 0x7a, 0x1d, 0x70, 0x3b, 0x9e, 0x86, + 0x7b, 0x7e, 0xba, 0xf7, 0xfe, 0xb9, 0x5b, 0x6f, +} + +func getEncryptedMasterKey() *ssss.EncryptedAccountDataEventContent { + var eadec ssss.EncryptedAccountDataEventContent + err := json.Unmarshal([]byte(key1CrossSigningMasterKey), &eadec) + if err != nil { + panic(err) + } + return &eadec +} + +func TestKey_Decrypt_Success(t *testing.T) { + key := getKey1() + emk := getEncryptedMasterKey() + decrypted, err := emk.Decrypt(event.AccountDataCrossSigningMaster.Type, key) + assert.NoError(t, err) + assert.Equal(t, key1CrossSigningMasterKeyDecrypted, decrypted) +} + +func TestKey_Decrypt_WrongKey(t *testing.T) { + key := getKey2() + emk := getEncryptedMasterKey() + decrypted, err := emk.Decrypt(event.AccountDataCrossSigningMaster.Type, key) + assert.True(t, errors.Is(err, ssss.ErrNotEncryptedForKey), "unexpected error %v", err) + assert.Nil(t, decrypted) +} + +func TestKey_Decrypt_FakeKey(t *testing.T) { + key := getKey2() + key.ID = key1ID + emk := getEncryptedMasterKey() + decrypted, err := emk.Decrypt(event.AccountDataCrossSigningMaster.Type, key) + assert.True(t, errors.Is(err, ssss.ErrKeyDataMACMismatch), "unexpected error %v", err) + assert.Nil(t, decrypted) +} + +func TestKey_Decrypt_WrongType(t *testing.T) { + key := getKey1() + emk := getEncryptedMasterKey() + decrypted, err := emk.Decrypt(event.AccountDataCrossSigningSelf.Type, key) + assert.True(t, errors.Is(err, ssss.ErrKeyDataMACMismatch), "unexpected error %v", err) + assert.Nil(t, decrypted) +} + +func TestKey_Encrypt(t *testing.T) { + key1 := getKey1() + var evtType = "net.maunium.data" + var data = []byte{0xde, 0xad, 0xbe, 0xef} + encrypted := key1.Encrypt(evtType, data) + decrypted, err := key1.Decrypt(evtType, encrypted) + assert.NoError(t, err) + assert.Equal(t, data, decrypted) +} diff --git a/mautrix-patched/crypto/ssss/meta.go b/mautrix-patched/crypto/ssss/meta.go new file mode 100644 index 00000000..7d88e4b9 --- /dev/null +++ b/mautrix-patched/crypto/ssss/meta.go @@ -0,0 +1,158 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package ssss + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/crypto/utils" +) + +// KeyMetadata represents server-side metadata about a SSSS key. The metadata can be used to get +// the actual SSSS key from a passphrase or recovery key. +type KeyMetadata struct { + Name string `json:"name,omitempty"` + Algorithm Algorithm `json:"algorithm"` + + // Note: as per https://spec.matrix.org/v1.9/client-server-api/#msecret_storagev1aes-hmac-sha2, + // these fields are "maybe padded" base64, so both unpadded and padded values must be supported. + IV string `json:"iv,omitempty"` + MAC string `json:"mac,omitempty"` + + Passphrase *PassphraseMetadata `json:"passphrase,omitempty"` +} + +// VerifyRecoveryKey verifies that the given passphrase is valid and returns the computed SSSS key. +func (kd *KeyMetadata) VerifyPassphrase(keyID, passphrase string) (*Key, error) { + ssssKey, err := kd.Passphrase.GetKey(passphrase) + if err != nil { + return nil, err + } + err = kd.verifyKey(ssssKey) + if err != nil && !errors.Is(err, ErrUnverifiableKey) { + return nil, err + } + + return &Key{ + ID: keyID, + Key: ssssKey, + Metadata: kd, + }, nil +} + +// VerifyRecoveryKey verifies that the given recovery key is valid and returns the decoded SSSS key. +func (kd *KeyMetadata) VerifyRecoveryKey(keyID, recoveryKey string) (*Key, error) { + ssssKey := utils.DecodeBase58RecoveryKey(recoveryKey) + if ssssKey == nil { + return nil, ErrInvalidRecoveryKey + } + err := kd.verifyKey(ssssKey) + if err != nil && !errors.Is(err, ErrUnverifiableKey) { + return nil, err + } + + return &Key{ + ID: keyID, + Key: ssssKey, + Metadata: kd, + }, err +} + +func (kd *KeyMetadata) verifyKey(key []byte) error { + if kd.MAC == "" || kd.IV == "" { + return ErrUnverifiableKey + } + unpaddedMAC := strings.TrimRight(kd.MAC, "=") + expectedMACLength := base64.RawStdEncoding.EncodedLen(utils.SHAHashLength) + if len(unpaddedMAC) != expectedMACLength { + return fmt.Errorf("%w: invalid mac length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedMAC), expectedMACLength) + } + expectedMAC, err := base64.RawStdEncoding.DecodeString(unpaddedMAC) + if err != nil { + return fmt.Errorf("%w: failed to decode mac: %w", ErrCorruptedKeyMetadata, err) + } + calculatedMAC, err := kd.calculateHash(key) + if err != nil { + return err + } + // This doesn't really need to be constant time since it's fully local, but might as well be. + if !hmac.Equal(expectedMAC, calculatedMAC) { + return ErrIncorrectSSSSKey + } + return nil +} + +// VerifyKey verifies the SSSS key is valid by calculating and comparing its MAC. +func (kd *KeyMetadata) VerifyKey(key []byte) bool { + return kd.verifyKey(key) == nil +} + +// calculateHash calculates the hash used for checking if the key is entered correctly as described +// in the spec: https://matrix.org/docs/spec/client_server/unstable#m-secret-storage-v1-aes-hmac-sha2 +func (kd *KeyMetadata) calculateHash(key []byte) ([]byte, error) { + aesKey, hmacKey := utils.DeriveKeysSHA256(key, "") + unpaddedIV := strings.TrimRight(kd.IV, "=") + expectedIVLength := base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength) + if len(unpaddedIV) < expectedIVLength || len(unpaddedIV) > expectedIVLength*3 { + return nil, fmt.Errorf("%w: invalid iv length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedIV), expectedIVLength) + } + rawIVBytes, err := base64.RawStdEncoding.DecodeString(unpaddedIV) + if err != nil { + return nil, fmt.Errorf("%w: failed to decode iv: %w", ErrCorruptedKeyMetadata, err) + } + // TODO log a warning for non-16 byte IVs? + // Certain broken clients like nheko generated 32-byte IVs where only the first 16 bytes were used. + ivBytes := *(*[utils.AESCTRIVLength]byte)(rawIVBytes[:utils.AESCTRIVLength]) + + zeroes := make([]byte, utils.AESCTRKeyLength) + encryptedZeroes := utils.XorA256CTR(zeroes, aesKey, ivBytes) + h := hmac.New(sha256.New, hmacKey[:]) + h.Write(encryptedZeroes) + return h.Sum(nil), nil +} + +// PassphraseMetadata represents server-side metadata about a SSSS key passphrase. +type PassphraseMetadata struct { + Algorithm PassphraseAlgorithm `json:"algorithm"` + Iterations int `json:"iterations"` + Salt string `json:"salt"` + Bits int `json:"bits"` +} + +func NewPassphraseMetadata() *PassphraseMetadata { + return &PassphraseMetadata{ + Algorithm: PassphraseAlgorithmPBKDF2, + Iterations: 500000, + Salt: base64.StdEncoding.EncodeToString(random.Bytes(24)), + Bits: 256, + } +} + +// GetKey gets the SSSS key from the passphrase. +func (pd *PassphraseMetadata) GetKey(passphrase string) ([]byte, error) { + if pd == nil { + return nil, ErrNoPassphrase + } + + if pd.Algorithm != PassphraseAlgorithmPBKDF2 { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedPassphraseAlgorithm, pd.Algorithm) + } + + bits := 256 + if pd.Bits != 0 { + bits = pd.Bits + } + + return utils.PBKDF2SHA512([]byte(passphrase), []byte(pd.Salt), pd.Iterations, bits), nil +} diff --git a/mautrix-patched/crypto/ssss/meta_test.go b/mautrix-patched/crypto/ssss/meta_test.go new file mode 100644 index 00000000..d59809c7 --- /dev/null +++ b/mautrix-patched/crypto/ssss/meta_test.go @@ -0,0 +1,174 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package ssss_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/crypto/ssss" +) + +const key1Meta = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2", + "passphrase": { + "algorithm": "m.pbkdf2", + "iterations": 500000, + "salt": "y863BOoqOadgDp8S3FtHXikDJEalsQ7d" + }, + "iv": "xxkTK0L4UzxgAFkQ6XPwsw", + "mac": "MEhooO0ZhFJNxUhvRMSxBnJfL20wkLgle3ocY0ee/eA" +} +` +const key1ID = "gEJqbfSEMnP5JXXcukpXEX1l0aI3MDs0" + +const key1RecoveryKey = "EsTE s92N EtaX s2h6 VQYF 9Kao tHYL mkyL GKMh isZb KJ4E tvoC" +const key1Passphrase = "correct horse battery staple" + +const key2Meta = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2", + "iv": "O0BOvTqiIAYjC+RMcyHfWw==", + "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" +} +` + +const key2MetaUnverified = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2" +} +` + +const key2MetaLongIV = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2", + "iv": "O0BOvTqiIAYjC+RMcyHfW2f/gdxjceTxoYtNlpPduJ8=", + "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" +} +` + +const key2MetaBrokenIV = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2", + "iv": "MeowMeowMeow", + "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" +} +` + +const key2MetaBrokenMAC = ` +{ + "algorithm": "m.secret_storage.v1.aes-hmac-sha2", + "iv": "O0BOvTqiIAYjC+RMcyHfWw==", + "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtIMeowMeowMeow" +} +` + +const key2ID = "NVe5vK6lZS9gEMQLJw0yqkzmE5Mr7dLv" +const key2RecoveryKey = "EsUC xSxt XJgQ dz19 8WBZ rHdE GZo7 ybsn EFmG Y5HY MDAG GNWe" + +func getKeyMeta(meta string) *ssss.KeyMetadata { + var km ssss.KeyMetadata + err := json.Unmarshal([]byte(meta), &km) + if err != nil { + panic(err) + } + return &km +} + +func getKey1() *ssss.Key { + return exerrors.Must(getKeyMeta(key1Meta).VerifyRecoveryKey(key1ID, key1RecoveryKey)) +} + +func getKey2() *ssss.Key { + return exerrors.Must(getKeyMeta(key2Meta).VerifyRecoveryKey(key2ID, key2RecoveryKey)) +} + +func TestKeyMetadata_VerifyRecoveryKey_Correct(t *testing.T) { + km := getKeyMeta(key1Meta) + key, err := km.VerifyRecoveryKey(key1ID, key1RecoveryKey) + assert.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, key1RecoveryKey, key.RecoveryKey()) +} + +func TestKeyMetadata_VerifyRecoveryKey_Correct2(t *testing.T) { + km := getKeyMeta(key2Meta) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) +} + +func TestKeyMetadata_VerifyRecoveryKey_NonCompliant_LongIV(t *testing.T) { + km := getKeyMeta(key2MetaLongIV) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) +} + +func TestKeyMetadata_VerifyRecoveryKey_Unverified(t *testing.T) { + km := getKeyMeta(key2MetaUnverified) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.ErrorIs(t, err, ssss.ErrUnverifiableKey) + assert.NotNil(t, key) + assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) +} + +func TestKeyMetadata_VerifyRecoveryKey_Invalid(t *testing.T) { + km := getKeyMeta(key1Meta) + key, err := km.VerifyRecoveryKey(key1ID, "foo") + assert.ErrorIs(t, err, ssss.ErrInvalidRecoveryKey) + assert.Nil(t, key) +} + +func TestKeyMetadata_VerifyRecoveryKey_Incorrect(t *testing.T) { + km := getKeyMeta(key1Meta) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey) + assert.Nil(t, key) +} + +func TestKeyMetadata_VerifyPassphrase_Correct(t *testing.T) { + km := getKeyMeta(key1Meta) + key, err := km.VerifyPassphrase(key1ID, key1Passphrase) + assert.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, key1RecoveryKey, key.RecoveryKey()) +} + +func TestKeyMetadata_VerifyPassphrase_Incorrect(t *testing.T) { + km := getKeyMeta(key1Meta) + key, err := km.VerifyPassphrase(key1ID, "incorrect horse battery staple") + assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey) + assert.Nil(t, key) +} + +func TestKeyMetadata_VerifyPassphrase_NotSet(t *testing.T) { + km := getKeyMeta(key2Meta) + key, err := km.VerifyPassphrase(key2ID, "hmm") + assert.ErrorIs(t, err, ssss.ErrNoPassphrase) + assert.Nil(t, key) +} + +func TestKeyMetadata_VerifyRecoveryKey_CorruptedIV(t *testing.T) { + km := getKeyMeta(key2MetaBrokenIV) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata) + assert.Nil(t, key) +} + +func TestKeyMetadata_VerifyRecoveryKey_CorruptedMAC(t *testing.T) { + km := getKeyMeta(key2MetaBrokenMAC) + key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) + assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata) + assert.Nil(t, key) +} diff --git a/mautrix-patched/crypto/ssss/types.go b/mautrix-patched/crypto/ssss/types.go new file mode 100644 index 00000000..b7465d3e --- /dev/null +++ b/mautrix-patched/crypto/ssss/types.go @@ -0,0 +1,81 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package ssss + +import ( + "errors" + "fmt" + "reflect" + + "maunium.net/go/mautrix/event" +) + +var ( + ErrNoDefaultKeyID = errors.New("could not find default key ID") + ErrNoDefaultKeyAccountDataEvent = fmt.Errorf("%w: no %s event in account data", ErrNoDefaultKeyID, event.AccountDataSecretStorageDefaultKey.Type) + ErrNoKeyFieldInAccountDataEvent = fmt.Errorf("%w: missing key field in account data event", ErrNoDefaultKeyID) + ErrNoKeyGiven = errors.New("must provide at least one key to encrypt for") + + ErrNotEncryptedForKey = errors.New("data is not encrypted for given key ID") + ErrKeyDataMACMismatch = errors.New("key data MAC mismatch") + ErrNoPassphrase = errors.New("no passphrase data has been set for the default key") + ErrUnsupportedPassphraseAlgorithm = errors.New("unsupported passphrase KDF algorithm") + ErrIncorrectSSSSKey = errors.New("incorrect SSSS key") + ErrInvalidRecoveryKey = errors.New("invalid recovery key") + ErrCorruptedKeyMetadata = errors.New("corrupted recovery key metadata") + ErrUnverifiableKey = errors.New("cannot verify recovery key: missing MAC or IV in metadata") +) + +// Algorithm is the identifier for an SSSS encryption algorithm. +type Algorithm string + +const ( + // AlgorithmAESHMACSHA2 is the current main algorithm. + AlgorithmAESHMACSHA2 Algorithm = "m.secret_storage.v1.aes-hmac-sha2" + // AlgorithmCurve25519AESSHA2 is the old algorithm + AlgorithmCurve25519AESSHA2 Algorithm = "m.secret_storage.v1.curve25519-aes-sha2" +) + +// PassphraseAlgorithm is the identifier for an algorithm used to derive a key from a passphrase for SSSS. +type PassphraseAlgorithm string + +const ( + // PassphraseAlgorithmPBKDF2 is the current main algorithm + PassphraseAlgorithmPBKDF2 PassphraseAlgorithm = "m.pbkdf2" +) + +type EncryptedKeyData struct { + // Note: as per https://spec.matrix.org/v1.9/client-server-api/#msecret_storagev1aes-hmac-sha2-1, + // these fields are "maybe padded" base64, so both unpadded and padded values must be supported. + Ciphertext string `json:"ciphertext"` + IV string `json:"iv"` + MAC string `json:"mac"` +} + +type EncryptedAccountDataEventContent struct { + Encrypted map[string]EncryptedKeyData `json:"encrypted"` + Metadata map[string]any `json:"com.beeper.metadata,omitzero"` +} + +func (ed *EncryptedAccountDataEventContent) Decrypt(eventType string, key *Key) ([]byte, error) { + keyEncData, ok := ed.Encrypted[key.ID] + if !ok { + return nil, ErrNotEncryptedForKey + } + + return key.Decrypt(eventType, keyEncData) +} + +func init() { + encryptedContent := reflect.TypeOf(&EncryptedAccountDataEventContent{}) + event.TypeMap[event.AccountDataCrossSigningMaster] = encryptedContent + event.TypeMap[event.AccountDataCrossSigningSelf] = encryptedContent + event.TypeMap[event.AccountDataCrossSigningUser] = encryptedContent + event.TypeMap[event.AccountDataSecretStorageDefaultKey] = reflect.TypeOf(&DefaultSecretStorageKeyContent{}) + event.TypeMap[event.AccountDataSecretStorageKey] = reflect.TypeOf(&KeyMetadata{}) + event.TypeMap[event.AccountDataMegolmBackupKey] = reflect.TypeOf(&EncryptedAccountDataEventContent{}) +} diff --git a/mautrix-patched/crypto/store.go b/mautrix-patched/crypto/store.go new file mode 100644 index 00000000..ba810ae1 --- /dev/null +++ b/mautrix-patched/crypto/store.go @@ -0,0 +1,755 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "fmt" + "slices" + "sort" + "sync" + "time" + + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exsync" + "golang.org/x/exp/maps" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ErrGroupSessionWithheld error = &event.RoomKeyWithheldEventContent{} + +// Store is used by OlmMachine to store Olm and Megolm sessions, user device lists and message indices. +// +// General implementation details: +// * Get methods should not return errors if the requested data does not exist in the store, they should simply return nil. +// * Update methods may assume that the pointer is the same as what has earlier been added to or fetched from the store. +type Store interface { + // Flush ensures that everything in the store is persisted to disk. + // This doesn't have to do anything, e.g. for database-backed implementations that persist everything immediately. + Flush(context.Context) error + + // PutAccount updates the OlmAccount in the store. + PutAccount(context.Context, *OlmAccount) error + // GetAccount returns the OlmAccount in the store that was previously inserted with PutAccount. + GetAccount(ctx context.Context) (*OlmAccount, error) + + // AddSession inserts an Olm session into the store. + AddSession(context.Context, id.SenderKey, *OlmSession) error + // HasSession returns whether or not the store has an Olm session with the given sender key. + HasSession(context.Context, id.SenderKey) bool + // GetSessions returns all Olm sessions in the store with the given sender key. + GetSessions(context.Context, id.SenderKey) (OlmSessionList, error) + // GetLatestSession returns the most recent session that should be used for encrypting outbound messages. + // It's usually the one with the most recent successful decryption or the highest ID lexically. + GetLatestSession(context.Context, id.SenderKey) (*OlmSession, error) + // GetNewestSessionCreationTS returns the creation timestamp of the most recently created session for the given sender key. + GetNewestSessionCreationTS(context.Context, id.SenderKey) (time.Time, error) + // UpdateSession updates a session that has previously been inserted with AddSession. + UpdateSession(context.Context, id.SenderKey, *OlmSession) error + // DeleteSession deletes the given session that has been previously inserted with AddSession. + DeleteSession(context.Context, id.SenderKey, *OlmSession) error + + // PutOlmHash marks a given olm message hash as handled. + PutOlmHash(context.Context, [32]byte, time.Time) error + // GetOlmHash gets the time that a given olm hash was handled. + GetOlmHash(context.Context, [32]byte) (time.Time, error) + // DeleteOldOlmHashes deletes all olm hashes that were handled before the given time. + DeleteOldOlmHashes(context.Context, time.Time) error + + // PutGroupSession inserts an inbound Megolm session into the store. If an earlier withhold event has been inserted + // with PutWithheldGroupSession, this call should replace that. However, PutWithheldGroupSession must not replace + // sessions inserted with this call. + PutGroupSession(context.Context, *InboundGroupSession) error + // GetGroupSession gets an inbound Megolm session from the store. If the group session has been withheld + // (i.e. a room key withheld event has been saved with PutWithheldGroupSession), this should return the + // ErrGroupSessionWithheld error. The caller may use GetWithheldGroupSession to find more details. + GetGroupSession(context.Context, id.RoomID, id.SessionID) (*InboundGroupSession, error) + // RedactGroupSession removes the session data for the given inbound Megolm session from the store. + RedactGroupSession(context.Context, id.RoomID, id.SessionID, string) error + // RedactGroupSessions removes the session data for all inbound Megolm sessions from a specific device and/or in a specific room. + RedactGroupSessions(context.Context, id.RoomID, id.SenderKey, string) ([]id.SessionID, error) + // RedactExpiredGroupSessions removes the session data for all inbound Megolm sessions that have expired. + RedactExpiredGroupSessions(context.Context) ([]id.SessionID, error) + // RedactOutdatedGroupSessions removes the session data for all inbound Megolm sessions that are lacking the expiration metadata. + RedactOutdatedGroupSessions(context.Context) ([]id.SessionID, error) + // PutWithheldGroupSession tells the store that a specific Megolm session was withheld. + PutWithheldGroupSession(context.Context, event.RoomKeyWithheldEventContent) error + // GetWithheldGroupSession gets the event content that was previously inserted with PutWithheldGroupSession. + GetWithheldGroupSession(context.Context, id.RoomID, id.SessionID) (*event.RoomKeyWithheldEventContent, error) + + // GetGroupSessionsForRoom gets all the inbound Megolm sessions for a specific room. This is used for creating key + // export files. Unlike GetGroupSession, this should not return any errors about withheld keys. + GetGroupSessionsForRoom(context.Context, id.RoomID) dbutil.RowIter[*InboundGroupSession] + // GetAllGroupSessions gets all the inbound Megolm sessions in the store. This is used for creating key export + // files. Unlike GetGroupSession, this should not return any errors about withheld keys. + GetAllGroupSessions(context.Context) dbutil.RowIter[*InboundGroupSession] + // GetGroupSessionsWithoutKeyBackupVersion gets all the inbound Megolm sessions in the store that do not match given key backup version. + GetGroupSessionsWithoutKeyBackupVersion(context.Context, id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] + + // AddOutboundGroupSession inserts the given outbound Megolm session into the store. + // + // The store should index inserted sessions by the RoomID field to support getting and removing sessions. + // There will only be one outbound session per room ID at a time. + AddOutboundGroupSession(context.Context, *OutboundGroupSession) error + // UpdateOutboundGroupSession updates the given outbound Megolm session in the store. + UpdateOutboundGroupSession(context.Context, *OutboundGroupSession) error + // GetOutboundGroupSession gets the stored outbound Megolm session for the given room ID from the store. + GetOutboundGroupSession(context.Context, id.RoomID) (*OutboundGroupSession, error) + // RemoveOutboundGroupSession removes the stored outbound Megolm session for the given room ID. + RemoveOutboundGroupSession(context.Context, id.RoomID) error + // MarkOutboutGroupSessionShared flags that the currently known device has been shared the keys for the specified session. + MarkOutboundGroupSessionShared(context.Context, id.UserID, id.IdentityKey, id.SessionID) error + // IsOutboutGroupSessionShared checks if the specified session has been shared with the device. + IsOutboundGroupSessionShared(context.Context, id.UserID, id.IdentityKey, id.SessionID) (bool, error) + + // ValidateMessageIndex validates that the given message details aren't from a replay attack. + // + // Implementations should store a map from (sessionID, index) to (eventID, timestamp), then use that map + // to check whether or not the message index is valid: + // + // * If the map key doesn't exist, the given values should be stored and this should return true. + // * If the map key exists and the stored values match the given values, this should return true. + // * If the map key exists, but the stored values do not match the given values, this should return false. + ValidateMessageIndex(ctx context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) + + // GetDevices returns a map from device ID to id.Device struct containing all devices of a given user. + GetDevices(context.Context, id.UserID) (map[id.DeviceID]*id.Device, error) + // GetDevice returns a specific device of a given user. + GetDevice(context.Context, id.UserID, id.DeviceID) (*id.Device, error) + // PutDevice stores a single device for a user, replacing it if it exists already. + PutDevice(context.Context, id.UserID, *id.Device) error + // PutDevices overrides the stored device list for the given user with the given list. + PutDevices(context.Context, id.UserID, map[id.DeviceID]*id.Device) error + // FindDeviceByKey finds a specific device by its identity key. + FindDeviceByKey(context.Context, id.UserID, id.IdentityKey) (*id.Device, error) + // FilterTrackedUsers returns a filtered version of the given list that only includes user IDs whose device lists + // have been stored with PutDevices. A user is considered tracked even if the PutDevices list was empty. + FilterTrackedUsers(context.Context, []id.UserID) ([]id.UserID, error) + // MarkTrackedUsersOutdated flags that the device list for given users are outdated. + MarkTrackedUsersOutdated(context.Context, []id.UserID) error + // GetOutdatedTrackerUsers gets all tracked users whose devices need to be updated. + GetOutdatedTrackedUsers(context.Context) ([]id.UserID, error) + + // PutCrossSigningKey stores a cross-signing key of some user along with its usage. + PutCrossSigningKey(context.Context, id.UserID, id.CrossSigningUsage, id.Ed25519) error + // GetCrossSigningKeys retrieves a user's stored cross-signing keys. + GetCrossSigningKeys(context.Context, id.UserID) (map[id.CrossSigningUsage]id.CrossSigningKey, error) + // PutSignature stores a signature of a cross-signing or device key along with the signer's user ID and key. + PutSignature(ctx context.Context, signedUser id.UserID, signedKey id.Ed25519, signerUser id.UserID, signerKey id.Ed25519, signature string) error + // IsKeySignedBy returns whether a cross-signing or device key is signed by the given signer. + IsKeySignedBy(ctx context.Context, userID id.UserID, key id.Ed25519, signedByUser id.UserID, signedByKey id.Ed25519) (bool, error) + // DropSignaturesByKey deletes the signatures made by the given user and key from the store. It returns the number of signatures deleted. + DropSignaturesByKey(context.Context, id.UserID, id.Ed25519) (int64, error) + // GetSignaturesForKeyBy retrieves the stored signatures for a given cross-signing or device key, by the given signer. + GetSignaturesForKeyBy(context.Context, id.UserID, id.Ed25519, id.UserID) (map[id.Ed25519]string, error) + + // PutSecret stores a named secret, replacing it if it exists already. + PutSecret(context.Context, id.Secret, string) error + // GetSecret returns a named secret. + GetSecret(context.Context, id.Secret) (string, error) + // DeleteSecret removes a named secret. + DeleteSecret(context.Context, id.Secret) error +} + +type messageIndexKey struct { + SessionID id.SessionID + Index uint +} + +type messageIndexValue struct { + EventID id.EventID + Timestamp int64 +} + +// MemoryStore is a simple in-memory Store implementation. It can optionally have a callback function for saving data, +// but the actual storage must be implemented manually. +type MemoryStore struct { + lock sync.RWMutex + + save func() error + + Account *OlmAccount + Sessions map[id.SenderKey]OlmSessionList + GroupSessions map[id.RoomID]map[id.SessionID]*InboundGroupSession + WithheldGroupSessions map[id.RoomID]map[id.SessionID]*event.RoomKeyWithheldEventContent + OutGroupSessions map[id.RoomID]*OutboundGroupSession + SharedGroupSessions map[id.UserID]map[id.IdentityKey]map[id.SessionID]struct{} + MessageIndices map[messageIndexKey]messageIndexValue + Devices map[id.UserID]map[id.DeviceID]*id.Device + CrossSigningKeys map[id.UserID]map[id.CrossSigningUsage]id.CrossSigningKey + KeySignatures map[id.UserID]map[id.Ed25519]map[id.UserID]map[id.Ed25519]string + OutdatedUsers map[id.UserID]struct{} + Secrets map[id.Secret]string + OlmHashes *exsync.Set[[32]byte] +} + +var _ Store = (*MemoryStore)(nil) + +func NewMemoryStore(saveCallback func() error) *MemoryStore { + if saveCallback == nil { + saveCallback = func() error { return nil } + } + return &MemoryStore{ + save: saveCallback, + + Sessions: make(map[id.SenderKey]OlmSessionList), + GroupSessions: make(map[id.RoomID]map[id.SessionID]*InboundGroupSession), + WithheldGroupSessions: make(map[id.RoomID]map[id.SessionID]*event.RoomKeyWithheldEventContent), + OutGroupSessions: make(map[id.RoomID]*OutboundGroupSession), + SharedGroupSessions: make(map[id.UserID]map[id.IdentityKey]map[id.SessionID]struct{}), + MessageIndices: make(map[messageIndexKey]messageIndexValue), + Devices: make(map[id.UserID]map[id.DeviceID]*id.Device), + CrossSigningKeys: make(map[id.UserID]map[id.CrossSigningUsage]id.CrossSigningKey), + KeySignatures: make(map[id.UserID]map[id.Ed25519]map[id.UserID]map[id.Ed25519]string), + OutdatedUsers: make(map[id.UserID]struct{}), + Secrets: make(map[id.Secret]string), + OlmHashes: exsync.NewSet[[32]byte](), + } +} + +func (gs *MemoryStore) Flush(_ context.Context) error { + gs.lock.Lock() + defer gs.lock.Unlock() + return gs.save() +} + +func (gs *MemoryStore) GetAccount(_ context.Context) (*OlmAccount, error) { + return gs.Account, nil +} + +func (gs *MemoryStore) PutAccount(_ context.Context, account *OlmAccount) error { + gs.lock.Lock() + defer gs.lock.Unlock() + gs.Account = account + return gs.save() +} + +func (gs *MemoryStore) GetSessions(_ context.Context, senderKey id.SenderKey) (OlmSessionList, error) { + gs.lock.Lock() + defer gs.lock.Unlock() + sessions, ok := gs.Sessions[senderKey] + if !ok { + sessions = []*OlmSession{} + gs.Sessions[senderKey] = sessions + } + return sessions, nil +} + +func (gs *MemoryStore) AddSession(_ context.Context, senderKey id.SenderKey, session *OlmSession) error { + gs.lock.Lock() + defer gs.lock.Unlock() + sessions := gs.Sessions[senderKey] + gs.Sessions[senderKey] = append(sessions, session) + sort.Sort(gs.Sessions[senderKey]) + return gs.save() +} + +func (gs *MemoryStore) DeleteSession(ctx context.Context, senderKey id.SenderKey, target *OlmSession) error { + gs.lock.Lock() + defer gs.lock.Unlock() + sessions, ok := gs.Sessions[senderKey] + if !ok { + return nil + } + gs.Sessions[senderKey] = slices.DeleteFunc(sessions, func(session *OlmSession) bool { + return session == target + }) + return gs.save() +} + +func (gs *MemoryStore) UpdateSession(_ context.Context, _ id.SenderKey, _ *OlmSession) error { + // we don't need to do anything here because the session is a pointer and already stored in our map + return gs.save() +} + +func (gs *MemoryStore) HasSession(_ context.Context, senderKey id.SenderKey) bool { + gs.lock.RLock() + defer gs.lock.RUnlock() + sessions, ok := gs.Sessions[senderKey] + return ok && len(sessions) > 0 && !sessions[0].Expired() +} + +func (gs *MemoryStore) PutOlmHash(_ context.Context, hash [32]byte, receivedAt time.Time) error { + gs.OlmHashes.Add(hash) + return nil +} + +func (gs *MemoryStore) GetOlmHash(_ context.Context, hash [32]byte) (time.Time, error) { + if gs.OlmHashes.Has(hash) { + // The time isn't that important, so we just return the current time + return time.Now(), nil + } + return time.Time{}, nil +} + +func (gs *MemoryStore) DeleteOldOlmHashes(_ context.Context, beforeTS time.Time) error { + return nil +} + +func (gs *MemoryStore) GetLatestSession(_ context.Context, senderKey id.SenderKey) (*OlmSession, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + sessions, ok := gs.Sessions[senderKey] + if !ok || len(sessions) == 0 { + return nil, nil + } + return sessions[len(sessions)-1], nil +} + +func (gs *MemoryStore) GetNewestSessionCreationTS(ctx context.Context, senderKey id.SenderKey) (createdAt time.Time, err error) { + var sess *OlmSession + sess, err = gs.GetLatestSession(ctx, senderKey) + if sess != nil { + createdAt = sess.CreationTime + } + return +} + +func (gs *MemoryStore) getGroupSessions(roomID id.RoomID) map[id.SessionID]*InboundGroupSession { + room, ok := gs.GroupSessions[roomID] + if !ok { + room = make(map[id.SessionID]*InboundGroupSession) + gs.GroupSessions[roomID] = room + } + return room +} + +func (gs *MemoryStore) PutGroupSession(_ context.Context, igs *InboundGroupSession) error { + gs.lock.Lock() + defer gs.lock.Unlock() + gs.getGroupSessions(igs.RoomID)[igs.ID()] = igs + return gs.save() +} + +func (gs *MemoryStore) GetGroupSession(_ context.Context, roomID id.RoomID, sessionID id.SessionID) (*InboundGroupSession, error) { + gs.lock.Lock() + defer gs.lock.Unlock() + session, ok := gs.getGroupSessions(roomID)[sessionID] + if !ok { + withheld, ok := gs.getWithheldGroupSessions(roomID)[sessionID] + if ok { + return nil, fmt.Errorf("%w (%s)", ErrGroupSessionWithheld, withheld.Code) + } + return nil, nil + } + return session, nil +} + +func (gs *MemoryStore) RedactGroupSession(_ context.Context, roomID id.RoomID, sessionID id.SessionID, reason string) error { + gs.lock.Lock() + defer gs.lock.Unlock() + delete(gs.getGroupSessions(roomID), sessionID) + return gs.save() +} + +func (gs *MemoryStore) RedactGroupSessions(_ context.Context, roomID id.RoomID, senderKey id.SenderKey, reason string) ([]id.SessionID, error) { + gs.lock.Lock() + defer gs.lock.Unlock() + var sessionIDs []id.SessionID + if roomID != "" && senderKey != "" { + sessions := gs.getGroupSessions(roomID) + for sessionID, session := range sessions { + if session.SenderKey == senderKey { + sessionIDs = append(sessionIDs, sessionID) + delete(sessions, sessionID) + } + } + } else if senderKey != "" { + for _, room := range gs.GroupSessions { + for sessionID, session := range room { + if session.SenderKey == senderKey { + sessionIDs = append(sessionIDs, sessionID) + delete(room, sessionID) + } + } + } + } else if roomID != "" { + sessionIDs = maps.Keys(gs.GroupSessions[roomID]) + delete(gs.GroupSessions, roomID) + } else { + return nil, fmt.Errorf("room ID or sender key must be provided for redacting sessions") + } + return sessionIDs, gs.save() +} + +func (gs *MemoryStore) RedactExpiredGroupSessions(_ context.Context) ([]id.SessionID, error) { + return nil, fmt.Errorf("not implemented") +} + +func (gs *MemoryStore) RedactOutdatedGroupSessions(_ context.Context) ([]id.SessionID, error) { + return nil, fmt.Errorf("not implemented") +} + +func (gs *MemoryStore) getWithheldGroupSessions(roomID id.RoomID) map[id.SessionID]*event.RoomKeyWithheldEventContent { + room, ok := gs.WithheldGroupSessions[roomID] + if !ok { + room = make(map[id.SessionID]*event.RoomKeyWithheldEventContent) + gs.WithheldGroupSessions[roomID] = room + } + return room +} + +func (gs *MemoryStore) PutWithheldGroupSession(_ context.Context, content event.RoomKeyWithheldEventContent) error { + gs.lock.Lock() + defer gs.lock.Unlock() + gs.getWithheldGroupSessions(content.RoomID)[content.SessionID] = &content + return gs.save() +} + +func (gs *MemoryStore) GetWithheldGroupSession(_ context.Context, roomID id.RoomID, sessionID id.SessionID) (*event.RoomKeyWithheldEventContent, error) { + gs.lock.Lock() + defer gs.lock.Unlock() + session, ok := gs.getWithheldGroupSessions(roomID)[sessionID] + if !ok { + return nil, nil + } + return session, nil +} + +func (gs *MemoryStore) GetGroupSessionsForRoom(_ context.Context, roomID id.RoomID) dbutil.RowIter[*InboundGroupSession] { + gs.lock.Lock() + defer gs.lock.Unlock() + room, ok := gs.GroupSessions[roomID] + if !ok { + return nil + } + return dbutil.NewSliceIter(maps.Values(room)) +} + +func (gs *MemoryStore) GetAllGroupSessions(_ context.Context) dbutil.RowIter[*InboundGroupSession] { + gs.lock.Lock() + defer gs.lock.Unlock() + var result []*InboundGroupSession + for _, room := range gs.GroupSessions { + result = append(result, maps.Values(room)...) + } + return dbutil.NewSliceIter(result) +} + +func (gs *MemoryStore) GetGroupSessionsWithoutKeyBackupVersion(_ context.Context, version id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] { + gs.lock.Lock() + defer gs.lock.Unlock() + var result []*InboundGroupSession + for _, room := range gs.GroupSessions { + for _, session := range room { + if session.KeyBackupVersion != version { + result = append(result, session) + } + } + } + return dbutil.NewSliceIter(result) +} + +func (gs *MemoryStore) AddOutboundGroupSession(_ context.Context, session *OutboundGroupSession) error { + gs.lock.Lock() + defer gs.lock.Unlock() + gs.OutGroupSessions[session.RoomID] = session + return gs.save() +} + +func (gs *MemoryStore) UpdateOutboundGroupSession(_ context.Context, _ *OutboundGroupSession) error { + // we don't need to do anything here because the session is a pointer and already stored in our map + return gs.save() +} + +func (gs *MemoryStore) GetOutboundGroupSession(_ context.Context, roomID id.RoomID) (*OutboundGroupSession, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + session, ok := gs.OutGroupSessions[roomID] + if !ok { + return nil, nil + } + return session, nil +} + +func (gs *MemoryStore) RemoveOutboundGroupSession(_ context.Context, roomID id.RoomID) error { + gs.lock.Lock() + defer gs.lock.Unlock() + session, ok := gs.OutGroupSessions[roomID] + if !ok || session == nil { + return nil + } + delete(gs.OutGroupSessions, roomID) + return nil +} + +func (gs *MemoryStore) MarkOutboundGroupSessionShared(_ context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) error { + gs.lock.Lock() + defer gs.lock.Unlock() + + if _, ok := gs.SharedGroupSessions[userID]; !ok { + gs.SharedGroupSessions[userID] = make(map[id.IdentityKey]map[id.SessionID]struct{}) + } + identities := gs.SharedGroupSessions[userID] + + if _, ok := identities[identityKey]; !ok { + identities[identityKey] = make(map[id.SessionID]struct{}) + } + + identities[identityKey][sessionID] = struct{}{} + + return nil +} + +func (gs *MemoryStore) IsOutboundGroupSessionShared(_ context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) (isShared bool, err error) { + gs.lock.Lock() + defer gs.lock.Unlock() + + if _, ok := gs.SharedGroupSessions[userID]; !ok { + return + } + identities := gs.SharedGroupSessions[userID] + + if _, ok := identities[identityKey]; !ok { + return + } + + _, isShared = identities[identityKey][sessionID] + return +} + +func (gs *MemoryStore) ValidateMessageIndex(_ context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { + gs.lock.Lock() + defer gs.lock.Unlock() + key := messageIndexKey{ + SessionID: sessionID, + Index: index, + } + val, ok := gs.MessageIndices[key] + if !ok { + if eventID == "" && timestamp == 0 { + return true, nil + } + gs.MessageIndices[key] = messageIndexValue{ + EventID: eventID, + Timestamp: timestamp, + } + _ = gs.save() + return true, nil + } + if val.EventID != eventID || val.Timestamp != timestamp { + return false, nil + } + return true, nil +} + +func (gs *MemoryStore) GetDevices(_ context.Context, userID id.UserID) (map[id.DeviceID]*id.Device, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + devices, ok := gs.Devices[userID] + if !ok { + devices = nil + } + return devices, nil +} + +func (gs *MemoryStore) GetDevice(_ context.Context, userID id.UserID, deviceID id.DeviceID) (*id.Device, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + devices, ok := gs.Devices[userID] + if !ok { + return nil, nil + } + device, ok := devices[deviceID] + if !ok { + return nil, nil + } + return device, nil +} + +func (gs *MemoryStore) FindDeviceByKey(_ context.Context, userID id.UserID, identityKey id.IdentityKey) (*id.Device, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + devices, ok := gs.Devices[userID] + if !ok { + return nil, nil + } + for _, device := range devices { + if device.IdentityKey == identityKey { + return device, nil + } + } + return nil, nil +} + +func (gs *MemoryStore) PutDevice(_ context.Context, userID id.UserID, device *id.Device) error { + gs.lock.Lock() + defer gs.lock.Unlock() + devices, ok := gs.Devices[userID] + if !ok { + devices = make(map[id.DeviceID]*id.Device) + gs.Devices[userID] = devices + } + devices[device.DeviceID] = device + return gs.save() +} + +func (gs *MemoryStore) PutDevices(_ context.Context, userID id.UserID, devices map[id.DeviceID]*id.Device) error { + gs.lock.Lock() + defer gs.lock.Unlock() + gs.Devices[userID] = devices + err := gs.save() + if err == nil { + delete(gs.OutdatedUsers, userID) + } + return err +} + +func (gs *MemoryStore) FilterTrackedUsers(_ context.Context, users []id.UserID) ([]id.UserID, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + var ptr int + for _, userID := range users { + _, ok := gs.Devices[userID] + if ok { + users[ptr] = userID + ptr++ + } + } + return users[:ptr], nil +} + +func (gs *MemoryStore) MarkTrackedUsersOutdated(_ context.Context, users []id.UserID) error { + gs.lock.Lock() + defer gs.lock.Unlock() + for _, userID := range users { + if _, ok := gs.Devices[userID]; ok { + gs.OutdatedUsers[userID] = struct{}{} + } + } + return nil +} + +func (gs *MemoryStore) GetOutdatedTrackedUsers(_ context.Context) ([]id.UserID, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + users := make([]id.UserID, 0, len(gs.OutdatedUsers)) + for userID := range gs.OutdatedUsers { + users = append(users, userID) + } + return users, nil +} + +func (gs *MemoryStore) PutCrossSigningKey(_ context.Context, userID id.UserID, usage id.CrossSigningUsage, key id.Ed25519) error { + gs.lock.RLock() + defer gs.lock.RUnlock() + userKeys, ok := gs.CrossSigningKeys[userID] + if !ok { + userKeys = make(map[id.CrossSigningUsage]id.CrossSigningKey) + gs.CrossSigningKeys[userID] = userKeys + } + existing, ok := userKeys[usage] + if ok { + existing.Key = key + userKeys[usage] = existing + } else { + userKeys[usage] = id.CrossSigningKey{ + Key: key, + First: key, + } + } + err := gs.save() + return err +} + +func (gs *MemoryStore) GetCrossSigningKeys(_ context.Context, userID id.UserID) (map[id.CrossSigningUsage]id.CrossSigningKey, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + keys, ok := gs.CrossSigningKeys[userID] + if !ok { + return map[id.CrossSigningUsage]id.CrossSigningKey{}, nil + } + return keys, nil +} + +func (gs *MemoryStore) PutSignature(_ context.Context, signedUserID id.UserID, signedKey id.Ed25519, signerUserID id.UserID, signerKey id.Ed25519, signature string) error { + gs.lock.RLock() + defer gs.lock.RUnlock() + signedUserSigs, ok := gs.KeySignatures[signedUserID] + if !ok { + signedUserSigs = make(map[id.Ed25519]map[id.UserID]map[id.Ed25519]string) + gs.KeySignatures[signedUserID] = signedUserSigs + } + signaturesForKey, ok := signedUserSigs[signedKey] + if !ok { + signaturesForKey = make(map[id.UserID]map[id.Ed25519]string) + signedUserSigs[signedKey] = signaturesForKey + } + signedByUser, ok := signaturesForKey[signerUserID] + if !ok { + signedByUser = make(map[id.Ed25519]string) + signaturesForKey[signerUserID] = signedByUser + } + signedByUser[signerKey] = signature + return gs.save() +} + +func (gs *MemoryStore) GetSignaturesForKeyBy(_ context.Context, userID id.UserID, key id.Ed25519, signerID id.UserID) (map[id.Ed25519]string, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + userKeys, ok := gs.KeySignatures[userID] + if !ok { + return map[id.Ed25519]string{}, nil + } + sigsForKey, ok := userKeys[key] + if !ok { + return map[id.Ed25519]string{}, nil + } + sigsBySigner, ok := sigsForKey[signerID] + if !ok { + return map[id.Ed25519]string{}, nil + } + return sigsBySigner, nil +} + +func (gs *MemoryStore) IsKeySignedBy(ctx context.Context, userID id.UserID, key id.Ed25519, signerID id.UserID, signerKey id.Ed25519) (bool, error) { + sigs, err := gs.GetSignaturesForKeyBy(ctx, userID, key, signerID) + if err != nil { + return false, err + } + _, ok := sigs[signerKey] + return ok, nil +} + +func (gs *MemoryStore) DropSignaturesByKey(_ context.Context, userID id.UserID, key id.Ed25519) (int64, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + var count int64 + for _, userSigs := range gs.KeySignatures { + for _, keySigs := range userSigs { + if signedBySigner, ok := keySigs[userID]; ok { + if _, ok := signedBySigner[key]; ok { + count++ + delete(signedBySigner, key) + } + } + } + } + return count, nil +} + +func (gs *MemoryStore) PutSecret(_ context.Context, name id.Secret, value string) error { + gs.lock.Lock() + defer gs.lock.Unlock() + gs.Secrets[name] = value + return nil +} + +func (gs *MemoryStore) GetSecret(_ context.Context, name id.Secret) (string, error) { + gs.lock.RLock() + defer gs.lock.RUnlock() + return gs.Secrets[name], nil +} + +func (gs *MemoryStore) DeleteSecret(_ context.Context, name id.Secret) error { + gs.lock.Lock() + defer gs.lock.Unlock() + delete(gs.Secrets, name) + return nil +} diff --git a/mautrix-patched/crypto/store_test.go b/mautrix-patched/crypto/store_test.go new file mode 100644 index 00000000..8c237c57 --- /dev/null +++ b/mautrix-patched/crypto/store_test.go @@ -0,0 +1,299 @@ +// Copyright (c) 2020 Nikos Filippakis +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "database/sql" + "strconv" + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +const olmSessID = "sJlikQQKXp7UQjmS9/lyZCNUVJ2AmKyHbufPBaC7tpk" +const olmPickled = "L6cdv3JYO9OzhXbcjNSwl7ldN5bDvwmGyin+hISePETE6bO71DIlhqTC9YIhg21RDqRPH2HNl1MCyCw0hEXICWQyeJ9S7JLie" + + "5PYxhqSSaTYaybvlvw34jvuSgEx0iotM6WNuWu5ocrsOo5Ye/3Nz7lBvxaw2rpS0jZnn7eV1n9GbINZk4YEVWrHOn7OxYfaGECJHDeAk/ameStiy" + + "o1Gru0a/cmR0O3oKMyYnlXir0jS7oETMCsWk59GeVlz++j4aK0FK4g8/3fCMmLDXSatFjE9hoWDmeRwal58Y+XwX76Te/PiWtrFrinvCDEQJcZTa" + + "qcCwp6sZrgLbmfBUBb0zJCogCmYw8m2" +const groupSession = "9ZbsRqJuETbjnxPpKv29n3dubP/m5PSLbr9I9CIWS2O86F/Og1JZXhqT+4fA5tovoPfdpk5QLh7PfDyjmgOcO9sSA37maJyzCy6Ap+uBZLAXp6VLJ0mjSvxi+PAbzGKDMqpn+pa+oeEIH6SFPG/2GGDSRoXVi5fttAClCIoav5RflWiMypKqnQRfkZR2Gx8glOaBiTzAd7m0X6XGfYIPol41JUIHfBLuJBfXQ0Uu5GScV4eKUWdJP2J6zzC2Hx8cZAhiBBzAza0CbGcnUK+YJXMYaJg92HiIo++l317LlsYUJ/P+gKOLafYR9/l8bAzxH7j5s31PnRs7mD1Bl6G1LFM+dPsGXUOLx6PlvlTlYYM/opai0uKKzT0Wk6zPoq9fN/smlXEPBtKlw2fqcytL4gOF0MrBPEca" + +func getCryptoStores(t *testing.T) map[string]Store { + rawDB, err := sql.Open("sqlite3", ":memory:?_busy_timeout=5000") + require.NoError(t, err, "Error opening raw database") + db, err := dbutil.NewWithDB(rawDB, "sqlite3") + require.NoError(t, err, "Error creating database wrapper") + sqlStore := NewSQLCryptoStore(db, nil, "accid", id.DeviceID("dev"), []byte("test")) + err = sqlStore.DB.Upgrade(context.TODO()) + require.NoError(t, err, "Error upgrading database") + + gobStore := NewMemoryStore(nil) + + return map[string]Store{ + "sql": sqlStore, + "gob": gobStore, + } +} + +func TestPutNextBatch(t *testing.T) { + stores := getCryptoStores(t) + store := stores["sql"].(*SQLCryptoStore) + store.PutNextBatch(context.Background(), "batch1") + + batch, err := store.GetNextBatch(context.Background()) + require.NoError(t, err, "Error retrieving next batch") + assert.Equal(t, "batch1", batch) +} + +func TestPutAccount(t *testing.T) { + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + acc := NewOlmAccount() + store.PutAccount(context.TODO(), acc) + retrieved, err := store.GetAccount(context.TODO()) + require.NoError(t, err, "Error retrieving account") + assert.Equal(t, acc.IdentityKey(), retrieved.IdentityKey(), "Identity key does not match") + assert.Equal(t, acc.SigningKey(), retrieved.SigningKey(), "Signing key does not match") + }) + } +} + +func TestValidateMessageIndex(t *testing.T) { + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + // Validating without event ID and timestamp before we have them should work + ok, err := store.ValidateMessageIndex(context.TODO(), "sess1", "", 0, 0) + require.NoError(t, err, "Error validating message index") + assert.True(t, ok, "First message validation should be valid") + + // First message should validate successfully + ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event1", 0, 1000) + require.NoError(t, err, "Error validating message index") + assert.True(t, ok, "First message validation should be valid") + + // Edit the timestamp and ensure validate fails + ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event1", 0, 1001) + require.NoError(t, err, "Error validating message index after timestamp change") + assert.False(t, ok, "First message validation should fail after timestamp change") + + // Edit the event ID and ensure validate fails + ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event2", 0, 1000) + require.NoError(t, err, "Error validating message index after event ID change") + assert.False(t, ok, "First message validation should fail after event ID change") + + // Validate again with the original parameters and ensure that it still passes + ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event1", 0, 1000) + require.NoError(t, err, "Error validating message index") + assert.True(t, ok, "First message validation should be valid") + + // Validating without event ID and timestamp must fail if we already know them + ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "", 0, 0) + require.NoError(t, err, "Error validating message index") + assert.False(t, ok, "First message validation should be invalid") + }) + } +} + +func TestStoreOlmSession(t *testing.T) { + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + require.False(t, store.HasSession(context.TODO(), olmSessID), "Found Olm session before inserting it") + + olmInternal, err := olm.SessionFromPickled([]byte(olmPickled), []byte("test")) + require.NoError(t, err, "Error creating internal Olm session") + + olmSess := OlmSession{ + id: olmSessID, + Internal: olmInternal, + } + err = store.AddSession(context.TODO(), olmSessID, &olmSess) + require.NoError(t, err, "Error storing Olm session") + assert.True(t, store.HasSession(context.TODO(), olmSessID), "Olm session not found after inserting it") + + retrieved, err := store.GetLatestSession(context.TODO(), olmSessID) + require.NoError(t, err, "Error retrieving Olm session") + assert.EqualValues(t, olmSessID, retrieved.ID()) + + pickled, err := retrieved.Internal.Pickle([]byte("test")) + require.NoError(t, err, "Error pickling Olm session") + assert.EqualValues(t, pickled, olmPickled, "Pickled Olm session does not match original") + }) + } +} + +func TestStoreMegolmSession(t *testing.T) { + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + acc := NewOlmAccount() + + internal, err := olm.InboundGroupSessionFromPickled([]byte(groupSession), []byte("test")) + require.NoError(t, err, "Error creating internal inbound group session") + + igs := &InboundGroupSession{ + Internal: internal, + SigningKey: acc.SigningKey(), + SenderKey: acc.IdentityKey(), + RoomID: "room1", + } + + err = store.PutGroupSession(context.TODO(), igs) + require.NoError(t, err, "Error storing inbound group session") + + retrieved, err := store.GetGroupSession(context.TODO(), "room1", igs.ID()) + require.NoError(t, err, "Error retrieving inbound group session") + + pickled, err := retrieved.Internal.Pickle([]byte("test")) + require.NoError(t, err, "Error pickling inbound group session") + assert.EqualValues(t, pickled, groupSession, "Pickled inbound group session does not match original") + }) + } +} + +func TestStoreOutboundMegolmSession(t *testing.T) { + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + sess, err := store.GetOutboundGroupSession(context.TODO(), "room1") + require.NoError(t, err, "Error retrieving outbound session") + require.Nil(t, sess, "Got outbound session before inserting") + + outbound, err := NewOutboundGroupSession("room1", nil) + require.NoError(t, err) + err = store.AddOutboundGroupSession(context.TODO(), outbound) + require.NoError(t, err, "Error inserting outbound session") + + sess, err = store.GetOutboundGroupSession(context.TODO(), "room1") + require.NoError(t, err, "Error retrieving outbound session") + assert.NotNil(t, sess, "Did not get outbound session after inserting") + + err = store.RemoveOutboundGroupSession(context.TODO(), "room1") + require.NoError(t, err, "Error deleting outbound session") + + sess, err = store.GetOutboundGroupSession(context.TODO(), "room1") + require.NoError(t, err, "Error retrieving outbound session after deletion") + assert.Nil(t, sess, "Got outbound session after deleting") + }) + } +} + +func TestStoreOutboundMegolmSessionSharing(t *testing.T) { + stores := getCryptoStores(t) + + resetDevice := func() *id.Device { + acc := NewOlmAccount() + return &id.Device{ + UserID: "user1", + DeviceID: id.DeviceID("dev1"), + IdentityKey: acc.IdentityKey(), + SigningKey: acc.SigningKey(), + } + } + + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + device := resetDevice() + err := store.PutDevice(context.TODO(), "user1", device) + require.NoError(t, err, "Error storing device") + + shared, err := store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") + require.NoError(t, err, "Error checking if outbound group session is shared") + assert.False(t, shared, "Outbound group session should not be shared initially") + + err = store.MarkOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") + require.NoError(t, err, "Error marking outbound group session as shared") + + shared, err = store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") + require.NoError(t, err, "Error checking if outbound group session is shared") + assert.True(t, shared, "Outbound group session should be shared after marking it as such") + + device = resetDevice() + err = store.PutDevice(context.TODO(), "user1", device) + require.NoError(t, err, "Error storing device after resetting") + + shared, err = store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") + require.NoError(t, err, "Error checking if outbound group session is shared") + assert.False(t, shared, "Outbound group session should not be shared after resetting device") + }) + } +} + +func TestStoreDevices(t *testing.T) { + devicesToCreate := 17 + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + outdated, err := store.GetOutdatedTrackedUsers(context.TODO()) + require.NoError(t, err, "Error filtering tracked users") + assert.Empty(t, outdated, "Expected no outdated tracked users initially") + + deviceMap := make(map[id.DeviceID]*id.Device) + for i := 0; i < devicesToCreate; i++ { + iStr := strconv.Itoa(i) + acc := NewOlmAccount() + deviceMap[id.DeviceID("dev"+iStr)] = &id.Device{ + UserID: "user1", + DeviceID: id.DeviceID("dev" + iStr), + IdentityKey: acc.IdentityKey(), + SigningKey: acc.SigningKey(), + } + } + err = store.PutDevices(context.TODO(), "user1", deviceMap) + require.NoError(t, err, "Error storing devices") + devs, err := store.GetDevices(context.TODO(), "user1") + require.NoError(t, err, "Error getting devices") + assert.Len(t, devs, devicesToCreate, "Expected to get %d devices back", devicesToCreate) + assert.Equal(t, deviceMap, devs, "Stored devices do not match retrieved devices") + + filtered, err := store.FilterTrackedUsers(context.TODO(), []id.UserID{"user0", "user1", "user2"}) + require.NoError(t, err, "Error filtering tracked users") + assert.Equal(t, []id.UserID{"user1"}, filtered, "Expected to get 'user1' from filter") + + outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) + require.NoError(t, err, "Error filtering tracked users") + assert.Empty(t, outdated, "Expected no outdated tracked users after initial storage") + + err = store.MarkTrackedUsersOutdated(context.TODO(), []id.UserID{"user0", "user1"}) + require.NoError(t, err, "Error marking tracked users outdated") + + outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) + require.NoError(t, err, "Error filtering tracked users") + assert.Equal(t, []id.UserID{"user1"}, outdated, "Expected 'user1' to be marked as outdated") + + err = store.PutDevices(context.TODO(), "user1", deviceMap) + require.NoError(t, err, "Error storing devices again") + + outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) + require.NoError(t, err, "Error filtering tracked users") + assert.Empty(t, outdated, "Expected no outdated tracked users after re-storing devices") + }) + } +} + +func TestStoreSecrets(t *testing.T) { + stores := getCryptoStores(t) + for storeName, store := range stores { + t.Run(storeName, func(t *testing.T) { + storedSecret := "trustno1" + err := store.PutSecret(context.TODO(), id.SecretMegolmBackupV1, storedSecret) + require.NoError(t, err, "Error storing secret") + + secret, err := store.GetSecret(context.TODO(), id.SecretMegolmBackupV1) + require.NoError(t, err, "Error retrieving secret") + assert.Equal(t, storedSecret, secret, "Retrieved secret does not match stored secret") + }) + } +} diff --git a/mautrix-patched/crypto/utils/utils.go b/mautrix-patched/crypto/utils/utils.go new file mode 100644 index 00000000..e2f8a19c --- /dev/null +++ b/mautrix-patched/crypto/utils/utils.go @@ -0,0 +1,132 @@ +// Copyright (c) 2020 Nikos Filippakis +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "strings" + + "go.mau.fi/util/base58" + "golang.org/x/crypto/hkdf" + "golang.org/x/crypto/pbkdf2" +) + +const ( + // AESCTRKeyLength is the length of the AES256-CTR key used. + AESCTRKeyLength = 32 + // AESCTRIVLength is the length of the AES256-CTR IV used. + AESCTRIVLength = 16 + // HMACKeyLength is the length of the HMAC key used. + HMACKeyLength = 32 + // SHAHashLength is the length of the SHA hash used. + SHAHashLength = 32 +) + +// XorA256CTR encrypts the input with the keystream generated by the AES256-CTR algorithm with the given arguments. +func XorA256CTR(source []byte, key [AESCTRKeyLength]byte, iv [AESCTRIVLength]byte) []byte { + block, _ := aes.NewCipher(key[:]) + cipher.NewCTR(block, iv[:]).XORKeyStream(source, source) + return source +} + +// GenAttachmentA256CTR generates a new random AES256-CTR key and IV suitable for encrypting attachments. +func GenAttachmentA256CTR() (key [AESCTRKeyLength]byte, iv [AESCTRIVLength]byte) { + _, err := rand.Read(key[:]) + if err != nil { + panic(err) + } + + // The last 8 bytes of the IV act as the counter in AES-CTR, which means they're left empty here + _, err = rand.Read(iv[:8]) + if err != nil { + panic(err) + } + return +} + +// GenA256CTRIV generates a random IV for AES256-CTR with the last bit set to zero. +func GenA256CTRIV() (iv [AESCTRIVLength]byte) { + _, err := rand.Read(iv[:]) + if err != nil { + panic(err) + } + iv[8] &= 0x7F + return +} + +// DeriveKeysSHA256 derives an AES and a HMAC key from the given recovery key. +func DeriveKeysSHA256(key []byte, name string) ([AESCTRKeyLength]byte, [HMACKeyLength]byte) { + var zeroBytes [32]byte + + derivedHkdf := hkdf.New(sha256.New, key[:], zeroBytes[:], []byte(name)) + + var aesKey [AESCTRKeyLength]byte + var hmacKey [HMACKeyLength]byte + derivedHkdf.Read(aesKey[:]) + derivedHkdf.Read(hmacKey[:]) + + return aesKey, hmacKey +} + +// PBKDF2SHA512 generates a key of the given bit-length using the given passphrase, salt and iteration count. +func PBKDF2SHA512(password []byte, salt []byte, iters int, keyLenBits int) []byte { + return pbkdf2.Key(password, salt, iters, keyLenBits/8, sha512.New) +} + +// DecodeBase58RecoveryKey recovers the secret storage from a recovery key. +func DecodeBase58RecoveryKey(recoveryKey string) []byte { + noSpaces := strings.ReplaceAll(recoveryKey, " ", "") + decoded := base58.Decode(noSpaces) + if len(decoded) != AESCTRKeyLength+3 { // AESCTRKeyLength bytes key and 3 bytes prefix / parity + return nil + } + var parity byte + for _, b := range decoded[:34] { + parity ^= b + } + if parity != decoded[34] || decoded[0] != 0x8B || decoded[1] != 1 { + return nil + } + return decoded[2:34] +} + +// EncodeBase58RecoveryKey recovers the secret storage from a recovery key. +func EncodeBase58RecoveryKey(key []byte) string { + var inputBytes [35]byte + copy(inputBytes[2:34], key[:]) + inputBytes[0] = 0x8B + inputBytes[1] = 1 + + var parity byte + for _, b := range inputBytes[:34] { + parity ^= b + } + inputBytes[34] = parity + recoveryKey := base58.Encode(inputBytes[:]) + + var spacedKey string + for i, c := range recoveryKey { + if i > 0 && i%4 == 0 { + spacedKey += " " + } + spacedKey += string(c) + } + return spacedKey +} + +// HMACSHA256B64 calculates the unpadded base64 of the SHA256 hmac of the input with the given key. +func HMACSHA256B64(input []byte, hmacKey [HMACKeyLength]byte) string { + h := hmac.New(sha256.New, hmacKey[:]) + h.Write(input) + return base64.RawStdEncoding.EncodeToString(h.Sum(nil)) +} diff --git a/mautrix-patched/crypto/utils/utils_test.go b/mautrix-patched/crypto/utils/utils_test.go new file mode 100644 index 00000000..b12fd9e2 --- /dev/null +++ b/mautrix-patched/crypto/utils/utils_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2020 Nikos Filippakis +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package utils + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAES256Ctr(t *testing.T) { + expected := "Hello world" + key, iv := GenAttachmentA256CTR() + enc := XorA256CTR([]byte(expected), key, iv) + dec := XorA256CTR(enc, key, iv) + assert.EqualValues(t, expected, dec, "Decrypted text should match original") + + var key2 [AESCTRKeyLength]byte + var iv2 [AESCTRIVLength]byte + for i := 0; i < AESCTRKeyLength; i++ { + key2[i] = byte(i) + } + for i := 0; i < AESCTRIVLength; i++ { + iv2[i] = byte(i) + 32 + } + dec2 := XorA256CTR([]byte{0x29, 0xc3, 0xff, 0x02, 0x21, 0xaf, 0x67, 0x73, 0x6e, 0xad, 0x9d}, key2, iv2) + assert.EqualValues(t, expected, dec2, "Decrypted text with constant key/iv should match original") +} + +func TestPBKDF(t *testing.T) { + salt := make([]byte, 16) + for i := 0; i < 16; i++ { + salt[i] = byte(i) + } + key := PBKDF2SHA512([]byte("Hello world"), salt, 1000, 256) + expected := "ffk9YdbVE1cgqOWgDaec0lH+rJzO+MuCcxpIn3Z6D0E=" + keyB64 := base64.StdEncoding.EncodeToString([]byte(key)) + assert.Equal(t, expected, keyB64) +} + +func TestDecodeSSSSKey(t *testing.T) { + recoveryKey := "EsTL 2cTx 9Qy1 8TVd qGsn GDrD i5dT EEuX Qz8U P7hi Z7uu U8wZ" + decoded := DecodeBase58RecoveryKey(recoveryKey) + + expected := "QCFDrXZYLEFnwf4NikVm62rYGJS2mNBEmAWLC3CgNPw=" + decodedB64 := base64.StdEncoding.EncodeToString(decoded[:]) + assert.Equal(t, expected, decodedB64) + + encoded := EncodeBase58RecoveryKey(decoded) + assert.Equal(t, recoveryKey, encoded) +} + +func TestKeyDerivationAndHMAC(t *testing.T) { + recoveryKey := "EsUG Ddi6 e1Cm F4um g38u JN72 d37v Q2ry qCf2 rKgL E2MQ ZQz6" + decoded := DecodeBase58RecoveryKey(recoveryKey) + + aesKey, hmacKey := DeriveKeysSHA256(decoded[:], "m.cross_signing.master") + + ciphertextBytes, err := base64.StdEncoding.DecodeString("Fx16KlJ9vkd3Dd6CafIq5spaH5QmK5BALMzbtFbQznG2j1VARKK+klc4/Qo=") + require.NoError(t, err) + + calcMac := HMACSHA256B64(ciphertextBytes, hmacKey) + expectedMac := "0DABPNIZsP9iTOh1o6EM0s7BfHHXb96dN7Eca88jq2E" + assert.Equal(t, expectedMac, calcMac) + + var ivBytes [AESCTRIVLength]byte + decodedIV, _ := base64.StdEncoding.DecodeString("zxT/W5LpZ0Q819pfju6hZw==") + copy(ivBytes[:], decodedIV) + decrypted := string(XorA256CTR(ciphertextBytes, aesKey, ivBytes)) + + expectedDec := "Ec8eZDyvVkO3EDsEG6ej5c0cCHnX7PINqFXZjnaTV2s=" + assert.Equal(t, expectedDec, decrypted) +} diff --git a/mautrix-patched/crypto/verificationhelper/callbacks_test.go b/mautrix-patched/crypto/verificationhelper/callbacks_test.go new file mode 100644 index 00000000..3b943f28 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/callbacks_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper_test + +import ( + "context" + + "maunium.net/go/mautrix/crypto/verificationhelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type MockVerificationCallbacks interface { + GetRequestedVerifications() map[id.UserID][]id.VerificationTransactionID + GetScanQRCodeTransactions() []id.VerificationTransactionID + GetVerificationsReadyTransactions() []id.VerificationTransactionID + GetQRCodeShown(id.VerificationTransactionID) *verificationhelper.QRCode +} + +type baseVerificationCallbacks struct { + scanQRCodeTransactions []id.VerificationTransactionID + verificationsRequested map[id.UserID][]id.VerificationTransactionID + verificationsReady []id.VerificationTransactionID + qrCodesShown map[id.VerificationTransactionID]*verificationhelper.QRCode + qrCodesScanned map[id.VerificationTransactionID]struct{} + doneTransactions map[id.VerificationTransactionID]struct{} + verificationCancellation map[id.VerificationTransactionID]*event.VerificationCancelEventContent + emojisShown map[id.VerificationTransactionID][]rune + emojiDescriptionsShown map[id.VerificationTransactionID][]string + decimalsShown map[id.VerificationTransactionID][]int +} + +var _ verificationhelper.RequiredCallbacks = (*baseVerificationCallbacks)(nil) +var _ MockVerificationCallbacks = (*baseVerificationCallbacks)(nil) + +func newBaseVerificationCallbacks() *baseVerificationCallbacks { + return &baseVerificationCallbacks{ + verificationsRequested: map[id.UserID][]id.VerificationTransactionID{}, + qrCodesShown: map[id.VerificationTransactionID]*verificationhelper.QRCode{}, + qrCodesScanned: map[id.VerificationTransactionID]struct{}{}, + doneTransactions: map[id.VerificationTransactionID]struct{}{}, + verificationCancellation: map[id.VerificationTransactionID]*event.VerificationCancelEventContent{}, + emojisShown: map[id.VerificationTransactionID][]rune{}, + emojiDescriptionsShown: map[id.VerificationTransactionID][]string{}, + decimalsShown: map[id.VerificationTransactionID][]int{}, + } +} + +func (c *baseVerificationCallbacks) GetRequestedVerifications() map[id.UserID][]id.VerificationTransactionID { + return c.verificationsRequested +} + +func (c *baseVerificationCallbacks) GetScanQRCodeTransactions() []id.VerificationTransactionID { + return c.scanQRCodeTransactions +} + +func (c *baseVerificationCallbacks) GetVerificationsReadyTransactions() []id.VerificationTransactionID { + return c.verificationsReady +} + +func (c *baseVerificationCallbacks) GetQRCodeShown(txnID id.VerificationTransactionID) *verificationhelper.QRCode { + return c.qrCodesShown[txnID] +} + +func (c *baseVerificationCallbacks) WasOurQRCodeScanned(txnID id.VerificationTransactionID) bool { + _, ok := c.qrCodesScanned[txnID] + return ok +} + +func (c *baseVerificationCallbacks) IsVerificationDone(txnID id.VerificationTransactionID) bool { + _, ok := c.doneTransactions[txnID] + return ok +} + +func (c *baseVerificationCallbacks) GetVerificationCancellation(txnID id.VerificationTransactionID) *event.VerificationCancelEventContent { + return c.verificationCancellation[txnID] +} + +func (c *baseVerificationCallbacks) GetEmojisAndDescriptionsShown(txnID id.VerificationTransactionID) ([]rune, []string) { + return c.emojisShown[txnID], c.emojiDescriptionsShown[txnID] +} + +func (c *baseVerificationCallbacks) GetDecimalsShown(txnID id.VerificationTransactionID) []int { + return c.decimalsShown[txnID] +} + +func (c *baseVerificationCallbacks) VerificationRequested(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) { + c.verificationsRequested[from] = append(c.verificationsRequested[from], txnID) +} + +func (c *baseVerificationCallbacks) VerificationReady(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, allowScanQRCode bool, qrCode *verificationhelper.QRCode) { + c.verificationsReady = append(c.verificationsReady, txnID) + if allowScanQRCode { + c.scanQRCodeTransactions = append(c.scanQRCodeTransactions, txnID) + } + if qrCode != nil { + c.qrCodesShown[txnID] = qrCode + } +} + +func (c *baseVerificationCallbacks) VerificationCancelled(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) { + c.verificationCancellation[txnID] = &event.VerificationCancelEventContent{ + Code: code, + Reason: reason, + } +} + +func (c *baseVerificationCallbacks) VerificationDone(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) { + c.doneTransactions[txnID] = struct{}{} +} + +type sasVerificationCallbacks struct { + *baseVerificationCallbacks +} + +var _ verificationhelper.ShowSASCallbacks = (*sasVerificationCallbacks)(nil) + +func newSASVerificationCallbacks() *sasVerificationCallbacks { + return &sasVerificationCallbacks{newBaseVerificationCallbacks()} +} + +func newSASVerificationCallbacksWithBase(base *baseVerificationCallbacks) *sasVerificationCallbacks { + return &sasVerificationCallbacks{base} +} + +func (c *sasVerificationCallbacks) ShowSAS(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) { + c.emojisShown[txnID] = emojis + c.emojiDescriptionsShown[txnID] = emojiDescriptions + c.decimalsShown[txnID] = decimals +} + +type showQRCodeVerificationCallbacks struct { + *baseVerificationCallbacks +} + +var _ verificationhelper.ShowQRCodeCallbacks = (*showQRCodeVerificationCallbacks)(nil) + +func newShowQRCodeVerificationCallbacks() *showQRCodeVerificationCallbacks { + return &showQRCodeVerificationCallbacks{newBaseVerificationCallbacks()} +} + +func newShowQRCodeVerificationCallbacksWithBase(base *baseVerificationCallbacks) *showQRCodeVerificationCallbacks { + return &showQRCodeVerificationCallbacks{base} +} + +func (c *showQRCodeVerificationCallbacks) QRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) { + c.qrCodesScanned[txnID] = struct{}{} +} + +type allVerificationCallbacks struct { + *baseVerificationCallbacks + *sasVerificationCallbacks + *showQRCodeVerificationCallbacks +} + +func newAllVerificationCallbacks() *allVerificationCallbacks { + base := newBaseVerificationCallbacks() + return &allVerificationCallbacks{ + base, + newSASVerificationCallbacksWithBase(base), + newShowQRCodeVerificationCallbacksWithBase(base), + } +} diff --git a/mautrix-patched/crypto/verificationhelper/doc.go b/mautrix-patched/crypto/verificationhelper/doc.go new file mode 100644 index 00000000..29931654 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/doc.go @@ -0,0 +1,11 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package verificationhelper provides a helper for the interactive +// verification process according to [Section 11.12.2] of the Spec. +// +// [Section 11.12.2]: https://spec.matrix.org/v1.9/client-server-api/#device-verification +package verificationhelper diff --git a/mautrix-patched/crypto/verificationhelper/ecdhkeys.go b/mautrix-patched/crypto/verificationhelper/ecdhkeys.go new file mode 100644 index 00000000..754530ed --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/ecdhkeys.go @@ -0,0 +1,57 @@ +package verificationhelper + +import ( + "crypto/ecdh" + "encoding/json" +) + +type ECDHPrivateKey struct { + *ecdh.PrivateKey +} + +func (e *ECDHPrivateKey) UnmarshalJSON(data []byte) (err error) { + if len(data) == 0 { + return nil + } + var raw []byte + err = json.Unmarshal(data, &raw) + if err != nil { + return + } + if len(raw) == 0 { + return nil + } + e.PrivateKey, err = ecdh.X25519().NewPrivateKey(raw) + return err +} + +func (e ECDHPrivateKey) MarshalJSON() ([]byte, error) { + if e.PrivateKey == nil { + return json.Marshal(nil) + } + return json.Marshal(e.Bytes()) +} + +type ECDHPublicKey struct { + *ecdh.PublicKey +} + +func (e *ECDHPublicKey) UnmarshalJSON(data []byte) (err error) { + if len(data) == 0 { + return nil + } + var raw []byte + err = json.Unmarshal(data, &raw) + if err != nil { + return + } + if len(raw) == 0 { + return nil + } + e.PublicKey, err = ecdh.X25519().NewPublicKey(raw) + return +} + +func (e ECDHPublicKey) MarshalJSON() ([]byte, error) { + return json.Marshal(e.Bytes()) +} diff --git a/mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go b/mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go new file mode 100644 index 00000000..109fbf88 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go @@ -0,0 +1,48 @@ +package verificationhelper_test + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/verificationhelper" +) + +func TestECDHPrivateKey(t *testing.T) { + pk, err := ecdh.X25519().GenerateKey(rand.Reader) + require.NoError(t, err) + private := verificationhelper.ECDHPrivateKey{pk} + marshalled, err := json.Marshal(private) + require.NoError(t, err) + + assert.Len(t, marshalled, 46) + + var unmarshalled verificationhelper.ECDHPrivateKey + err = json.Unmarshal(marshalled, &unmarshalled) + require.NoError(t, err) + + assert.True(t, private.Equal(unmarshalled.PrivateKey)) +} + +func TestECDHPublicKey(t *testing.T) { + private, err := ecdh.X25519().GenerateKey(rand.Reader) + require.NoError(t, err) + + public := private.PublicKey() + + pub := verificationhelper.ECDHPublicKey{public} + marshalled, err := json.Marshal(pub) + require.NoError(t, err) + + assert.Len(t, marshalled, 46) + + var unmarshalled verificationhelper.ECDHPublicKey + err = json.Unmarshal(marshalled, &unmarshalled) + require.NoError(t, err) + + assert.True(t, public.Equal(unmarshalled.PublicKey)) +} diff --git a/mautrix-patched/crypto/verificationhelper/qrcode.go b/mautrix-patched/crypto/verificationhelper/qrcode.go new file mode 100644 index 00000000..3f38d97c --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/qrcode.go @@ -0,0 +1,121 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "go.mau.fi/util/random" + + "maunium.net/go/mautrix/id" +) + +var ( + ErrInvalidQRCodeHeader = errors.New("invalid QR code header") + ErrUnknownQRCodeVersion = errors.New("invalid QR code version") + ErrInvalidQRCodeMode = errors.New("invalid QR code mode") + ErrInvalidQRCodeLength = errors.New("QR data too short") +) + +type QRCodeMode byte + +const ( + QRCodeModeCrossSigning QRCodeMode = 0x00 + QRCodeModeSelfVerifyingMasterKeyTrusted QRCodeMode = 0x01 + QRCodeModeSelfVerifyingMasterKeyUntrusted QRCodeMode = 0x02 +) + +const MinQRSharedSecretLength = 8 + +type QRCode struct { + Mode QRCodeMode + TransactionID id.VerificationTransactionID + Key1, Key2 [32]byte + SharedSecret []byte +} + +func NewQRCode(mode QRCodeMode, txnID id.VerificationTransactionID, key1, key2 [32]byte) *QRCode { + return &QRCode{ + Mode: mode, + TransactionID: txnID, + Key1: key1, + Key2: key2, + SharedSecret: random.Bytes(16), + } +} + +const qrHeaderString = "MATRIX" +const minQRLength = len(qrHeaderString) + 4 + 2*32 + MinQRSharedSecretLength + +var qrHeader = []byte(qrHeaderString) + +// NewQRCodeFromBytes parses the bytes from a QR code scan as defined in +// [Section 11.12.2.4.1] of the Spec. +// +// [Section 11.12.2.4.1]: https://spec.matrix.org/v1.9/client-server-api/#qr-code-format +func NewQRCodeFromBytes(data []byte) (*QRCode, error) { + if !bytes.HasPrefix(data, qrHeader) { + return nil, ErrInvalidQRCodeHeader + } + if len(data) > 6 && data[6] != 0x02 { + return nil, ErrUnknownQRCodeVersion + } + if len(data) < minQRLength { + return nil, fmt.Errorf("%w: expected at least %d bytes, got %d", ErrInvalidQRCodeLength, minQRLength, len(data)) + } + if data[7] != 0x00 && data[7] != 0x01 && data[7] != 0x02 { + return nil, ErrInvalidQRCodeMode + } + transactionIDLength := binary.BigEndian.Uint16(data[8:10]) + if len(data) < int(10+transactionIDLength+64) { + return nil, fmt.Errorf("%w for transaction ID: expected at least %d bytes, got %d", ErrInvalidQRCodeLength, 10+transactionIDLength+64, len(data)) + } + transactionID := data[10 : 10+transactionIDLength] + + var key1, key2 [32]byte + copy(key1[:], data[10+transactionIDLength:10+transactionIDLength+32]) + copy(key2[:], data[10+transactionIDLength+32:10+transactionIDLength+64]) + sharedSecret := data[10+transactionIDLength+64:] + if len(sharedSecret) < MinQRSharedSecretLength { + return nil, fmt.Errorf("%w: expected at least %d bytes for shared secret, got %d", ErrInvalidQRCodeLength, MinQRSharedSecretLength, len(sharedSecret)) + } + + return &QRCode{ + Mode: QRCodeMode(data[7]), + TransactionID: id.VerificationTransactionID(transactionID), + Key1: key1, + Key2: key2, + SharedSecret: sharedSecret, + }, nil +} + +// Bytes returns the bytes that need to be encoded in the QR code as defined in +// [Section 11.12.2.4.1] of the Spec. +// +// [Section 11.12.2.4.1]: https://spec.matrix.org/v1.9/client-server-api/#qr-code-format +func (q *QRCode) Bytes() []byte { + if q == nil { + return nil + } + + var buf bytes.Buffer + buf.WriteString("MATRIX") // Header + buf.WriteByte(0x02) // Version + buf.WriteByte(byte(q.Mode)) // Mode + + // Transaction ID length + Transaction ID + buf.Write(binary.BigEndian.AppendUint16(nil, uint16(len(q.TransactionID.String())))) + buf.WriteString(q.TransactionID.String()) + + buf.Write(q.Key1[:]) // Key 1 + buf.Write(q.Key2[:]) // Key 2 + buf.Write(q.SharedSecret) // Shared secret + return buf.Bytes() +} diff --git a/mautrix-patched/crypto/verificationhelper/qrcode_test.go b/mautrix-patched/crypto/verificationhelper/qrcode_test.go new file mode 100644 index 00000000..0e9d8520 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/qrcode_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper_test + +import ( + "bytes" + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/crypto/verificationhelper" + "maunium.net/go/mautrix/id" +) + +func TestQRCode_Roundtrip(t *testing.T) { + var key1, key2 [32]byte + copy(key1[:], bytes.Repeat([]byte{0x01}, 32)) + copy(key2[:], bytes.Repeat([]byte{0x02}, 32)) + txnID := id.VerificationTransactionID(strings.Repeat("a", 20)) + qrCode := verificationhelper.NewQRCode(verificationhelper.QRCodeModeCrossSigning, txnID, key1, key2) + + encoded := qrCode.Bytes() + decoded, err := verificationhelper.NewQRCodeFromBytes(encoded) + require.NoError(t, err) + + assert.Equal(t, verificationhelper.QRCodeModeCrossSigning, decoded.Mode) + assert.EqualValues(t, txnID, decoded.TransactionID) + assert.Equal(t, key1, decoded.Key1) + assert.Equal(t, key2, decoded.Key2) +} + +func TestQRCodeDecode(t *testing.T) { + testCases := []struct { + b64 string + txnID string + key1 string + key2 string + sharedSecret string + }{ + { + "TUFUUklYAgEAIEduQWVDdnRXanpNT1ZXUVRrdDM1WVJVcnVqbVJQYzhhGDJ8w4zCpsK1wqdQV2cZXsOvwqDCmMKdNsOtehAuGD5Ow4TDgUUMwq4ZeMKZBsKSwpTCjsK3WcKWwq3DvXBqEcK6wqkpw48NwrjCiGdbw7MBwrBjLsKlw7Ngw4IEw6NyfXwdwrbCusKBHsKZwrh/Cg==", + "GnAeCvtWjzMOVWQTkt35YRUrujmRPc8a", + "GDJ8w4zCpsK1wqdQV2cZXsOvwqDCmMKdNsOtehAuGD4=", + "TsOEw4FFDMKuGXjCmQbCksKUwo7Ct1nClsKtw71wahE=", + "wrrCqSnDjw3CuMKIZ1vDswHCsGMuwqXDs2DDggTDo3J9fB3CtsK6woEewpnCuH8K", + }, + { + "TUFUUklYAgEAIGM1YjljNzE3ZWIzYjRmYzBiZDhhZjA0MDQ4NDY5MDdle4oLkpUdO1cTu5M3K3B4BlnpxtAbVgXCuQKOIqMmt+xAjVvaEXF39X0z5waRY9UE0b5PKiWvOBSJHEGkxX28Y2OEDLIWP/kCVUlyXXENlj0=", + "c5b9c717eb3b4fc0bd8af0404846907e", + "e4oLkpUdO1cTu5M3K3B4BlnpxtAbVgXCuQKOIqMmt+w=", + "QI1b2hFxd/V9M+cGkWPVBNG+TyolrzgUiRxBpMV9vGM=", + "Y4QMshY/+QJVSXJdcQ2WPQ==", + }, + } + + for _, tc := range testCases { + t.Run(tc.b64, func(t *testing.T) { + qrcodeData, err := base64.StdEncoding.DecodeString(tc.b64) + require.NoError(t, err) + expectedKey1, err := base64.StdEncoding.DecodeString(tc.key1) + require.NoError(t, err) + expectedKey2, err := base64.StdEncoding.DecodeString(tc.key2) + require.NoError(t, err) + expectedSharedSecret, err := base64.StdEncoding.DecodeString(tc.sharedSecret) + require.NoError(t, err) + + decoded, err := verificationhelper.NewQRCodeFromBytes(qrcodeData) + require.NoError(t, err) + assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyTrusted, decoded.Mode) + assert.EqualValues(t, tc.txnID, decoded.TransactionID) + assert.EqualValues(t, expectedKey1, decoded.Key1) + assert.EqualValues(t, expectedKey2, decoded.Key2) + assert.EqualValues(t, expectedSharedSecret, decoded.SharedSecret) + + // Test that truncating the shared secret causes an error up to the point + // where we have 8 bytes of the shared secret (secret length isn't validated) + for i := 6; i < len(qrcodeData)-len(expectedSharedSecret)+7; i++ { + _, err := verificationhelper.NewQRCodeFromBytes(qrcodeData[:i]) + assert.ErrorIs(t, err, verificationhelper.ErrInvalidQRCodeLength) + } + }) + } +} diff --git a/mautrix-patched/crypto/verificationhelper/reciprocate.go b/mautrix-patched/crypto/verificationhelper/reciprocate.go new file mode 100644 index 00000000..10658ba6 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/reciprocate.go @@ -0,0 +1,353 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper + +import ( + "bytes" + "context" + "errors" + "fmt" + + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// HandleScannedQRData verifies the keys from a scanned QR code and if +// successful, sends the m.key.verification.start event and +// m.key.verification.done event. +func (vh *VerificationHelper) HandleScannedQRData(ctx context.Context, data []byte) error { + qrCode, err := NewQRCodeFromBytes(data) + if err != nil { + return err + } + log := vh.getLog(ctx).With(). + Str("verification_action", "handle scanned QR data"). + Stringer("transaction_id", qrCode.TransactionID). + Int("mode", int(qrCode.Mode)). + Logger() + ctx = log.WithContext(ctx) + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + txn, err := vh.store.GetVerificationTransaction(ctx, qrCode.TransactionID) + if err != nil { + return fmt.Errorf("failed to get transaction %s: %w", qrCode.TransactionID, err) + } else if txn.VerificationState != VerificationStateReady { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "transaction found in the QR code is not in the ready state") + } + txn.VerificationState = VerificationStateTheirQRScanned + + // Verify the keys + log.Info().Msg("Verifying keys from QR code") + + ownCrossSigningPublicKeys, err := vh.mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } else if ownCrossSigningPublicKeys == nil { + return crypto.ErrCrossSigningPubkeysNotCached + } + + switch qrCode.Mode { + case QRCodeModeCrossSigning: + theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) + if err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "couldn't get %s's cross-signing keys: %w", txn.TheirUserID, err) + } + if bytes.Equal(theirSigningKeys.MasterKey.Bytes(), qrCode.Key1[:]) { + log.Info().Msg("Verified that the other device has the master key we expected") + } else { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the other device does not have the master key we expected") + } + + // Verify the master key is correct + if bytes.Equal(ownCrossSigningPublicKeys.MasterKey.Bytes(), qrCode.Key2[:]) { + log.Info().Msg("Verified that the other device has the same master key") + } else { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the master key does not match") + } + + if err := vh.mach.SignUser(ctx, txn.TheirUserID, theirSigningKeys.MasterKey); err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign their master key: %w", err) + } + case QRCodeModeSelfVerifyingMasterKeyTrusted: + // The QR was created by a device that trusts the master key, which + // means that we don't trust the key. Key1 is the master key public + // key, and Key2 is what the other device thinks our device key is. + + if vh.client.UserID != txn.TheirUserID { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "mode %d is only allowed when the other user is the same as the current user", qrCode.Mode) + } + + // Verify the master key is correct + if bytes.Equal(ownCrossSigningPublicKeys.MasterKey.Bytes(), qrCode.Key1[:]) { + log.Info().Msg("Verified that the other device has the same master key") + } else { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the master key does not match") + } + + // Verify that the device key that the other device things we have is + // correct. + myKeys := vh.mach.OwnIdentity() + if bytes.Equal(myKeys.SigningKey.Bytes(), qrCode.Key2[:]) { + log.Info().Msg("Verified that the other device has the correct key for this device") + } else { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the other device has the wrong key for this device") + } + + if err := vh.mach.SignOwnMasterKey(ctx); err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign own master key: %w", err) + } + case QRCodeModeSelfVerifyingMasterKeyUntrusted: + // The QR was created by a device that does not trust the master key, + // which means that we do trust the master key. Key1 is the other + // device's device key, and Key2 is what the other device thinks the + // master key is. + + // Check that we actually trust the master key. + if trusted, err := vh.mach.CryptoStore.IsKeySignedBy(ctx, vh.client.UserID, ownCrossSigningPublicKeys.MasterKey, vh.client.UserID, vh.mach.OwnIdentity().SigningKey); err != nil { + return err + } else if !trusted { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeMasterKeyNotTrusted, "the master key is not trusted by this device, cannot verify device that does not trust the master key") + } + + if vh.client.UserID != txn.TheirUserID { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "mode %d is only allowed when the other user is the same as the current user", qrCode.Mode) + } + + // Get their device + theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) + if err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to get their device: %w", err) + } + + // Verify that the other device's key is what we expect. + if bytes.Equal(theirDevice.SigningKey.Bytes(), qrCode.Key1[:]) { + log.Info().Msg("Verified that the other device key is what we expected") + } else { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the other device's key is not what we expected") + } + + // Verify that what they think the master key is is correct. + if bytes.Equal(ownCrossSigningPublicKeys.MasterKey.Bytes(), qrCode.Key2[:]) { + log.Info().Msg("Verified that the other device has the correct master key") + } else { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the master key does not match") + } + + // Trust their device + theirDevice.Trust = id.TrustStateVerified + err = vh.mach.CryptoStore.PutDevice(ctx, txn.TheirUserID, theirDevice) + if err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to update device trust state after verifying: %+v", err) + } + + // Cross-sign their device with the self-signing key + err = vh.mach.SignOwnDevice(ctx, theirDevice) + if err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign their device: %+v", err) + } + default: + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "unknown QR code mode %d", qrCode.Mode) + } + + // Send a m.key.verification.start event with the secret + txn.StartedByUs = true + txn.StartEventContent = &event.VerificationStartEventContent{ + FromDevice: vh.client.DeviceID, + Method: event.VerificationMethodReciprocate, + Secret: qrCode.SharedSecret, + } + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationStart, txn.StartEventContent) + if err != nil { + return fmt.Errorf("failed to send m.key.verification.start event: %w", err) + } + log.Debug().Msg("Successfully sent the m.key.verification.start event") + + // Immediately send the m.key.verification.done event, as our side of the + // transaction is done. + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) + if err != nil { + return fmt.Errorf("failed to send m.key.verification.done event: %w", err) + } + log.Debug().Msg("Successfully sent the m.key.verification.done event") + txn.SentOurDone = true + if txn.ReceivedTheirDone { + log.Debug().Msg("We already received their done event. Setting verification state to done.") + if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { + return err + } + vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) + } else { + return vh.store.SaveVerificationTransaction(ctx, txn) + } + return nil +} + +// ConfirmQRCodeScanned confirms that our QR code has been scanned and sends +// the m.key.verification.done event to the other device for the given +// transaction ID. The transaction ID should be one received via the +// VerificationRequested callback in [RequiredCallbacks] or the +// [StartVerification] or [StartInRoomVerification] functions. +func (vh *VerificationHelper) ConfirmQRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) error { + log := vh.getLog(ctx).With(). + Str("verification_action", "confirm QR code scanned"). + Stringer("transaction_id", txnID). + Logger() + ctx = log.WithContext(ctx) + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + txn, err := vh.store.GetVerificationTransaction(ctx, txnID) + if err != nil { + return fmt.Errorf("failed to get transaction %s: %w", txnID, err) + } else if txn.VerificationState != VerificationStateOurQRScanned { + return fmt.Errorf("transaction is not in the scanned state") + } + + log.Info().Msg("Confirming QR code scanned") + + // Get their device + theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) + if err != nil { + return err + } + + // Trust their device + theirDevice.Trust = id.TrustStateVerified + err = vh.mach.CryptoStore.PutDevice(ctx, txn.TheirUserID, theirDevice) + if err != nil { + return fmt.Errorf("failed to update device trust state after verifying: %w", err) + } + + if txn.TheirUserID == vh.client.UserID { + // Self-signing situation. + // + // If we have the cross-signing keys, then we need to sign their device + // using the self-signing key. Otherwise, they have the master private + // key, so we need to trust the master public key. + if vh.mach.CrossSigningKeys != nil { + err = vh.mach.SignOwnDevice(ctx, theirDevice) + if err != nil { + return fmt.Errorf("failed to sign our own new device: %w", err) + } + } else { + err = vh.mach.SignOwnMasterKey(ctx) + if err != nil { + return fmt.Errorf("failed to sign our own master key: %w", err) + } + } + } else { + // Cross-signing situation. Sign their master key. + theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) + if err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "couldn't get %s's cross-signing keys: %w", txn.TheirUserID, err) + } + + if err := vh.mach.SignUser(ctx, txn.TheirUserID, theirSigningKeys.MasterKey); err != nil { + return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign their master key: %w", err) + } + } + + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) + if err != nil { + return err + } + txn.SentOurDone = true + if txn.ReceivedTheirDone { + if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { + return err + } + vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) + } else { + return vh.store.SaveVerificationTransaction(ctx, txn) + } + return nil +} + +func (vh *VerificationHelper) generateQRCode(ctx context.Context, txn *VerificationTransaction) (*QRCode, error) { + log := vh.getLog(ctx).With(). + Str("verification_action", "generate and show QR code"). + Stringer("transaction_id", txn.TransactionID). + Logger() + ctx = log.WithContext(ctx) + + if !slices.Contains(vh.supportedMethods, event.VerificationMethodReciprocate) || + !slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodReciprocate) { + log.Info().Msg("Ignoring QR code generation request as reciprocating is not supported by both devices") + return nil, nil + } else if !slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodQRCodeScan) { + log.Info().Msg("Ignoring QR code generation request as other device cannot scan QR codes") + return nil, nil + } + + ownCrossSigningPublicKeys, err := vh.mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } else if ownCrossSigningPublicKeys == nil || len(ownCrossSigningPublicKeys.MasterKey) == 0 { + return nil, errors.New("failed to get own cross-signing master public key") + } + + ownMasterKeyTrusted, err := vh.mach.CryptoStore.IsKeySignedBy(ctx, vh.client.UserID, ownCrossSigningPublicKeys.MasterKey, vh.client.UserID, vh.mach.OwnIdentity().SigningKey) + if err != nil { + return nil, err + } + mode := QRCodeModeCrossSigning + if vh.client.UserID == txn.TheirUserID { + // This is a self-signing situation. + if ownMasterKeyTrusted { + mode = QRCodeModeSelfVerifyingMasterKeyTrusted + } else { + mode = QRCodeModeSelfVerifyingMasterKeyUntrusted + } + } else { + // This is a cross-signing situation. + if !ownMasterKeyTrusted { + return nil, errors.New("cannot cross-sign other device when own master key is not trusted") + } + mode = QRCodeModeCrossSigning + } + + var key1, key2 []byte + switch mode { + case QRCodeModeCrossSigning: + // Key 1 is the current user's master signing key. + key1 = ownCrossSigningPublicKeys.MasterKey.Bytes() + + // Key 2 is the other user's master signing key. + theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) + if err != nil { + return nil, err + } + key2 = theirSigningKeys.MasterKey.Bytes() + case QRCodeModeSelfVerifyingMasterKeyTrusted: + // Key 1 is the current user's master signing key. + key1 = ownCrossSigningPublicKeys.MasterKey.Bytes() + + // Key 2 is the other device's key. + theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) + if err != nil { + return nil, err + } + key2 = theirDevice.SigningKey.Bytes() + case QRCodeModeSelfVerifyingMasterKeyUntrusted: + // Key 1 is the current device's key + key1 = vh.mach.OwnIdentity().SigningKey.Bytes() + + // Key 2 is the master signing key. + key2 = ownCrossSigningPublicKeys.MasterKey.Bytes() + default: + log.Fatal().Int("mode", int(mode)).Msg("Unknown QR code mode") + } + + qrCode := NewQRCode(mode, txn.TransactionID, [32]byte(key1), [32]byte(key2)) + txn.QRCodeSharedSecret = qrCode.SharedSecret + return qrCode, nil +} diff --git a/mautrix-patched/crypto/verificationhelper/sas.go b/mautrix-patched/crypto/verificationhelper/sas.go new file mode 100644 index 00000000..f5f13773 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/sas.go @@ -0,0 +1,821 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper + +import ( + "bytes" + "context" + "crypto/ecdh" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + + "go.mau.fi/util/jsonbytes" + "golang.org/x/crypto/hkdf" + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// StartSAS starts a SAS verification flow for the given transaction ID. The +// transaction ID should be one received via the VerificationRequested callback +// in [RequiredCallbacks] or the [StartVerification] or +// [StartInRoomVerification] functions. +func (vh *VerificationHelper) StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error { + log := vh.getLog(ctx).With(). + Str("verification_action", "start SAS"). + Stringer("transaction_id", txnID). + Logger() + ctx = log.WithContext(ctx) + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + txn, err := vh.store.GetVerificationTransaction(ctx, txnID) + if err != nil { + return fmt.Errorf("failed to get verification transaction %s: %w", txnID, err) + } else if txn.VerificationState != VerificationStateReady { + return fmt.Errorf("transaction is not in ready state: %s", txn.VerificationState.String()) + } else if txn.StartEventContent != nil { + return errors.New("start event already sent or received") + } + + txn.VerificationState = VerificationStateSASStarted + txn.StartedByUs = true + if !slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodSAS) { + return fmt.Errorf("the other device does not support SAS verification") + } + + // Ensure that we have their device key. + _, err = vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) + if err != nil { + log.Err(err).Msg("Failed to fetch device") + return err + } + + log.Info().Msg("Sending start event") + startEventContent := event.VerificationStartEventContent{ + FromDevice: vh.client.DeviceID, + Method: event.VerificationMethodSAS, + + Hashes: []event.VerificationHashMethod{event.VerificationHashMethodSHA256}, + KeyAgreementProtocols: []event.KeyAgreementProtocol{event.KeyAgreementProtocolCurve25519HKDFSHA256}, + MessageAuthenticationCodes: []event.MACMethod{ + event.MACMethodHKDFHMACSHA256, + event.MACMethodHKDFHMACSHA256V2, + }, + ShortAuthenticationString: []event.SASMethod{ + event.SASMethodDecimal, + event.SASMethodEmoji, + }, + } + if err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationStart, &startEventContent); err != nil { + return err + } + txn.StartEventContent = &startEventContent + return vh.store.SaveVerificationTransaction(ctx, txn) +} + +// ConfirmSAS indicates that the user has confirmed that the SAS matches SAS +// shown on the other user's device for the given transaction ID. The +// transaction ID should be one received via the VerificationRequested callback +// in [RequiredCallbacks] or the [StartVerification] or +// [StartInRoomVerification] functions. +func (vh *VerificationHelper) ConfirmSAS(ctx context.Context, txnID id.VerificationTransactionID) error { + log := vh.getLog(ctx).With(). + Str("verification_action", "confirm SAS"). + Stringer("transaction_id", txnID). + Logger() + ctx = log.WithContext(ctx) + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + txn, err := vh.store.GetVerificationTransaction(ctx, txnID) + if err != nil { + return fmt.Errorf("failed to get transaction %s: %w", txnID, err) + } else if txn.VerificationState != VerificationStateSASKeysExchanged { + return errors.New("transaction is not in keys exchanged state") + } + + keys := map[id.KeyID]jsonbytes.UnpaddedBytes{} + + log.Info().Msg("Signing keys") + var masterKey string + + // My device key + myDevice := vh.mach.OwnIdentity() + myDeviceKeyID := id.NewKeyID(id.KeyAlgorithmEd25519, myDevice.DeviceID.String()) + keys[myDeviceKeyID], err = vh.verificationMACHKDF(txn, vh.client.UserID, vh.client.DeviceID, txn.TheirUserID, txn.TheirDeviceID, myDeviceKeyID.String(), myDevice.SigningKey.String()) + if err != nil { + return err + } + + // Master signing key + crossSigningKeys, err := vh.mach.GetOwnCrossSigningPublicKeys(ctx) + if err != nil { + return fmt.Errorf("failed to get own cross-signing public keys: %w", err) + } else if crossSigningKeys != nil { + masterKey = crossSigningKeys.MasterKey.String() + crossSigningKeyID := id.NewKeyID(id.KeyAlgorithmEd25519, masterKey) + keys[crossSigningKeyID], err = vh.verificationMACHKDF(txn, vh.client.UserID, vh.client.DeviceID, txn.TheirUserID, txn.TheirDeviceID, crossSigningKeyID.String(), masterKey) + if err != nil { + return err + } + } + + var keyIDs []string + for keyID := range keys { + keyIDs = append(keyIDs, keyID.String()) + } + slices.Sort(keyIDs) + keysMAC, err := vh.verificationMACHKDF(txn, vh.client.UserID, vh.client.DeviceID, txn.TheirUserID, txn.TheirDeviceID, "KEY_IDS", strings.Join(keyIDs, ",")) + if err != nil { + return err + } + + macEventContent := &event.VerificationMACEventContent{ + Keys: keysMAC, + MAC: keys, + } + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationMAC, macEventContent) + if err != nil { + return err + } + log.Info().Msg("Sent our MAC event") + + txn.SentOurMAC = true + if txn.ReceivedTheirMAC { + txn.VerificationState = VerificationStateSASMACExchanged + + if err := vh.trustKeysAfterMACCheck(ctx, txn, masterKey); err != nil { + return fmt.Errorf("failed to trust keys: %w", err) + } + + err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) + if err != nil { + return err + } + txn.SentOurDone = true + } + return vh.store.SaveVerificationTransaction(ctx, txn) +} + +// onVerificationStartSAS handles the m.key.verification.start events with +// method of m.sas.v1 by implementing steps 4-7 of [Section 11.12.2.2] of the +// Spec. +// +// [Section 11.12.2.2]: https://spec.matrix.org/v1.9/client-server-api/#short-authentication-string-sas-verification +func (vh *VerificationHelper) onVerificationStartSAS(ctx context.Context, txn VerificationTransaction, evt *event.Event) error { + startEvt := evt.Content.AsVerificationStart() + log := vh.getLog(ctx).With(). + Str("verification_action", "start SAS"). + Stringer("transaction_id", txn.TransactionID). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Received SAS verification start event") + + _, err := vh.mach.GetOrFetchDevice(ctx, evt.Sender, startEvt.FromDevice) + if err != nil { + log.Err(err).Msg("Failed to fetch device") + return err + } + + keyAggreementProtocol := event.KeyAgreementProtocolCurve25519HKDFSHA256 + if !slices.Contains(startEvt.KeyAgreementProtocols, keyAggreementProtocol) { + return fmt.Errorf("the other device does not support any key agreement protocols that we support") + } + + hashAlgorithm := event.VerificationHashMethodSHA256 + if !slices.Contains(startEvt.Hashes, hashAlgorithm) { + return fmt.Errorf("the other device does not support any hash algorithms that we support") + } + + macMethod := event.MACMethodHKDFHMACSHA256V2 + if !slices.Contains(startEvt.MessageAuthenticationCodes, macMethod) { + if slices.Contains(startEvt.MessageAuthenticationCodes, event.MACMethodHKDFHMACSHA256) { + macMethod = event.MACMethodHKDFHMACSHA256 + } else { + return fmt.Errorf("the other device does not support any message authentication codes that we support") + } + } + + var sasMethods []event.SASMethod + for _, sasMethod := range startEvt.ShortAuthenticationString { + if sasMethod == event.SASMethodDecimal || sasMethod == event.SASMethodEmoji { + sasMethods = append(sasMethods, sasMethod) + } + } + if len(sasMethods) == 0 { + return fmt.Errorf("the other device does not support any short authentication string methods that we support") + } + + ephemeralKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("failed to generate ephemeral key: %w", err) + } + txn.MACMethod = macMethod + txn.EphemeralKey = &ECDHPrivateKey{ephemeralKey} + + if !txn.StartedByUs { + commitment, err := calculateCommitment(ephemeralKey.PublicKey(), txn) + if err != nil { + return fmt.Errorf("failed to calculate commitment: %w", err) + } + + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationAccept, &event.VerificationAcceptEventContent{ + Commitment: commitment, + Hash: hashAlgorithm, + KeyAgreementProtocol: keyAggreementProtocol, + MessageAuthenticationCode: macMethod, + ShortAuthenticationString: sasMethods, + }) + if err != nil { + return fmt.Errorf("failed to send accept event: %w", err) + } + txn.VerificationState = VerificationStateSASAccepted + } + return vh.store.SaveVerificationTransaction(ctx, txn) +} + +func calculateCommitment(ephemeralPubKey *ecdh.PublicKey, txn VerificationTransaction) ([]byte, error) { + // The commitmentHashInput is the hash (encoded as unpadded base64) of the + // concatenation of the device's ephemeral public key (encoded as + // unpadded base64) and the canonical JSON representation of the + // m.key.verification.start message. + // + // I have no idea why they chose to base64-encode the public key before + // hashing it, but we are just stuck on that. + commitmentHashInput := sha256.New() + commitmentHashInput.Write([]byte(base64.RawStdEncoding.EncodeToString(ephemeralPubKey.Bytes()))) + encodedStartEvt, err := canonicaljson.Marshal(txn.StartEventContent) + if err != nil { + return nil, err + } + commitmentHashInput.Write(encodedStartEvt) + return commitmentHashInput.Sum(nil), nil +} + +// onVerificationAccept handles the m.key.verification.accept SAS verification +// event. This follows Step 4 of [Section 11.12.2.2] of the Spec. +// +// [Section 11.12.2.2]: https://spec.matrix.org/v1.9/client-server-api/#short-authentication-string-sas-verification +func (vh *VerificationHelper) onVerificationAccept(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + acceptEvt := evt.Content.AsVerificationAccept() + log := vh.getLog(ctx).With(). + Str("verification_action", "accept"). + Stringer("transaction_id", txn.TransactionID). + Str("commitment", base64.RawStdEncoding.EncodeToString(acceptEvt.Commitment)). + Str("hash", string(acceptEvt.Hash)). + Str("key_agreement_protocol", string(acceptEvt.KeyAgreementProtocol)). + Str("message_authentication_code", string(acceptEvt.MessageAuthenticationCode)). + Any("short_authentication_string", acceptEvt.ShortAuthenticationString). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Received SAS verification accept event") + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + if txn.VerificationState != VerificationStateSASStarted { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, + "received accept event for a transaction that is not in the started state") + return + } + + ephemeralKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + log.Err(err).Msg("Failed to generate ephemeral key") + return + } + + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationKey, &event.VerificationKeyEventContent{ + Key: ephemeralKey.PublicKey().Bytes(), + }) + if err != nil { + log.Err(err).Msg("Failed to send key event") + return + } + + txn.VerificationState = VerificationStateSASAccepted + txn.MACMethod = acceptEvt.MessageAuthenticationCode + txn.Commitment = acceptEvt.Commitment + txn.EphemeralKey = &ECDHPrivateKey{ephemeralKey} + txn.EphemeralPublicKeyShared = true + + if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { + log.Err(err).Msg("failed to save verification transaction") + } +} + +func (vh *VerificationHelper) onVerificationKey(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + log := vh.getLog(ctx).With(). + Str("verification_action", "key"). + Logger() + ctx = log.WithContext(ctx) + keyEvt := evt.Content.AsVerificationKey() + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + if txn.VerificationState != VerificationStateSASAccepted { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, + "received key event for a transaction that is not in the accepted state") + return + } + + var err error + publicKey, err := ecdh.X25519().NewPublicKey(keyEvt.Key) + if err != nil { + log.Err(err).Msg("Failed to generate other public key") + return + } + txn.OtherPublicKey = &ECDHPublicKey{publicKey} + + if txn.EphemeralPublicKeyShared { + // Verify that the commitment hash is correct + commitment, err := calculateCommitment(publicKey, txn) + if err != nil { + log.Err(err).Msg("Failed to calculate commitment") + return + } + if !bytes.Equal(commitment, txn.Commitment) { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "The key was not the one we expected") + return + } + } else { + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationKey, &event.VerificationKeyEventContent{ + Key: txn.EphemeralKey.PublicKey().Bytes(), + }) + if err != nil { + log.Err(err).Msg("Failed to send key event") + return + } + txn.EphemeralPublicKeyShared = true + } + txn.VerificationState = VerificationStateSASKeysExchanged + + sasBytes, err := vh.verificationSASHKDF(txn) + if err != nil { + log.Err(err).Msg("Failed to compute HKDF for SAS") + return + } + + var decimals []int + var emojis []rune + var emojiDescriptions []string + if slices.Contains(txn.StartEventContent.ShortAuthenticationString, event.SASMethodDecimal) { + decimals = []int{ + (int(sasBytes[0])<<5 | int(sasBytes[1])>>3) + 1000, + ((int(sasBytes[1])&0x07)<<10 | int(sasBytes[2])<<2 | int(sasBytes[3])>>6) + 1000, + ((int(sasBytes[3])&0x3f)<<7 | int(sasBytes[4])>>1) + 1000, + } + } + if slices.Contains(txn.StartEventContent.ShortAuthenticationString, event.SASMethodEmoji) { + sasNum := uint64(sasBytes[0])<<40 | uint64(sasBytes[1])<<32 | uint64(sasBytes[2])<<24 | + uint64(sasBytes[3])<<16 | uint64(sasBytes[4])<<8 | uint64(sasBytes[5]) + + for i := 0; i < 7; i++ { + // Right shift the number and then mask the lowest 6 bits. + emojiIdx := (sasNum >> uint(48-(i+1)*6)) & 0b111111 + emojis = append(emojis, allEmojis[emojiIdx]) + emojiDescriptions = append(emojiDescriptions, allEmojiDescriptions[emojiIdx]) + } + } + vh.showSAS(ctx, txn.TransactionID, emojis, emojiDescriptions, decimals) + + if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { + log.Err(err).Msg("failed to save verification transaction") + } +} + +func (vh *VerificationHelper) verificationSASHKDF(txn VerificationTransaction) ([]byte, error) { + sharedSecret, err := txn.EphemeralKey.ECDH(txn.OtherPublicKey.PublicKey) + if err != nil { + return nil, err + } + + // Perform the SAS HKDF calculation according to Section 11.12.2.2.4 of the + // Spec: + // https://spec.matrix.org/v1.9/client-server-api/#sas-hkdf-calculation + myInfo := strings.Join([]string{ + vh.client.UserID.String(), + vh.client.DeviceID.String(), + base64.RawStdEncoding.EncodeToString(txn.EphemeralKey.PublicKey().Bytes()), + }, "|") + + theirInfo := strings.Join([]string{ + txn.TheirUserID.String(), + txn.TheirDeviceID.String(), + base64.RawStdEncoding.EncodeToString(txn.OtherPublicKey.Bytes()), + }, "|") + + var infoBuf bytes.Buffer + infoBuf.WriteString("MATRIX_KEY_VERIFICATION_SAS|") + if txn.StartedByUs { + infoBuf.WriteString(myInfo + "|" + theirInfo) + } else { + infoBuf.WriteString(theirInfo + "|" + myInfo) + } + infoBuf.WriteRune('|') + infoBuf.WriteString(txn.TransactionID.String()) + + reader := hkdf.New(sha256.New, sharedSecret, nil, infoBuf.Bytes()) + output := make([]byte, 6) + _, err = reader.Read(output) + return output, err +} + +// BrokenB64Encode implements the incorrect base64 serialization in libolm for +// the hkdf-hmac-sha256 MAC method. The bug is caused by the input and output +// buffers being equal to one another during the base64 encoding. +// +// This function is narrowly scoped to this specific bug, and does not work +// generally (it only supports if the input is 32-bytes). +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3783 and +// https://gitlab.matrix.org/matrix-org/olm/-/merge_requests/16 for details. +// +// Deprecated: never use this. It is only here for compatibility with the +// broken libolm implementation. +func BrokenB64Encode(input []byte) string { + encodeBase64 := []byte{ + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5A, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, + 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7A, 0x30, 0x31, 0x32, 0x33, + 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x2B, 0x2F, + } + + output := make([]byte, 43) + copy(output, input) + + pos := 0 + outputPos := 0 + for pos != 30 { + value := int32(output[pos]) + value <<= 8 + value |= int32(output[pos+1]) + value <<= 8 + value |= int32(output[pos+2]) + pos += 3 + output[outputPos] = encodeBase64[(value>>18)&0x3F] + output[outputPos+1] = encodeBase64[(value>>12)&0x3F] + output[outputPos+2] = encodeBase64[(value>>6)&0x3F] + output[outputPos+3] = encodeBase64[value&0x3F] + outputPos += 4 + } + // This is the mangling that libolm does to the base64 encoding. + value := int32(output[pos]) + value <<= 8 + value |= int32(output[pos+1]) + value <<= 2 + output[outputPos] = encodeBase64[(value>>12)&0x3F] + output[outputPos+1] = encodeBase64[(value>>6)&0x3F] + output[outputPos+2] = encodeBase64[value&0x3F] + return string(output) +} + +func (vh *VerificationHelper) verificationMACHKDF(txn VerificationTransaction, senderUser id.UserID, senderDevice id.DeviceID, receivingUser id.UserID, receivingDevice id.DeviceID, keyID, key string) ([]byte, error) { + sharedSecret, err := txn.EphemeralKey.ECDH(txn.OtherPublicKey.PublicKey) + if err != nil { + return nil, err + } + + var infoBuf bytes.Buffer + infoBuf.WriteString("MATRIX_KEY_VERIFICATION_MAC") + infoBuf.WriteString(senderUser.String()) + infoBuf.WriteString(senderDevice.String()) + infoBuf.WriteString(receivingUser.String()) + infoBuf.WriteString(receivingDevice.String()) + infoBuf.WriteString(txn.TransactionID.String()) + infoBuf.WriteString(keyID) + + reader := hkdf.New(sha256.New, sharedSecret, nil, infoBuf.Bytes()) + macKey := make([]byte, 32) + _, err = reader.Read(macKey) + if err != nil { + return nil, err + } + + hash := hmac.New(sha256.New, macKey) + hash.Write([]byte(key)) + sum := hash.Sum(nil) + if txn.MACMethod == event.MACMethodHKDFHMACSHA256 { + sum, err = base64.RawStdEncoding.DecodeString(BrokenB64Encode(sum)) + if err != nil { + panic(err) + } + } + return sum, nil +} + +var allEmojis = []rune{ + '🐶', + '🐱', + '🦁', + '🐎', + '🦄', + '🐷', + '🐘', + '🐰', + '🐼', + '🐓', + '🐧', + '🐢', + '🐟', + '🐙', + '🦋', + '🌷', + '🌳', + '🌵', + '🍄', + '🌏', + '🌙', + '☁', + '🔥', + '🍌', + '🍎', + '🍓', + '🌽', + '🍕', + '🎂', + '❤', + '😀', + '🤖', + '🎩', + '👓', + '🔧', + '🎅', + '👍', + '☂', + '⌛', + '⏰', + '🎁', + '💡', + '📕', + '✏', + '📎', + '✂', + '🔒', + '🔑', + '🔨', + '☎', + '🏁', + '🚂', + '🚲', + '✈', + '🚀', + '🏆', + '⚽', + '🎸', + '🎺', + '🔔', + '⚓', + '🎧', + '📁', + '📌', +} + +var allEmojiDescriptions = []string{ + "Dog", + "Cat", + "Lion", + "Horse", + "Unicorn", + "Pig", + "Elephant", + "Rabbit", + "Panda", + "Rooster", + "Penguin", + "Turtle", + "Fish", + "Octopus", + "Butterfly", + "Flower", + "Tree", + "Cactus", + "Mushroom", + "Globe", + "Moon", + "Cloud", + "Fire", + "Banana", + "Apple", + "Strawberry", + "Corn", + "Pizza", + "Cake", + "Heart", + "Smiley", + "Robot", + "Hat", + "Glasses", + "Spanner", + "Santa", + "Thumbs Up", + "Umbrella", + "Hourglass", + "Clock", + "Gift", + "Light Bulb", + "Book", + "Pencil", + "Paperclip", + "Scissors", + "Lock", + "Key", + "Hammer", + "Telephone", + "Flag", + "Train", + "Bicycle", + "Aeroplane", + "Rocket", + "Trophy", + "Ball", + "Guitar", + "Trumpet", + "Bell", + "Anchor", + "Headphones", + "Folder", + "Pin", +} + +func (vh *VerificationHelper) onVerificationMAC(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + log := vh.getLog(ctx).With(). + Str("verification_action", "mac"). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Received SAS verification MAC event") + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + macEvt := evt.Content.AsVerificationMAC() + + // Verifying Keys MAC + log.Info().Msg("Verifying MAC for all sent keys") + var hasTheirDeviceKey bool + var masterKey string + var keyIDs []string + for keyID := range macEvt.MAC { + keyIDs = append(keyIDs, keyID.String()) + _, kID := keyID.Parse() + if kID == txn.TheirDeviceID.String() { + hasTheirDeviceKey = true + } else { + masterKey = kID + } + } + slices.Sort(keyIDs) + expectedKeyMAC, err := vh.verificationMACHKDF(txn, txn.TheirUserID, txn.TheirDeviceID, vh.client.UserID, vh.client.DeviceID, "KEY_IDS", strings.Join(keyIDs, ",")) + if err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "failed to calculate key list MAC: %w", err) + return + } + if !hmac.Equal(expectedKeyMAC, macEvt.Keys) { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "key list MAC mismatch") + return + } + if !hasTheirDeviceKey { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "their device key not found in list of keys") + return + } + + // Verify the MAC for each key + var theirDevice *id.Device + for keyID, mac := range macEvt.MAC { + log.Info().Stringer("key_id", keyID).Msg("Received MAC for key") + + alg, kID := keyID.Parse() + if alg != id.KeyAlgorithmEd25519 { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownMethod, "unsupported key algorithm %s", alg) + return + } + + var key string + if kID == txn.TheirDeviceID.String() { + if theirDevice != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInvalidMessage, "two keys found for their device ID") + return + } + theirDevice, err = vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) + if err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to fetch their device: %w", err) + return + } + key = theirDevice.SigningKey.String() + } else { // This is the master key + var crossSigningKeys *crypto.CrossSigningPublicKeysCache + if txn.TheirUserID == vh.client.UserID { + crossSigningKeys, err = vh.mach.GetOwnCrossSigningPublicKeys(ctx) + } else { + crossSigningKeys, err = vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) + } + if crossSigningKeys == nil { + if err != nil { + log.Err(err).Msg("Failed to get cross-signing public keys") + } + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "cross-signing keys not found") + return + } + if kID != crossSigningKeys.MasterKey.String() { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "unknown key ID %s", keyID) + return + } + key = crossSigningKeys.MasterKey.String() + } + + expectedMAC, err := vh.verificationMACHKDF(txn, txn.TheirUserID, txn.TheirDeviceID, vh.client.UserID, vh.client.DeviceID, keyID.String(), key) + if err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to calculate key MAC: %w", err) + return + } + if !hmac.Equal(expectedMAC, mac) { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "MAC mismatch for key %s", keyID) + return + } + } + log.Info().Msg("All MACs verified") + + txn.ReceivedTheirMAC = true + if txn.SentOurMAC { + txn.VerificationState = VerificationStateSASMACExchanged + + if err := vh.trustKeysAfterMACCheck(ctx, txn, masterKey); err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to trust keys: %w", err) + return + } + + err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) + if err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to send verification done event: %w", err) + return + } + txn.SentOurDone = true + } + + if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { + log.Err(err).Msg("failed to save verification transaction") + } +} + +func (vh *VerificationHelper) trustKeysAfterMACCheck(ctx context.Context, txn VerificationTransaction, masterKey string) error { + theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) + if err != nil { + return fmt.Errorf("failed to fetch their device: %w", err) + } + // Trust their device + theirDevice.Trust = id.TrustStateVerified + err = vh.mach.CryptoStore.PutDevice(ctx, txn.TheirUserID, theirDevice) + if err != nil { + return fmt.Errorf("failed to update device trust state after verifying: %w", err) + } + + if txn.TheirUserID == vh.client.UserID { + // Self-signing situation. + // + // If we have the cross-signing keys, then we need to sign their device + // using the self-signing key. Otherwise, they have the master private + // key, so we need to trust the master public key. + if vh.mach.CrossSigningKeys != nil { + err = vh.mach.SignOwnDevice(ctx, theirDevice) + if err != nil { + return fmt.Errorf("failed to sign our own new device: %w", err) + } + } else { + err = vh.mach.SignOwnMasterKey(ctx) + if err != nil { + return fmt.Errorf("failed to sign our own master key: %w", err) + } + } + } else if masterKey != "" { + // Cross-signing situation. + // + // The master key was included in the list of keys to verify, so verify + // that it matches what we expect and sign their master key using the + // user-signing key. + theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) + if err != nil { + return fmt.Errorf("couldn't get %s's cross-signing keys: %w", txn.TheirUserID, err) + } else if theirSigningKeys.MasterKey.String() != masterKey { + return fmt.Errorf("master keys do not match") + } + + if err := vh.mach.SignUser(ctx, txn.TheirUserID, theirSigningKeys.MasterKey); err != nil { + return fmt.Errorf("failed to sign %s's master key: %w", txn.TheirUserID, err) + } + } + return nil +} diff --git a/mautrix-patched/crypto/verificationhelper/sas_test.go b/mautrix-patched/crypto/verificationhelper/sas_test.go new file mode 100644 index 00000000..78e88b80 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/sas_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/crypto/verificationhelper" +) + +func TestBrokenB64Encode(t *testing.T) { + // See example from the PR that fixed the issue: + // https://gitlab.matrix.org/matrix-org/olm/-/merge_requests/16 + input := []byte{ + 121, 105, 187, 19, 37, 94, 119, 248, 224, 34, 94, 29, 157, 5, + 15, 230, 246, 115, 236, 217, 80, 78, 56, 200, 80, 200, 82, 158, + 168, 179, 10, 230, + } + + b64 := verificationhelper.BrokenB64Encode(input) + assert.Equal(t, "eWm7NyVeVmXgbVhnYlZobllsWm9ibGxzV205aWJHeHo", b64) +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper.go b/mautrix-patched/crypto/verificationhelper/verificationhelper.go new file mode 100644 index 00000000..0a781c16 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationhelper.go @@ -0,0 +1,932 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exslices" + "go.mau.fi/util/jsontime" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// RequiredCallbacks is an interface representing the callbacks required for +// the [VerificationHelper]. +type RequiredCallbacks interface { + // VerificationRequested is called when a verification request is received + // from another device. + VerificationRequested(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) + + // VerificationReady is called when a verification request has been + // accepted by both parties. + VerificationReady(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, supportsScanQRCode bool, qrCode *QRCode) + + // VerificationCancelled is called when the verification is cancelled. + VerificationCancelled(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) + + // VerificationDone is called when the verification is done. + VerificationDone(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) +} + +type ShowSASCallbacks interface { + // ShowSAS is a callback that is called when the SAS verification has + // generated a short authentication string to show. It is guaranteed that + // either the emojis and emoji descriptions lists, or the decimals list, or + // both will be present. + ShowSAS(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) +} + +type ShowQRCodeCallbacks interface { + // QRCodeScanned is called when the other user has scanned the QR code and + // sent the m.key.verification.start event. + QRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) +} + +type VerificationHelper struct { + client *mautrix.Client + mach *crypto.OlmMachine + + store VerificationStore + activeTransactionsLock sync.Mutex + + // supportedMethods are the methods that *we* support + supportedMethods []event.VerificationMethod + verificationRequested func(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) + verificationReady func(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, supportsScanQRCode bool, qrCode *QRCode) + verificationCancelledCallback func(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) + verificationDone func(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) + + // showSAS is a callback that will be called after the SAS verification + // dance is complete and we want the client to show the emojis/decimals + showSAS func(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) + // qrCodeScanned is a callback that will be called when the other device + // scanned the QR code we are showing + qrCodeScanned func(ctx context.Context, txnID id.VerificationTransactionID) +} + +var _ mautrix.VerificationHelper = (*VerificationHelper)(nil) + +func NewVerificationHelper(client *mautrix.Client, mach *crypto.OlmMachine, store VerificationStore, callbacks any, supportsQRShow, supportsQRScan, supportsSAS bool) *VerificationHelper { + if client.Crypto == nil { + panic("client.Crypto is nil") + } + + if store == nil { + store = NewInMemoryVerificationStore() + } + + helper := VerificationHelper{ + client: client, + mach: mach, + store: store, + } + + if c, ok := callbacks.(RequiredCallbacks); !ok { + panic("callbacks must implement RequiredCallbacks") + } else { + helper.verificationRequested = c.VerificationRequested + helper.verificationReady = c.VerificationReady + helper.verificationCancelledCallback = c.VerificationCancelled + helper.verificationDone = c.VerificationDone + } + + if supportsSAS { + if c, ok := callbacks.(ShowSASCallbacks); !ok { + panic("callbacks must implement showSAS if supportsSAS is true") + } else { + helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodSAS) + helper.showSAS = c.ShowSAS + } + } + if supportsQRShow { + if c, ok := callbacks.(ShowQRCodeCallbacks); !ok { + panic("callbacks must implement ShowQRCodeCallbacks if supportsQRShow is true") + } else { + helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodQRCodeShow) + helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodReciprocate) + helper.qrCodeScanned = c.QRCodeScanned + } + } + if supportsQRScan { + helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodQRCodeScan) + helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodReciprocate) + } + helper.supportedMethods = exslices.DeduplicateUnsorted(helper.supportedMethods) + return &helper +} + +func (vh *VerificationHelper) getLog(ctx context.Context) *zerolog.Logger { + logger := zerolog.Ctx(ctx).With(). + Str("component", "verification"). + Stringer("device_id", vh.client.DeviceID). + Stringer("user_id", vh.client.UserID). + Any("supported_methods", vh.supportedMethods). + Logger() + return &logger +} + +// Init initializes the verification helper by adding the necessary event +// handlers to the syncer. +func (vh *VerificationHelper) Init(ctx context.Context) error { + if vh == nil { + return fmt.Errorf("verification helper is nil") + } + syncer, ok := vh.client.Syncer.(mautrix.ExtensibleSyncer) + if !ok { + return fmt.Errorf("the client syncer must implement ExtensibleSyncer") + } + + // Event handlers for verification requests. These are special since we do + // not need to check that the transaction ID is known. + syncer.OnEventType(event.ToDeviceVerificationRequest, vh.onVerificationRequest) + syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + if evt.Content.AsMessage().MsgType == event.MsgVerificationRequest { + vh.onVerificationRequest(ctx, evt) + } + }) + + // Wrapper for the event handlers to check that the transaction ID is known + // and ignore the event if it isn't. + wrapHandler := func(callback func(context.Context, VerificationTransaction, *event.Event)) func(context.Context, *event.Event) { + return func(ctx context.Context, evt *event.Event) { + log := vh.getLog(ctx).With(). + Str("verification_action", "check transaction ID"). + Stringer("sender", evt.Sender). + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.ID). + Stringer("event_type", evt.Type). + Logger() + ctx = log.WithContext(ctx) + + var transactionID id.VerificationTransactionID + if evt.ID != "" { + transactionID = id.VerificationTransactionID(evt.ID) + } else { + if txnID, ok := evt.Content.Parsed.(event.VerificationTransactionable); !ok { + log.Warn().Msg("Ignoring verification event without a transaction ID") + return + } else { + transactionID = txnID.GetTransactionID() + } + } + log = log.With().Stringer("transaction_id", transactionID).Logger() + + vh.activeTransactionsLock.Lock() + txn, err := vh.store.GetVerificationTransaction(ctx, transactionID) + if err != nil && errors.Is(err, ErrUnknownVerificationTransaction) { + log.Err(err).Msg("failed to get verification transaction") + vh.activeTransactionsLock.Unlock() + return + } else if errors.Is(err, ErrUnknownVerificationTransaction) { + // If it's a cancellation event for an unknown transaction, we + // can just ignore it. + if evt.Type == event.ToDeviceVerificationCancel || evt.Type == event.InRoomVerificationCancel { + log.Info().Msg("Ignoring verification cancellation event for an unknown transaction") + vh.activeTransactionsLock.Unlock() + return + } + + log.Warn().Msg("Sending cancellation event for unknown transaction ID") + + // We have to create a fake transaction so that the call to + // cancelVerificationTxn works. + txn = VerificationTransaction{ + ExpirationTime: jsontime.UnixMilli{Time: time.Now().Add(time.Minute * 10)}, + RoomID: evt.RoomID, + TheirUserID: evt.Sender, + } + if transactionable, ok := evt.Content.Parsed.(event.VerificationTransactionable); ok { + txn.TransactionID = transactionable.GetTransactionID() + } else { + txn.TransactionID = id.VerificationTransactionID(evt.ID) + } + if fromDevice, ok := evt.Content.Raw["from_device"]; ok { + txn.TheirDeviceID = id.DeviceID(fromDevice.(string)) + } + + // Send a cancellation event. + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownTransaction, "The transaction ID was not recognized.") + vh.activeTransactionsLock.Unlock() + return + } else { + vh.activeTransactionsLock.Unlock() + } + + logCtx := log.With(). + Stringer("transaction_step", txn.VerificationState). + Stringer("sender", evt.Sender) + if evt.RoomID != "" { + logCtx = logCtx. + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.ID) + } + callback(logCtx.Logger().WithContext(ctx), txn, evt) + } + } + + // Event handlers for the to-device verification events. + syncer.OnEventType(event.ToDeviceVerificationReady, wrapHandler(vh.onVerificationReady)) + syncer.OnEventType(event.ToDeviceVerificationStart, wrapHandler(vh.onVerificationStart)) + syncer.OnEventType(event.ToDeviceVerificationDone, wrapHandler(vh.onVerificationDone)) + syncer.OnEventType(event.ToDeviceVerificationCancel, wrapHandler(vh.onVerificationCancel)) + syncer.OnEventType(event.ToDeviceVerificationAccept, wrapHandler(vh.onVerificationAccept)) // SAS + syncer.OnEventType(event.ToDeviceVerificationKey, wrapHandler(vh.onVerificationKey)) // SAS + syncer.OnEventType(event.ToDeviceVerificationMAC, wrapHandler(vh.onVerificationMAC)) // SAS + + // Event handlers for the in-room verification events. + syncer.OnEventType(event.InRoomVerificationReady, wrapHandler(vh.onVerificationReady)) + syncer.OnEventType(event.InRoomVerificationStart, wrapHandler(vh.onVerificationStart)) + syncer.OnEventType(event.InRoomVerificationDone, wrapHandler(vh.onVerificationDone)) + syncer.OnEventType(event.InRoomVerificationCancel, wrapHandler(vh.onVerificationCancel)) + syncer.OnEventType(event.InRoomVerificationAccept, wrapHandler(vh.onVerificationAccept)) // SAS + syncer.OnEventType(event.InRoomVerificationKey, wrapHandler(vh.onVerificationKey)) // SAS + syncer.OnEventType(event.InRoomVerificationMAC, wrapHandler(vh.onVerificationMAC)) // SAS + + allTransactions, err := vh.store.GetAllVerificationTransactions(ctx) + for _, txn := range allTransactions { + vh.expireTransactionAt(txn.TransactionID, txn.ExpirationTime.Time) + } + return err +} + +// StartVerification starts an interactive verification flow with the given +// user via a to-device event. +func (vh *VerificationHelper) StartVerification(ctx context.Context, to id.UserID) (id.VerificationTransactionID, error) { + if len(vh.supportedMethods) == 0 { + return "", fmt.Errorf("no supported verification methods") + } + + txnID := id.NewVerificationTransactionID() + + devices, err := vh.mach.CryptoStore.GetDevices(ctx, to) + if err != nil { + return "", fmt.Errorf("failed to get devices for user: %w", err) + } else if len(devices) == 0 { + // HACK: we are doing this because the client doesn't wait until it has + // the devices before starting verification. + if keys, err := vh.mach.FetchKeys(ctx, []id.UserID{to}, true); err != nil { + return "", err + } else { + devices = keys[to] + } + } + + log := vh.getLog(ctx).With(). + Str("verification_action", "start verification"). + Stringer("transaction_id", txnID). + Stringer("to", to). + Any("device_ids", maps.Keys(devices)). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Sending verification request") + + now := time.Now() + content := &event.Content{ + Parsed: &event.VerificationRequestEventContent{ + ToDeviceVerificationEvent: event.ToDeviceVerificationEvent{TransactionID: txnID}, + FromDevice: vh.client.DeviceID, + Methods: vh.supportedMethods, + Timestamp: jsontime.UM(now), + }, + } + vh.expireTransactionAt(txnID, now.Add(time.Minute*10)) + + req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{to: {}}} + for deviceID := range devices { + if deviceID == vh.client.DeviceID { + // Don't ever send the event to the current device. We are likely + // trying to send a verification request to our other devices. + continue + } + + req.Messages[to][deviceID] = content + } + _, err = vh.client.SendToDevice(ctx, event.ToDeviceVerificationRequest, &req) + if err != nil { + return "", fmt.Errorf("failed to send verification request: %w", err) + } + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + return txnID, vh.store.SaveVerificationTransaction(ctx, VerificationTransaction{ + ExpirationTime: jsontime.UnixMilli{Time: time.Now().Add(time.Minute * 10)}, + VerificationState: VerificationStateRequested, + TransactionID: txnID, + TheirUserID: to, + SentToDeviceIDs: maps.Keys(devices), + }) +} + +// StartInRoomVerification starts an interactive verification flow with the +// given user in the given room. +func (vh *VerificationHelper) StartInRoomVerification(ctx context.Context, roomID id.RoomID, to id.UserID) (id.VerificationTransactionID, error) { + log := vh.getLog(ctx).With(). + Str("verification_action", "start in-room verification"). + Stringer("room_id", roomID). + Stringer("to", to). + Logger() + ctx = log.WithContext(ctx) + + log.Info().Msg("Sending verification request") + content := event.MessageEventContent{ + MsgType: event.MsgVerificationRequest, + Body: fmt.Sprintf("%s is requesting to verify your device, but your client does not support verification, so you may need to use a different verification method.", vh.client.UserID), + FromDevice: vh.client.DeviceID, + Methods: vh.supportedMethods, + To: to, + } + encryptedContent, err := vh.client.Crypto.Encrypt(ctx, roomID, event.EventMessage, &content) + if err != nil { + return "", fmt.Errorf("failed to encrypt verification request: %w", err) + } + resp, err := vh.client.SendMessageEvent(ctx, roomID, event.EventMessage, encryptedContent) + if err != nil { + return "", fmt.Errorf("failed to send verification request: %w", err) + } + + txnID := id.VerificationTransactionID(resp.EventID) + log.Info().Stringer("transaction_id", txnID).Msg("Got a transaction ID for the verification request") + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + return txnID, vh.store.SaveVerificationTransaction(ctx, VerificationTransaction{ + ExpirationTime: jsontime.UnixMilli{Time: time.Now().Add(time.Minute * 10)}, + RoomID: roomID, + VerificationState: VerificationStateRequested, + TransactionID: txnID, + TheirUserID: to, + }) +} + +// AcceptVerification accepts a verification request. The transaction ID should +// be the transaction ID of a verification request that was received via the +// VerificationRequested callback in [RequiredCallbacks]. +func (vh *VerificationHelper) AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error { + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + log := vh.getLog(ctx).With(). + Str("verification_action", "accept verification"). + Stringer("transaction_id", txnID). + Logger() + ctx = log.WithContext(ctx) + + txn, err := vh.store.GetVerificationTransaction(ctx, txnID) + if err != nil { + return err + } else if txn.VerificationState != VerificationStateRequested { + return fmt.Errorf("transaction is not in the requested state") + } + + supportedMethods := map[event.VerificationMethod]struct{}{} + for _, method := range txn.TheirSupportedMethods { + switch method { + case event.VerificationMethodSAS: + if slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) { + supportedMethods[event.VerificationMethodSAS] = struct{}{} + } + case event.VerificationMethodQRCodeShow: + if slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) { + supportedMethods[event.VerificationMethodQRCodeScan] = struct{}{} + supportedMethods[event.VerificationMethodReciprocate] = struct{}{} + } + case event.VerificationMethodQRCodeScan: + if slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeShow) { + supportedMethods[event.VerificationMethodQRCodeShow] = struct{}{} + supportedMethods[event.VerificationMethodReciprocate] = struct{}{} + } + } + } + + log.Info().Any("methods", maps.Keys(supportedMethods)).Msg("Sending ready event") + readyEvt := &event.VerificationReadyEventContent{ + FromDevice: vh.client.DeviceID, + Methods: maps.Keys(supportedMethods), + } + err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationReady, readyEvt) + if err != nil { + return err + } + txn.VerificationState = VerificationStateReady + + supportsSAS := slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) && + slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodSAS) + supportsReciprocate := slices.Contains(vh.supportedMethods, event.VerificationMethodReciprocate) && + slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodReciprocate) + supportsScanQRCode := supportsReciprocate && + slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) && + slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodQRCodeShow) + + qrCode, err := vh.generateQRCode(ctx, &txn) + if err != nil { + return err + } + vh.verificationReady(ctx, txn.TransactionID, txn.TheirDeviceID, supportsSAS, supportsScanQRCode, qrCode) + return vh.store.SaveVerificationTransaction(ctx, txn) +} + +// DismissVerification dismisses the verification request with the given +// transaction ID. The transaction ID should be one received via the +// VerificationRequested callback in [RequiredCallbacks] or the +// [StartVerification] or [StartInRoomVerification] functions. +func (vh *VerificationHelper) DismissVerification(ctx context.Context, txnID id.VerificationTransactionID) error { + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + return vh.store.DeleteVerification(ctx, txnID) +} + +// DismissVerification cancels the verification request with the given +// transaction ID. The transaction ID should be one received via the +// VerificationRequested callback in [RequiredCallbacks] or the +// [StartVerification] or [StartInRoomVerification] functions. +func (vh *VerificationHelper) CancelVerification(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) error { + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + txn, err := vh.store.GetVerificationTransaction(ctx, txnID) + if err != nil { + return err + } + log := vh.getLog(ctx).With(). + Str("verification_action", "cancel verification"). + Stringer("transaction_id", txnID). + Str("code", string(code)). + Str("reason", reason). + Logger() + ctx = log.WithContext(ctx) + + log.Info().Msg("Sending cancellation event") + cancelEvt := &event.VerificationCancelEventContent{Code: code, Reason: reason} + if len(txn.RoomID) > 0 { + // Sending the cancellation event to the room. + err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationCancel, cancelEvt) + if err != nil { + return fmt.Errorf("failed to send cancel verification event (code: %s, reason: %s): %w", code, reason, err) + } + } else { + cancelEvt.SetTransactionID(txn.TransactionID) + req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{ + txn.TheirUserID: {}, + }} + if len(txn.TheirDeviceID) > 0 { + // Send the cancellation event to only the device that accepted the + // verification request. All of the other devices already received a + // cancellation event with code "m.acceped". + req.Messages[txn.TheirUserID][txn.TheirDeviceID] = &event.Content{Parsed: cancelEvt} + } else { + // Send the cancellation event to all of the devices that we sent the + // request to. + for _, deviceID := range txn.SentToDeviceIDs { + if deviceID != vh.client.DeviceID { + req.Messages[txn.TheirUserID][deviceID] = &event.Content{Parsed: cancelEvt} + } + } + } + _, err := vh.client.SendToDevice(ctx, event.ToDeviceVerificationCancel, &req) + if err != nil { + return fmt.Errorf("failed to send m.key.verification.cancel event to %v: %w", maps.Keys(req.Messages[txn.TheirUserID]), err) + } + } + return vh.store.DeleteVerification(ctx, txn.TransactionID) +} + +// sendVerificationEvent sends a verification event to the other user's device +// setting the m.relates_to or transaction ID as necessary. +// +// Notes: +// +// - "content" must implement [event.Relatable] and +// [event.VerificationTransactionable]. +// - evtType can be either the to-device or in-room version of the event type +// as it is always stringified. +func (vh *VerificationHelper) sendVerificationEvent(ctx context.Context, txn VerificationTransaction, evtType event.Type, content any) error { + if txn.RoomID != "" { + content.(event.Relatable).SetRelatesTo(&event.RelatesTo{Type: event.RelReference, EventID: id.EventID(txn.TransactionID)}) + _, err := vh.client.SendMessageEvent(ctx, txn.RoomID, evtType, &event.Content{ + Parsed: content, + }) + if err != nil { + return fmt.Errorf("failed to send %s event to %s: %w", evtType.String(), txn.RoomID, err) + } + } else { + content.(event.VerificationTransactionable).SetTransactionID(txn.TransactionID) + req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{ + txn.TheirUserID: { + txn.TheirDeviceID: &event.Content{Parsed: content}, + }, + }} + _, err := vh.client.SendToDevice(ctx, evtType, &req) + if err != nil { + return fmt.Errorf("failed to send %s event to %s: %w", evtType.String(), txn.TheirDeviceID, err) + } + } + return nil +} + +// cancelVerificationTxn cancels a verification transaction with the given code +// and reason. It always returns an error, which is the formatted error message +// (this is allows the caller to return the result of this function call +// directly to expose the error to its caller). +// +// Must always be called with the activeTransactionsLock held. +func (vh *VerificationHelper) cancelVerificationTxn(ctx context.Context, txn VerificationTransaction, code event.VerificationCancelCode, reasonFmtStr string, fmtArgs ...any) error { + reason := fmt.Errorf(reasonFmtStr, fmtArgs...).Error() + log := vh.getLog(ctx).With(). + Stringer("transaction_id", txn.TransactionID). + Str("code", string(code)). + Str("reason", reason). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Sending cancellation event") + cancelEvt := &event.VerificationCancelEventContent{Code: code, Reason: reason} + err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationCancel, cancelEvt) + if err != nil { + log.Err(err).Msg("failed to send cancellation event") + return fmt.Errorf("failed to send cancel verification event (code: %s, reason: %s): %w", code, reason, err) + } + if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { + log.Err(err).Msg("deleting verification failed") + } + vh.verificationCancelledCallback(ctx, txn.TransactionID, code, reason) + return fmt.Errorf("verification cancelled (code: %s): %s", code, reason) +} + +func (vh *VerificationHelper) onVerificationRequest(ctx context.Context, evt *event.Event) { + logCtx := vh.getLog(ctx).With(). + Str("verification_action", "verification request"). + Stringer("sender", evt.Sender) + if evt.RoomID != "" { + logCtx = logCtx. + Stringer("room_id", evt.RoomID). + Stringer("event_id", evt.ID) + } + log := logCtx.Logger() + + var verificationRequest *event.VerificationRequestEventContent + switch evt.Type { + case event.EventMessage: + to := evt.Content.AsMessage().To + if to != vh.client.UserID { + log.Info().Stringer("to", to).Msg("Ignoring verification request for another user") + return + } + + verificationRequest = event.VerificationRequestEventContentFromMessage(evt) + case event.ToDeviceVerificationRequest: + verificationRequest = evt.Content.AsVerificationRequest() + default: + log.Warn().Str("type", evt.Type.Type).Msg("Ignoring verification request of unknown type") + return + } + + if verificationRequest.FromDevice == vh.client.DeviceID { + log.Warn().Msg("Ignoring verification request from our own device. Why did it even get sent to us?") + return + } + + if verificationRequest.Timestamp.Add(10 * time.Minute).Before(time.Now()) { + log.Warn().Msg("Ignoring verification request that is over ten minutes old") + return + } + + if len(verificationRequest.TransactionID) == 0 { + log.Warn().Msg("Ignoring verification request without a transaction ID") + return + } + + log = log.With(). + Any("requested_methods", verificationRequest.Methods). + Stringer("transaction_id", verificationRequest.TransactionID). + Stringer("from_device", verificationRequest.FromDevice). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Received verification request") + + // Check if we support any of the methods listed + var supportsAnyMethod bool + for _, method := range verificationRequest.Methods { + switch method { + case event.VerificationMethodSAS: + supportsAnyMethod = slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) + case event.VerificationMethodQRCodeScan: + supportsAnyMethod = slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeShow) && + slices.Contains(verificationRequest.Methods, event.VerificationMethodReciprocate) + case event.VerificationMethodQRCodeShow: + supportsAnyMethod = slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) && + slices.Contains(verificationRequest.Methods, event.VerificationMethodReciprocate) + } + if supportsAnyMethod { + break + } + } + if !supportsAnyMethod { + log.Warn().Msg("Ignoring verification request that doesn't have any methods we support") + return + } + + vh.activeTransactionsLock.Lock() + newTxn := VerificationTransaction{ + ExpirationTime: jsontime.UnixMilli{Time: verificationRequest.Timestamp.Add(time.Minute * 10)}, + RoomID: evt.RoomID, + VerificationState: VerificationStateRequested, + TransactionID: verificationRequest.TransactionID, + TheirDeviceID: verificationRequest.FromDevice, + TheirUserID: evt.Sender, + TheirSupportedMethods: verificationRequest.Methods, + } + if txn, err := vh.store.FindVerificationTransactionForUserDevice(ctx, evt.Sender, verificationRequest.FromDevice); err != nil && !errors.Is(err, ErrUnknownVerificationTransaction) { + log.Err(err).Stringer("sender", evt.Sender).Stringer("device_id", verificationRequest.FromDevice).Msg("failed to find verification transaction") + vh.activeTransactionsLock.Unlock() + return + } else if !errors.Is(err, ErrUnknownVerificationTransaction) { + if txn.TransactionID == verificationRequest.TransactionID { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "received a new verification request for the same transaction ID") + } else { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "received multiple verification requests from the same device") + vh.cancelVerificationTxn(ctx, newTxn, event.VerificationCancelCodeUnexpectedMessage, "received multiple verification requests from the same device") + } + vh.activeTransactionsLock.Unlock() + return + } + if err := vh.store.SaveVerificationTransaction(ctx, newTxn); err != nil { + log.Err(err).Msg("failed to save verification transaction") + } + vh.activeTransactionsLock.Unlock() + + vh.expireTransactionAt(verificationRequest.TransactionID, verificationRequest.Timestamp.Add(time.Minute*10)) + vh.verificationRequested(ctx, verificationRequest.TransactionID, evt.Sender, verificationRequest.FromDevice) +} + +func (vh *VerificationHelper) expireTransactionAt(txnID id.VerificationTransactionID, expiresAt time.Time) { + go func() { + time.Sleep(time.Until(expiresAt)) + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + txn, err := vh.store.GetVerificationTransaction(context.Background(), txnID) + if err == ErrUnknownVerificationTransaction { + // Already deleted, nothing to expire + return + } else if err != nil { + vh.getLog(context.Background()).Err(err).Msg("failed to get verification transaction to expire") + } else { + vh.cancelVerificationTxn(context.Background(), txn, event.VerificationCancelCodeTimeout, "verification timed out") + } + }() +} + +func (vh *VerificationHelper) onVerificationReady(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + log := vh.getLog(ctx).With(). + Str("verification_action", "verification ready"). + Logger() + ctx = log.WithContext(ctx) + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + if txn.VerificationState != VerificationStateRequested { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "verification ready event received for a transaction that is not in the requested state") + return + } + + readyEvt := evt.Content.AsVerificationReady() + + // Update the transaction state. + txn.VerificationState = VerificationStateReady + txn.TheirDeviceID = readyEvt.FromDevice + txn.TheirSupportedMethods = readyEvt.Methods + + log.Info(). + Stringer("their_device_id", txn.TheirDeviceID). + Any("their_supported_methods", txn.TheirSupportedMethods). + Msg("Received verification ready event") + + // If we sent this verification request, send cancellations to all of the + // other devices. + if len(txn.SentToDeviceIDs) > 0 { + content := &event.Content{ + Parsed: &event.VerificationCancelEventContent{ + ToDeviceVerificationEvent: event.ToDeviceVerificationEvent{TransactionID: txn.TransactionID}, + Code: event.VerificationCancelCodeAccepted, + Reason: "The verification was accepted on another device.", + }, + } + req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{txn.TheirUserID: {}}} + for _, deviceID := range txn.SentToDeviceIDs { + if deviceID == txn.TheirDeviceID || deviceID == vh.client.DeviceID { + // Don't ever send a cancellation to the device that accepted + // the request or to our own device (which can happen if this + // is a self-verification). + continue + } + + req.Messages[txn.TheirUserID][deviceID] = content + } + _, err := vh.client.SendToDevice(ctx, event.ToDeviceVerificationCancel, &req) + if err != nil { + log.Warn().Err(err).Msg("Failed to send cancellation requests") + } + } + + supportsSAS := slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) && + slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodSAS) + supportsReciprocate := slices.Contains(vh.supportedMethods, event.VerificationMethodReciprocate) && + slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodReciprocate) + supportsScanQRCode := supportsReciprocate && + slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) && + slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodQRCodeShow) + + qrCode, err := vh.generateQRCode(ctx, &txn) + if err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to generate QR code: %w", err) + return + } + + vh.verificationReady(ctx, txn.TransactionID, txn.TheirDeviceID, supportsSAS, supportsScanQRCode, qrCode) + + if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to save verification transaction: %w", err) + } +} + +func (vh *VerificationHelper) onVerificationStart(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + startEvt := evt.Content.AsVerificationStart() + log := vh.getLog(ctx).With(). + Str("verification_action", "verification start"). + Str("method", string(startEvt.Method)). + Stringer("their_device_id", txn.TheirDeviceID). + Any("their_supported_methods", txn.TheirSupportedMethods). + Bool("started_by_us", txn.StartedByUs). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Received verification start event") + + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + if txn.VerificationState == VerificationStateSASStarted || txn.VerificationState == VerificationStateOurQRScanned || txn.VerificationState == VerificationStateTheirQRScanned { + // We might have sent the event, and they also sent an event. + if txn.StartEventContent == nil || !txn.StartedByUs { + // We didn't sent a start event yet, so we have gotten ourselves + // into a bad state. They've either sent two start events, or we + // have gone on to a new state. + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "got repeat start event from other user") + return + } + + // Otherwise, we need to implement the following algorithm from Section + // 11.12.2.1 of the Spec: + // https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework + // + // If Alice's and Bob's clients both send an m.key.verification.start + // message, and both specify the same verification method, then the + // m.key.verification.start message sent by the user whose ID is the + // lexicographically largest user ID should be ignored, and the + // situation should be treated the same as if only the user with the + // lexicographically smallest user ID had sent the + // m.key.verification.start message. In the case where the user IDs are + // the same (that is, when a user is verifying their own device), then + // the device IDs should be compared instead. If the two + // m.key.verification.start messages do not specify the same + // verification method, then the verification should be cancelled with + // a code of m.unexpected_message. + + if txn.StartEventContent.Method != startEvt.Method { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "the start events have different verification methods") + return + } + + if txn.TheirUserID < vh.client.UserID || (txn.TheirUserID == vh.client.UserID && txn.TheirDeviceID < vh.client.DeviceID) { + log.Debug().Msg("Using their start event instead of ours because they are alphabetically before us") + txn.StartedByUs = false + txn.StartEventContent = startEvt + } + } else if txn.VerificationState != VerificationStateReady { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "got start event for transaction that is not in ready state") + return + } else { + txn.StartEventContent = startEvt + } + + switch startEvt.Method { + case event.VerificationMethodSAS: + log.Info().Msg("Received SAS start event") + txn.VerificationState = VerificationStateSASStarted + if err := vh.onVerificationStartSAS(ctx, txn, evt); err != nil { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to handle SAS verification start: %w", err) + } + case event.VerificationMethodReciprocate: + log.Info().Msg("Received reciprocate start event") + if !bytes.Equal(txn.QRCodeSharedSecret, txn.StartEventContent.Secret) { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "reciprocated shared secret does not match") + return + } + txn.VerificationState = VerificationStateOurQRScanned + vh.qrCodeScanned(ctx, txn.TransactionID) + if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { + log.Err(err).Msg("failed to save verification transaction") + } + default: + // Note that we should never get m.qr_code.show.v1 or m.qr_code.scan.v1 + // here, since the start command for scanning and showing QR codes + // should be of type m.reciprocate.v1. + log.Error().Str("method", string(txn.StartEventContent.Method)).Msg("Unsupported verification method in start event") + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownMethod, "unknown method %s", txn.StartEventContent.Method) + } +} + +func (vh *VerificationHelper) onVerificationDone(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + log := vh.getLog(ctx).With(). + Str("verification_action", "done"). + Stringer("transaction_id", txn.TransactionID). + Bool("sent_our_done", txn.SentOurDone). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Verification done") + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + if !slices.Contains([]VerificationState{ + VerificationStateTheirQRScanned, VerificationStateOurQRScanned, VerificationStateSASMACExchanged, + }, txn.VerificationState) { + vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "got done event for transaction that is not in QR-scanned or MAC-exchanged state") + return + } + + txn.ReceivedTheirDone = true + if txn.SentOurDone { + if err := vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { + log.Err(err).Msg("Delete verification failed") + } + vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) + } else if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { + log.Err(err).Msg("failed to save verification transaction") + } +} + +func (vh *VerificationHelper) onVerificationCancel(ctx context.Context, txn VerificationTransaction, evt *event.Event) { + cancelEvt := evt.Content.AsVerificationCancel() + log := vh.getLog(ctx).With(). + Str("verification_action", "cancel"). + Stringer("transaction_id", txn.TransactionID). + Str("cancel_code", string(cancelEvt.Code)). + Str("reason", cancelEvt.Reason). + Logger() + ctx = log.WithContext(ctx) + log.Info().Msg("Verification was cancelled") + vh.activeTransactionsLock.Lock() + defer vh.activeTransactionsLock.Unlock() + + // Element (and at least the old desktop client) send cancellation events + // when the user rejects the verification request. This is really dumb, + // because they should just instead ignore the request and not send a + // cancellation. + // + // The above behavior causes a problem with the other devices that we sent + // the verification request to because they don't know that the request was + // cancelled. + // + // As a workaround, if we receive a cancellation event to a transaction + // that is currently in the REQUESTED state, then we will send + // cancellations to all of the devices that we sent the request to. This + // will ensure that all of the clients know that the request was cancelled. + if txn.VerificationState == VerificationStateRequested && len(txn.SentToDeviceIDs) > 0 { + content := &event.Content{ + Parsed: &event.VerificationCancelEventContent{ + ToDeviceVerificationEvent: event.ToDeviceVerificationEvent{TransactionID: txn.TransactionID}, + Code: event.VerificationCancelCodeUser, + Reason: "The verification was rejected from another device.", + }, + } + req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{txn.TheirUserID: {}}} + for _, deviceID := range txn.SentToDeviceIDs { + req.Messages[txn.TheirUserID][deviceID] = content + } + _, err := vh.client.SendToDevice(ctx, event.ToDeviceVerificationCancel, &req) + if err != nil { + log.Warn().Err(err).Msg("Failed to send cancellation requests") + } + } + + if err := vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { + log.Err(err).Msg("Delete verification failed") + } + vh.verificationCancelledCallback(ctx, txn.TransactionID, cancelEvt.Code, cancelEvt.Reason) +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go new file mode 100644 index 00000000..5e3f146b --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper_test + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + testCases := []struct { + sendingScansQR bool // false indicates that receiving device should emulate a scan + }{ + {false}, + {true}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("sendingScansQR=%t", tc.sendingScansQR), func(t *testing.T) { + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginAliceBob(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + // Generate cross-signing keys for both users + _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + _, _, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + + // Fetch each other's keys + sendingMachine.FetchKeys(ctx, []id.UserID{bobUserID}, true) + receivingMachine.FetchKeys(ctx, []id.UserID{aliceUserID}, true) + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, bobUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) + + receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, receivingShownQRCode) + sendingShownQRCode := sendingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, sendingShownQRCode) + + if tc.sendingScansQR { + // Emulate scanning the QR code shown by the receiving device + // on the sending device. + err := sendingHelper.HandleScannedQRData(ctx, receivingShownQRCode.Bytes()) + require.NoError(t, err) + + // Ensure that the receiving device received a verification + // start event and a verification done event. + receivingInbox := ts.DeviceInbox[bobUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 2) + + startEvt := receivingInbox[0].Content.AsVerificationStart() + assert.Equal(t, txnID, startEvt.TransactionID) + assert.Equal(t, sendingDeviceID, startEvt.FromDevice) + assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) + assert.EqualValues(t, receivingShownQRCode.SharedSecret, startEvt.Secret) + + doneEvt := receivingInbox[1].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + // Handle the start and done events on the receiving client and + // confirm the scan. + ts.DispatchToDevice(t, ctx, receivingClient) + + // Ensure that the receiving device detected that its QR code + // was scanned. + assert.True(t, receivingCallbacks.WasOurQRCodeScanned(txnID)) + err = receivingHelper.ConfirmQRCodeScanned(ctx, txnID) + require.NoError(t, err) + + // Ensure that the sending device received a verification done + // event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + require.Len(t, sendingInbox, 1) + doneEvt = sendingInbox[0].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + ts.DispatchToDevice(t, ctx, sendingClient) + } else { // receiving scans QR + // Emulate scanning the QR code shown by the sending device on + // the receiving device. + err := receivingHelper.HandleScannedQRData(ctx, sendingShownQRCode.Bytes()) + require.NoError(t, err) + + // Ensure that the sending device received a verification + // start event and a verification done event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 2) + + startEvt := sendingInbox[0].Content.AsVerificationStart() + assert.Equal(t, txnID, startEvt.TransactionID) + assert.Equal(t, receivingDeviceID, startEvt.FromDevice) + assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) + assert.EqualValues(t, sendingShownQRCode.SharedSecret, startEvt.Secret) + + doneEvt := sendingInbox[1].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + // Handle the start and done events on the receiving client and + // confirm the scan. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Ensure that the sending device detected that its QR code was + // scanned. + assert.True(t, sendingCallbacks.WasOurQRCodeScanned(txnID)) + err = sendingHelper.ConfirmQRCodeScanned(ctx, txnID) + require.NoError(t, err) + + // Ensure that the receiving device received a verification + // done event. + receivingInbox := ts.DeviceInbox[bobUserID][receivingDeviceID] + require.Len(t, receivingInbox, 1) + doneEvt = receivingInbox[0].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + ts.DispatchToDevice(t, ctx, receivingClient) + } + + // Ensure that both devices have marked the verification as done. + assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) + assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) + + bobTrustsAlice, err := receivingMachine.IsUserTrusted(ctx, aliceUserID) + assert.NoError(t, err) + assert.True(t, bobTrustsAlice) + aliceTrustsBob, err := sendingMachine.IsUserTrusted(ctx, bobUserID) + assert.NoError(t, err) + assert.True(t, aliceTrustsBob) + }) + } +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go new file mode 100644 index 00000000..256dfd0d --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go @@ -0,0 +1,370 @@ +// Copyright (c) 2024 Sumner Evans +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package verificationhelper_test + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/verificationhelper" + "maunium.net/go/mautrix/event" +) + +func TestSelfVerification_Accept_QRContents(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + testCases := []struct { + sendingGeneratedCrossSigningKeys bool + receivingGeneratedCrossSigningKeys bool + expectedAcceptError string + }{ + {true, false, ""}, + {false, true, ""}, + {false, false, "failed to get own cross-signing master public key"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("sendingGenerated=%t receivingGenerated=%t err=%s", tc.sendingGeneratedCrossSigningKeys, tc.receivingGeneratedCrossSigningKeys, tc.expectedAcceptError), func(t *testing.T) { + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + var sendingRecoveryKey, receivingRecoveryKey string + var sendingCrossSigningKeysCache, receivingCrossSigningKeysCache *crypto.CrossSigningKeysCache + + if tc.sendingGeneratedCrossSigningKeys { + sendingRecoveryKey, sendingCrossSigningKeysCache, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + assert.NotEmpty(t, sendingRecoveryKey) + assert.NotNil(t, sendingCrossSigningKeysCache) + } + + if tc.receivingGeneratedCrossSigningKeys { + receivingRecoveryKey, receivingCrossSigningKeysCache, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + assert.NotEmpty(t, receivingRecoveryKey) + assert.NotNil(t, receivingCrossSigningKeysCache) + } + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + + err = receivingHelper.AcceptVerification(ctx, txnID) + if tc.expectedAcceptError != "" { + assert.ErrorContains(t, err, tc.expectedAcceptError) + return + } else { + require.NoError(t, err) + } + + ts.DispatchToDevice(t, ctx, sendingClient) + + receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, receivingShownQRCode) + assert.NotEmpty(t, receivingShownQRCode.SharedSecret) + assert.Equal(t, txnID, receivingShownQRCode.TransactionID) + + sendingShownQRCode := sendingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, sendingShownQRCode) + assert.NotEmpty(t, sendingShownQRCode.SharedSecret) + assert.Equal(t, txnID, sendingShownQRCode.TransactionID) + + // See the spec for the QR Code format: + // https://spec.matrix.org/v1.10/client-server-api/#qr-code-format + if tc.receivingGeneratedCrossSigningKeys { + masterKeyBytes := exerrors.Must(receivingMachine.GetOwnCrossSigningPublicKeys(ctx)).MasterKey.Bytes() + + // The receiving device should have shown a QR Code with + // trusted mode + assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyTrusted, receivingShownQRCode.Mode) + assert.EqualValues(t, masterKeyBytes, receivingShownQRCode.Key1) // master key + assert.EqualValues(t, sendingMachine.OwnIdentity().SigningKey.Bytes(), receivingShownQRCode.Key2) // other device key + + // The sending device should have shown a QR code with + // untrusted mode. + assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyUntrusted, sendingShownQRCode.Mode) + assert.EqualValues(t, sendingMachine.OwnIdentity().SigningKey.Bytes(), sendingShownQRCode.Key1) // own device key + assert.EqualValues(t, masterKeyBytes, sendingShownQRCode.Key2) // master key + } else if tc.sendingGeneratedCrossSigningKeys { + masterKeyBytes := exerrors.Must(sendingMachine.GetOwnCrossSigningPublicKeys(ctx)).MasterKey.Bytes() + + // The receiving device should have shown a QR code with + // untrusted mode + assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyUntrusted, receivingShownQRCode.Mode) + assert.EqualValues(t, receivingMachine.OwnIdentity().SigningKey.Bytes(), receivingShownQRCode.Key1) // own device key + assert.EqualValues(t, masterKeyBytes, receivingShownQRCode.Key2) // master key + + // The sending device should have shown a QR code with trusted + // mode. + assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyTrusted, sendingShownQRCode.Mode) + assert.EqualValues(t, masterKeyBytes, sendingShownQRCode.Key1) // master key + assert.EqualValues(t, receivingMachine.OwnIdentity().SigningKey.Bytes(), sendingShownQRCode.Key2) // other device key + } + }) + } +} + +func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + testCases := []struct { + sendingGeneratedCrossSigningKeys bool + sendingScansQR bool // false indicates that receiving device should emulate a scan + }{ + {false, false}, + {false, true}, + {true, false}, + {true, true}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("sendingGeneratedCrossSigningKeys=%t sendingScansQR=%t", tc.sendingGeneratedCrossSigningKeys, tc.sendingScansQR), func(t *testing.T) { + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + if tc.sendingGeneratedCrossSigningKeys { + _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + } else { + _, _, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + } + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) + + receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, receivingShownQRCode) + sendingShownQRCode := sendingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, sendingShownQRCode) + + if tc.sendingScansQR { + // Emulate scanning the QR code shown by the receiving device + // on the sending device. + err := sendingHelper.HandleScannedQRData(ctx, receivingShownQRCode.Bytes()) + require.NoError(t, err) + + // Ensure that the receiving device received a verification + // start event and a verification done event. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 2) + + startEvt := receivingInbox[0].Content.AsVerificationStart() + assert.Equal(t, txnID, startEvt.TransactionID) + assert.Equal(t, sendingDeviceID, startEvt.FromDevice) + assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) + assert.EqualValues(t, receivingShownQRCode.SharedSecret, startEvt.Secret) + + doneEvt := receivingInbox[1].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + // Handle the start and done events on the receiving client and + // confirm the scan. + ts.DispatchToDevice(t, ctx, receivingClient) + + // Ensure that the receiving device detected that its QR code + // was scanned. + assert.True(t, receivingCallbacks.WasOurQRCodeScanned(txnID)) + err = receivingHelper.ConfirmQRCodeScanned(ctx, txnID) + require.NoError(t, err) + + // Ensure that the sending device received a verification done + // event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + require.Len(t, sendingInbox, 1) + doneEvt = sendingInbox[0].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + ts.DispatchToDevice(t, ctx, sendingClient) + } else { // receiving scans QR + // Emulate scanning the QR code shown by the sending device on + // the receiving device. + err := receivingHelper.HandleScannedQRData(ctx, sendingShownQRCode.Bytes()) + require.NoError(t, err) + + // Ensure that the sending device received a verification + // start event and a verification done event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 2) + + startEvt := sendingInbox[0].Content.AsVerificationStart() + assert.Equal(t, txnID, startEvt.TransactionID) + assert.Equal(t, receivingDeviceID, startEvt.FromDevice) + assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) + assert.EqualValues(t, sendingShownQRCode.SharedSecret, startEvt.Secret) + + doneEvt := sendingInbox[1].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + // Handle the start and done events on the receiving client and + // confirm the scan. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Ensure that the sending device detected that its QR code was + // scanned. + assert.True(t, sendingCallbacks.WasOurQRCodeScanned(txnID)) + err = sendingHelper.ConfirmQRCodeScanned(ctx, txnID) + require.NoError(t, err) + + // Ensure that the receiving device received a verification + // done event. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + require.Len(t, receivingInbox, 1) + doneEvt = receivingInbox[0].Content.AsVerificationDone() + assert.Equal(t, txnID, doneEvt.TransactionID) + + ts.DispatchToDevice(t, ctx, receivingClient) + } + + // Ensure that both devices have marked the verification as done. + assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) + assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) + }) + } +} + +func TestSelfVerification_ScanQRTransactionIDCorrupted(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) + + receivingShownQRCodeBytes := receivingCallbacks.GetQRCodeShown(txnID).Bytes() + sendingShownQRCodeBytes := sendingCallbacks.GetQRCodeShown(txnID).Bytes() + + // Corrupt the QR codes (the 20th byte should be in the transaction ID) + receivingShownQRCodeBytes[20]++ + sendingShownQRCodeBytes[20]++ + + // Emulate scanning the QR code shown by the receiving device + // on the sending device. + err = sendingHelper.HandleScannedQRData(ctx, receivingShownQRCodeBytes) + assert.ErrorContains(t, err, "unknown transaction ID") + + // Emulate scanning the QR code shown by the sending device on + // the receiving device. + err = receivingHelper.HandleScannedQRData(ctx, sendingShownQRCodeBytes) + assert.ErrorContains(t, err, "unknown transaction ID") +} + +func TestSelfVerification_ScanQRKeyCorrupted(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + testCases := []struct { + sendingGeneratedCrossSigningKeys bool + sendingScansQR bool // false indicates that receiving device should emulate a scan + corruptByte int + expectedError string + }{ + // The 50th byte should be in the first key + {false, false, 50, "the other device's key is not what we expected"}, // receiver scans sender QR code, sender doesn't trust the master key => mode 0x02 => key1 == sender device key + {false, true, 50, "the master key does not match"}, // sender scans receiver QR code, receiver trusts the master key => mode 0x01 => key1 == master key + {true, false, 50, "the master key does not match"}, // receiver scans sender QR code, sender trusts the master key => mode 0x01 => key1 == master key + {true, true, 50, "the other device's key is not what we expected"}, // sender scans receiver QR Code, receiver doesn't trust the master key => mode 0x02 => key1 == receiver device key + // The 100th byte should be in the second key + {false, false, 100, "the master key does not match"}, // receiver scans sender QR code, sender doesn't trust the master key => mode 0x02 => key2 == master key + {false, true, 100, "the other device has the wrong key for this device"}, // sender scans receiver QR code, receiver trusts the master key => mode 0x01 => key2 == sender device key + {true, false, 100, "the other device has the wrong key for this device"}, // receiver scans sender QR code, sender trusts the master key => mode 0x01 => key2 == receiver device key + {true, true, 100, "the master key does not match"}, // sender scans receiver QR Code, receiver doesn't trust the master key => mode 0x02 => key2 == master key + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("sendingGeneratedCrossSigningKeys=%t sendingScansQR=%t corrupt=%d", tc.sendingGeneratedCrossSigningKeys, tc.sendingScansQR, tc.corruptByte), func(t *testing.T) { + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + if tc.sendingGeneratedCrossSigningKeys { + _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + } else { + _, _, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + } + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) + + receivingShownQRCodeBytes := receivingCallbacks.GetQRCodeShown(txnID).Bytes() + sendingShownQRCodeBytes := sendingCallbacks.GetQRCodeShown(txnID).Bytes() + + // Corrupt the QR codes + receivingShownQRCodeBytes[tc.corruptByte]++ + sendingShownQRCodeBytes[tc.corruptByte]++ + + if tc.sendingScansQR { + // Emulate scanning the QR code shown by the receiving device + // on the sending device. + err := sendingHelper.HandleScannedQRData(ctx, receivingShownQRCodeBytes) + assert.ErrorContains(t, err, tc.expectedError) + + // Ensure that the receiving device received a cancellation. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + ts.DispatchToDevice(t, ctx, receivingClient) + cancellation := receivingCallbacks.GetVerificationCancellation(txnID) + require.NotNil(t, cancellation) + assert.Equal(t, event.VerificationCancelCodeKeyMismatch, cancellation.Code) + assert.Equal(t, tc.expectedError, cancellation.Reason) + } else { // receiving scans QR + // Emulate scanning the QR code shown by the sending device on + // the receiving device. + err := receivingHelper.HandleScannedQRData(ctx, sendingShownQRCodeBytes) + assert.ErrorContains(t, err, tc.expectedError) + + // Ensure that the sending device received a cancellation. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + ts.DispatchToDevice(t, ctx, sendingClient) + cancellation := sendingCallbacks.GetVerificationCancellation(txnID) + require.NotNil(t, cancellation) + assert.Equal(t, event.VerificationCancelCodeKeyMismatch, cancellation.Code) + assert.Equal(t, tc.expectedError, cancellation.Reason) + } + }) + } +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go new file mode 100644 index 00000000..283eca84 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go @@ -0,0 +1,360 @@ +package verificationhelper_test + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestVerification_SAS(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + testCases := []struct { + sendingGeneratedCrossSigningKeys bool + sendingStartsSAS bool + sendingConfirmsFirst bool + }{ + {true, true, true}, + {true, true, false}, + {true, false, true}, + {true, false, false}, + {false, true, true}, + {false, true, false}, + {false, false, true}, + {false, false, false}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("sendingGenerated=%t sendingStartsSAS=%t sendingConfirmsFirst=%t", tc.sendingGeneratedCrossSigningKeys, tc.sendingStartsSAS, tc.sendingConfirmsFirst), func(t *testing.T) { + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + var sendingRecoveryKey, receivingRecoveryKey string + var sendingCrossSigningKeysCache, receivingCrossSigningKeysCache *crypto.CrossSigningKeysCache + + if tc.sendingGeneratedCrossSigningKeys { + sendingRecoveryKey, sendingCrossSigningKeysCache, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + assert.NotEmpty(t, sendingRecoveryKey) + assert.NotNil(t, sendingCrossSigningKeysCache) + } else { + receivingRecoveryKey, receivingCrossSigningKeysCache, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + assert.NotEmpty(t, receivingRecoveryKey) + assert.NotNil(t, receivingCrossSigningKeysCache) + } + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) + + // Test that the start event is correct + var startEvt *event.VerificationStartEventContent + if tc.sendingStartsSAS { + err = sendingHelper.StartSAS(ctx, txnID) + require.NoError(t, err) + + // Ensure that the receiving device received a verification + // start event. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + startEvt = receivingInbox[0].Content.AsVerificationStart() + assert.Equal(t, sendingDeviceID, startEvt.FromDevice) + } else { + err = receivingHelper.StartSAS(ctx, txnID) + require.NoError(t, err) + + // Ensure that the receiving device received a verification + // start event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + startEvt = sendingInbox[0].Content.AsVerificationStart() + assert.Equal(t, receivingDeviceID, startEvt.FromDevice) + } + assert.Equal(t, txnID, startEvt.TransactionID) + assert.Equal(t, event.VerificationMethodSAS, startEvt.Method) + assert.Contains(t, startEvt.Hashes, event.VerificationHashMethodSHA256) + assert.Contains(t, startEvt.KeyAgreementProtocols, event.KeyAgreementProtocolCurve25519HKDFSHA256) + assert.Contains(t, startEvt.MessageAuthenticationCodes, event.MACMethodHKDFHMACSHA256) + assert.Contains(t, startEvt.MessageAuthenticationCodes, event.MACMethodHKDFHMACSHA256V2) + assert.Contains(t, startEvt.ShortAuthenticationString, event.SASMethodDecimal) + assert.Contains(t, startEvt.ShortAuthenticationString, event.SASMethodEmoji) + + // Test that the accept event is correct + var acceptEvt *event.VerificationAcceptEventContent + if tc.sendingStartsSAS { + // Process the verification start event on the receiving + // device. + ts.DispatchToDevice(t, ctx, receivingClient) + + // Receiving device sent the accept event to the sending device + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + acceptEvt = sendingInbox[0].Content.AsVerificationAccept() + } else { + // Process the verification start event on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Sending device sent the accept event to the receiving device + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + acceptEvt = receivingInbox[0].Content.AsVerificationAccept() + } + assert.Equal(t, txnID, acceptEvt.TransactionID) + assert.Equal(t, acceptEvt.Hash, event.VerificationHashMethodSHA256) + assert.Equal(t, acceptEvt.KeyAgreementProtocol, event.KeyAgreementProtocolCurve25519HKDFSHA256) + assert.Equal(t, acceptEvt.MessageAuthenticationCode, event.MACMethodHKDFHMACSHA256V2) + assert.Contains(t, acceptEvt.ShortAuthenticationString, event.SASMethodDecimal) + assert.Contains(t, acceptEvt.ShortAuthenticationString, event.SASMethodEmoji) + assert.NotEmpty(t, acceptEvt.Commitment) + + // Test that the first key event is correct + var firstKeyEvt *event.VerificationKeyEventContent + if tc.sendingStartsSAS { + // Process the verification accept event on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Sending device sends first key event to the receiving + // device. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + firstKeyEvt = receivingInbox[0].Content.AsVerificationKey() + } else { + // Process the verification accept event on the receiving + // device. + ts.DispatchToDevice(t, ctx, receivingClient) + + // Receiving device sends first key event to the sending + // device. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + firstKeyEvt = sendingInbox[0].Content.AsVerificationKey() + } + assert.Equal(t, txnID, firstKeyEvt.TransactionID) + assert.NotEmpty(t, firstKeyEvt.Key) + assert.Len(t, firstKeyEvt.Key, 32) + + // Test that the second key event is correct + var secondKeyEvt *event.VerificationKeyEventContent + if tc.sendingStartsSAS { + // Process the first key event on the receiving device. + ts.DispatchToDevice(t, ctx, receivingClient) + + // Receiving device sends second key event to the sending + // device. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + secondKeyEvt = sendingInbox[0].Content.AsVerificationKey() + + // Ensure that the receiving device showed emojis and SAS numbers. + assert.Len(t, receivingCallbacks.GetDecimalsShown(txnID), 3) + emojis, descriptions := receivingCallbacks.GetEmojisAndDescriptionsShown(txnID) + assert.Len(t, emojis, 7) + assert.Len(t, descriptions, 7) + } else { + // Process the first key event on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Sending device sends second key event to the receiving + // device. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + secondKeyEvt = receivingInbox[0].Content.AsVerificationKey() + + // Ensure that the sending device showed emojis and SAS numbers. + assert.Len(t, sendingCallbacks.GetDecimalsShown(txnID), 3) + emojis, descriptions := sendingCallbacks.GetEmojisAndDescriptionsShown(txnID) + assert.Len(t, emojis, 7) + assert.Len(t, descriptions, 7) + } + assert.Equal(t, txnID, secondKeyEvt.TransactionID) + assert.NotEmpty(t, secondKeyEvt.Key) + assert.Len(t, secondKeyEvt.Key, 32) + + // Ensure that the SAS codes are the same. + if tc.sendingStartsSAS { + // Process the second key event on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + } else { + // Process the second key event on the receiving device. + ts.DispatchToDevice(t, ctx, receivingClient) + } + assert.Equal(t, sendingCallbacks.GetDecimalsShown(txnID), receivingCallbacks.GetDecimalsShown(txnID)) + sendingEmojis, sendingDescriptions := sendingCallbacks.GetEmojisAndDescriptionsShown(txnID) + receivingEmojis, receivingDescriptions := receivingCallbacks.GetEmojisAndDescriptionsShown(txnID) + assert.Equal(t, sendingEmojis, receivingEmojis) + assert.Equal(t, sendingDescriptions, receivingDescriptions) + + // Test that the first MAC event is correct + var firstMACEvt *event.VerificationMACEventContent + if tc.sendingConfirmsFirst { + err = sendingHelper.ConfirmSAS(ctx, txnID) + require.NoError(t, err) + + // The receiving device should have received the MAC event. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + firstMACEvt = receivingInbox[0].Content.AsVerificationMAC() + + // The MAC event should have a MAC for the sending device ID. + assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingDeviceID.String())) + } else { + err = receivingHelper.ConfirmSAS(ctx, txnID) + require.NoError(t, err) + + // The sending device should have received the MAC event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + firstMACEvt = sendingInbox[0].Content.AsVerificationMAC() + + // The MAC event should have a MAC for the receiving device ID. + assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingDeviceID.String())) + } + assert.Equal(t, txnID, firstMACEvt.TransactionID) + + // The master key and the sending device ID should be in the + // MAC event's mac keys. + if tc.sendingGeneratedCrossSigningKeys { + assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingCrossSigningKeysCache.MasterKey.PublicKey().String())) + } else { + assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingCrossSigningKeysCache.MasterKey.PublicKey().String())) + } + + // Test that the second MAC event is correct + var secondMACEvt *event.VerificationMACEventContent + if tc.sendingConfirmsFirst { + err = receivingHelper.ConfirmSAS(ctx, txnID) + require.NoError(t, err) + + // The sending device should have received the MAC event. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + secondMACEvt = sendingInbox[0].Content.AsVerificationMAC() + + // The MAC event should have a MAC for the receiving device ID. + assert.Contains(t, maps.Keys(secondMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingDeviceID.String())) + } else { + err = sendingHelper.ConfirmSAS(ctx, txnID) + require.NoError(t, err) + + // The receiving device should have received the MAC event. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + secondMACEvt = receivingInbox[0].Content.AsVerificationMAC() + + // The MAC event should have a MAC for the sending device ID. + assert.Contains(t, maps.Keys(secondMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingDeviceID.String())) + } + assert.Equal(t, txnID, secondMACEvt.TransactionID) + + // The master key and the sending device ID should be in the + // MAC event's mac keys. + if tc.sendingGeneratedCrossSigningKeys { + assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingCrossSigningKeysCache.MasterKey.PublicKey().String())) + } else { + assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingCrossSigningKeysCache.MasterKey.PublicKey().String())) + } + + // Test the transaction is done on both sides. We have to dispatch + // twice to process and drain all of the events. + ts.DispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, sendingClient) + ts.DispatchToDevice(t, ctx, receivingClient) + assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) + assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) + }) + } +} + +func TestVerification_SAS_BothCallStart(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + var err error + + var sendingRecoveryKey string + var sendingCrossSigningKeysCache *crypto.CrossSigningKeysCache + + sendingRecoveryKey, sendingCrossSigningKeysCache, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + assert.NotEmpty(t, sendingRecoveryKey) + assert.NotNil(t, sendingCrossSigningKeysCache) + + // Send the verification request from the sender device and accept + // it on the receiving device and receive the verification ready + // event on the sending device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) + + err = sendingHelper.StartSAS(ctx, txnID) + require.NoError(t, err) + + err = receivingHelper.StartSAS(ctx, txnID) + require.NoError(t, err) + + // Ensure that both devices have received the verification start event. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationStart().TransactionID) + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + assert.Equal(t, txnID, sendingInbox[0].Content.AsVerificationStart().TransactionID) + + // Process the start event from the receiving client to the sending client. + ts.DispatchToDevice(t, ctx, sendingClient) + receivingInbox = ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 2) + assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationStart().TransactionID) + assert.Equal(t, txnID, receivingInbox[1].Content.AsVerificationAccept().TransactionID) + + // Process the rest of the events until we need to confirm the SAS. + for len(ts.DeviceInbox[aliceUserID][sendingDeviceID]) > 0 || len(ts.DeviceInbox[aliceUserID][receivingDeviceID]) > 0 { + ts.DispatchToDevice(t, ctx, receivingClient) + ts.DispatchToDevice(t, ctx, sendingClient) + } + + // Confirm the SAS only the receiving device. + receivingHelper.ConfirmSAS(ctx, txnID) + ts.DispatchToDevice(t, ctx, sendingClient) + + // Verification is not done until both devices confirm the SAS. + assert.False(t, sendingCallbacks.IsVerificationDone(txnID)) + assert.False(t, receivingCallbacks.IsVerificationDone(txnID)) + + // Now, confirm it on the sending device. + sendingHelper.ConfirmSAS(ctx, txnID) + + // Dispatching the events to the receiving device should get us to the done + // state on the receiving device. + ts.DispatchToDevice(t, ctx, receivingClient) + assert.False(t, sendingCallbacks.IsVerificationDone(txnID)) + assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) + + // Dispatching the events to the sending client should get us to the done + // state on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) + assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_test.go new file mode 100644 index 00000000..ce5ec5b4 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationhelper_test.go @@ -0,0 +1,517 @@ +package verificationhelper_test + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/crypto/verificationhelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mockserver" +) + +var aliceUserID = id.UserID("@alice:example.org") +var bobUserID = id.UserID("@bob:example.org") +var sendingDeviceID = id.DeviceID("sending") +var receivingDeviceID = id.DeviceID("receiving") + +func init() { + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.TraceLevel) + zerolog.DefaultContextLogger = &log.Logger +} + +func addDeviceID(ctx context.Context, cryptoStore crypto.Store, userID id.UserID, deviceID id.DeviceID) { + err := cryptoStore.PutDevice(ctx, userID, &id.Device{ + UserID: userID, + DeviceID: deviceID, + }) + if err != nil { + panic(err) + } +} + +func initServerAndLoginTwoAlice(t *testing.T, ctx context.Context) (ts *mockserver.MockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { + t.Helper() + ts = mockserver.Create(t) + + sendingClient, sendingCryptoStore = ts.Login(t, ctx, aliceUserID, sendingDeviceID) + sendingMachine = sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() + receivingClient, receivingCryptoStore = ts.Login(t, ctx, aliceUserID, receivingDeviceID) + receivingMachine = receivingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() + + require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) + require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, receivingMachine.OwnIdentity())) + require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) + require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, receivingMachine.OwnIdentity())) + return +} + +func initServerAndLoginAliceBob(t *testing.T, ctx context.Context) (ts *mockserver.MockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { + t.Helper() + ts = mockserver.Create(t) + + sendingClient, sendingCryptoStore = ts.Login(t, ctx, aliceUserID, sendingDeviceID) + sendingMachine = sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() + receivingClient, receivingCryptoStore = ts.Login(t, ctx, bobUserID, receivingDeviceID) + receivingMachine = receivingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() + + require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) + require.NoError(t, sendingCryptoStore.PutDevice(ctx, bobUserID, receivingMachine.OwnIdentity())) + require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) + require.NoError(t, receivingCryptoStore.PutDevice(ctx, bobUserID, receivingMachine.OwnIdentity())) + return +} + +func initDefaultCallbacks(t *testing.T, ctx context.Context, sendingClient, receivingClient *mautrix.Client, sendingMachine, receivingMachine *crypto.OlmMachine) (sendingCallbacks, receivingCallbacks *allVerificationCallbacks, sendingHelper, receivingHelper *verificationhelper.VerificationHelper) { + t.Helper() + sendingCallbacks = newAllVerificationCallbacks() + senderVerificationDB, err := sql.Open("sqlite3", ":memory:") + require.NoError(t, err) + senderVerificationStore, err := NewSQLiteVerificationStore(ctx, senderVerificationDB) + require.NoError(t, err) + + sendingHelper = verificationhelper.NewVerificationHelper(sendingClient, sendingMachine, senderVerificationStore, sendingCallbacks, true, true, true) + require.NoError(t, sendingHelper.Init(ctx)) + + receivingCallbacks = newAllVerificationCallbacks() + receiverVerificationDB, err := sql.Open("sqlite3", ":memory:") + require.NoError(t, err) + receiverVerificationStore, err := NewSQLiteVerificationStore(ctx, receiverVerificationDB) + require.NoError(t, err) + receivingHelper = verificationhelper.NewVerificationHelper(receivingClient, receivingMachine, receiverVerificationStore, receivingCallbacks, true, true, true) + require.NoError(t, receivingHelper.Init(ctx)) + return +} + +func TestVerification_Start(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + receivingDeviceID2 := id.DeviceID("receiving2") + + testCases := []struct { + supportsShow bool + supportsScan bool + supportsSAS bool + callbacks MockVerificationCallbacks + startVerificationErrMsg string + expectedVerificationMethods []event.VerificationMethod + }{ + {false, false, false, newBaseVerificationCallbacks(), "no supported verification methods", nil}, + {false, true, false, newBaseVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, + + {false, false, false, newShowQRCodeVerificationCallbacks(), "no supported verification methods", nil}, + {true, false, false, newShowQRCodeVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeShow, event.VerificationMethodReciprocate}}, + {false, true, false, newShowQRCodeVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, + {true, true, false, newShowQRCodeVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeShow, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, + + {false, false, true, newSASVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS}}, + {false, true, true, newSASVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, + + {false, false, false, newAllVerificationCallbacks(), "no supported verification methods", nil}, + {false, false, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS}}, + {false, true, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, + {true, false, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeShow, event.VerificationMethodReciprocate}}, + {true, true, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeShow, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + ts := mockserver.Create(t) + + client, cryptoStore := ts.Login(t, ctx, aliceUserID, sendingDeviceID) + addDeviceID(ctx, cryptoStore, aliceUserID, sendingDeviceID) + addDeviceID(ctx, cryptoStore, aliceUserID, receivingDeviceID) + addDeviceID(ctx, cryptoStore, aliceUserID, receivingDeviceID2) + + senderHelper := verificationhelper.NewVerificationHelper(client, client.Crypto.(*cryptohelper.CryptoHelper).Machine(), nil, tc.callbacks, tc.supportsShow, tc.supportsScan, tc.supportsSAS) + err := senderHelper.Init(ctx) + require.NoError(t, err) + + txnID, err := senderHelper.StartVerification(ctx, aliceUserID) + if tc.startVerificationErrMsg != "" { + assert.ErrorContains(t, err, tc.startVerificationErrMsg) + return + } + + require.NoError(t, err) + assert.NotEmpty(t, txnID) + + toDeviceInbox := ts.DeviceInbox[aliceUserID] + + // Ensure that we didn't send a verification request to the + // sending device. + assert.Empty(t, toDeviceInbox[sendingDeviceID]) + + // Ensure that the verification request was sent to both of + // the other devices. + assert.NotEmpty(t, toDeviceInbox[receivingDeviceID]) + assert.NotEmpty(t, toDeviceInbox[receivingDeviceID2]) + assert.Equal(t, toDeviceInbox[receivingDeviceID], toDeviceInbox[receivingDeviceID2]) + require.Len(t, toDeviceInbox[receivingDeviceID], 1) + + // Ensure that the verification request is correct. + verificationRequest := toDeviceInbox[receivingDeviceID][0].Content.AsVerificationRequest() + assert.Equal(t, sendingDeviceID, verificationRequest.FromDevice) + assert.Equal(t, txnID, verificationRequest.TransactionID) + assert.ElementsMatch(t, tc.expectedVerificationMethods, verificationRequest.Methods) + }) + } +} + +func TestVerification_StartThenCancel(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + bystanderDeviceID := id.DeviceID("bystander") + + for _, sendingCancels := range []bool{true, false} { + t.Run(fmt.Sprintf("sendingCancels=%t", sendingCancels), func(t *testing.T) { + ts, sendingClient, receivingClient, sendingCryptoStore, receivingCryptoStore, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + + bystanderClient, _ := ts.Login(t, ctx, aliceUserID, bystanderDeviceID) + bystanderMachine := bystanderClient.Crypto.(*cryptohelper.CryptoHelper).Machine() + bystanderHelper := verificationhelper.NewVerificationHelper(bystanderClient, bystanderMachine, nil, newAllVerificationCallbacks(), true, true, true) + require.NoError(t, bystanderHelper.Init(ctx)) + + require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, bystanderMachine.OwnIdentity())) + require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, bystanderMachine.OwnIdentity())) + + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + + assert.Empty(t, ts.DeviceInbox[aliceUserID][sendingDeviceID]) + + // Process the request event on the receiving device. + receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] + assert.Len(t, receivingInbox, 1) + assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationRequest().TransactionID) + ts.DispatchToDevice(t, ctx, receivingClient) + + // Process the request event on the bystander device. + bystanderInbox := ts.DeviceInbox[aliceUserID][bystanderDeviceID] + assert.Len(t, bystanderInbox, 1) + assert.Equal(t, txnID, bystanderInbox[0].Content.AsVerificationRequest().TransactionID) + ts.DispatchToDevice(t, ctx, bystanderClient) + + // Cancel the verification request. + var cancelEvt *event.VerificationCancelEventContent + if sendingCancels { + err = sendingHelper.CancelVerification(ctx, txnID, event.VerificationCancelCodeUser, "Recovery code preferred") + assert.NoError(t, err) + + // The sending device should not have a cancellation event. + assert.Empty(t, ts.DeviceInbox[aliceUserID][sendingDeviceID]) + + // Ensure that the cancellation event was sent to the receiving device. + assert.Len(t, ts.DeviceInbox[aliceUserID][receivingDeviceID], 1) + cancelEvt = ts.DeviceInbox[aliceUserID][receivingDeviceID][0].Content.AsVerificationCancel() + + // Ensure that the cancellation event was sent to the bystander device. + assert.Len(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID], 1) + bystanderCancelEvt := ts.DeviceInbox[aliceUserID][bystanderDeviceID][0].Content.AsVerificationCancel() + assert.Equal(t, cancelEvt, bystanderCancelEvt) + } else { + err = receivingHelper.CancelVerification(ctx, txnID, event.VerificationCancelCodeUser, "Recovery code preferred") + assert.NoError(t, err) + + // The receiving device should not have a cancellation event. + assert.Empty(t, ts.DeviceInbox[aliceUserID][receivingDeviceID]) + + // Ensure that the cancellation event was sent to the sending device. + assert.Len(t, ts.DeviceInbox[aliceUserID][sendingDeviceID], 1) + cancelEvt = ts.DeviceInbox[aliceUserID][sendingDeviceID][0].Content.AsVerificationCancel() + + // The bystander device should not have a cancellation event. + assert.Empty(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID]) + } + assert.Equal(t, txnID, cancelEvt.TransactionID) + assert.Equal(t, event.VerificationCancelCodeUser, cancelEvt.Code) + assert.Equal(t, "Recovery code preferred", cancelEvt.Reason) + + if !sendingCancels { + // Process the cancellation event on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Ensure that the cancellation event was sent to the bystander device. + assert.Len(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID], 1) + bystanderCancelEvt := ts.DeviceInbox[aliceUserID][bystanderDeviceID][0].Content.AsVerificationCancel() + assert.Equal(t, txnID, bystanderCancelEvt.TransactionID) + assert.Equal(t, event.VerificationCancelCodeUser, bystanderCancelEvt.Code) + assert.Equal(t, "The verification was rejected from another device.", bystanderCancelEvt.Reason) + } + }) + } +} + +func TestVerification_Accept_NoSupportedMethods(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + ts := mockserver.Create(t) + + sendingClient, sendingCryptoStore := ts.Login(t, ctx, aliceUserID, sendingDeviceID) + receivingClient, _ := ts.Login(t, ctx, aliceUserID, receivingDeviceID) + addDeviceID(ctx, sendingCryptoStore, aliceUserID, sendingDeviceID) + addDeviceID(ctx, sendingCryptoStore, aliceUserID, receivingDeviceID) + + sendingMachine := sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() + recoveryKey, cache, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + assert.NoError(t, err) + assert.NotEmpty(t, recoveryKey) + assert.NotNil(t, cache) + + sendingHelper := verificationhelper.NewVerificationHelper(sendingClient, sendingMachine, nil, newAllVerificationCallbacks(), true, true, true) + err = sendingHelper.Init(ctx) + require.NoError(t, err) + + receivingCallbacks := newBaseVerificationCallbacks() + receivingHelper := verificationhelper.NewVerificationHelper(receivingClient, receivingClient.Crypto.(*cryptohelper.CryptoHelper).Machine(), nil, receivingCallbacks, false, false, false) + err = receivingHelper.Init(ctx) + require.NoError(t, err) + + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + require.NotEmpty(t, txnID) + + ts.DispatchToDevice(t, ctx, receivingClient) + + // Ensure that the receiver ignored the request because it + // doesn't support any of the verification methods in the + // request. + assert.Empty(t, receivingCallbacks.GetRequestedVerifications()) +} + +func TestVerification_Accept_CorrectMethodsPresented(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + + testCases := []struct { + sendingSupportsScan bool + sendingSupportsShow bool + receivingSupportsScan bool + receivingSupportsShow bool + sendingSupportsSAS bool + receivingSupportsSAS bool + sendingCallbacks MockVerificationCallbacks + receivingCallbacks MockVerificationCallbacks + expectedVerificationMethods []event.VerificationMethod + }{ + // TODO + {false, false, false, false, true, true, newSASVerificationCallbacks(), newSASVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodSAS}}, + {true, false, true, false, true, true, newSASVerificationCallbacks(), newSASVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodSAS}}, + + {true, false, false, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeShow}}, + {false, true, true, false, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan}}, + {true, false, true, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeShow}}, + {false, true, true, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan}}, + {true, true, true, false, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan}}, + {true, true, false, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeShow}}, + {true, true, true, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan, event.VerificationMethodQRCodeShow}}, + + {true, true, true, true, true, true, newAllVerificationCallbacks(), newAllVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan, event.VerificationMethodQRCodeShow}}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + + recoveryKey, sendingCrossSigningKeysCache, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + assert.NoError(t, err) + assert.NotEmpty(t, recoveryKey) + assert.NotNil(t, sendingCrossSigningKeysCache) + + sendingHelper := verificationhelper.NewVerificationHelper(sendingClient, sendingMachine, nil, tc.sendingCallbacks, tc.sendingSupportsShow, tc.sendingSupportsScan, tc.sendingSupportsSAS) + err = sendingHelper.Init(ctx) + require.NoError(t, err) + + receivingHelper := verificationhelper.NewVerificationHelper(receivingClient, receivingMachine, nil, tc.receivingCallbacks, tc.receivingSupportsShow, tc.receivingSupportsScan, tc.receivingSupportsSAS) + err = receivingHelper.Init(ctx) + require.NoError(t, err) + + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + + // Process the verification request on the receiving device. + ts.DispatchToDevice(t, ctx, receivingClient) + + // Ensure that the receiving device received a verification + // request with the correct transaction ID. + assert.ElementsMatch(t, []id.VerificationTransactionID{txnID}, tc.receivingCallbacks.GetRequestedVerifications()[aliceUserID]) + + // Have the receiving device accept the verification request. + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + + // Ensure that the receiving device get a notification about the + // transaction being ready. + assert.Contains(t, tc.receivingCallbacks.GetVerificationsReadyTransactions(), txnID) + + // Ensure that if the receiving device should show a QR code that + // it has the correct content. + if tc.sendingSupportsScan && tc.receivingSupportsShow { + receivingShownQRCode := tc.receivingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, receivingShownQRCode) + assert.Equal(t, txnID, receivingShownQRCode.TransactionID) + assert.NotEmpty(t, receivingShownQRCode.SharedSecret) + } + + // Check for whether the receiving device should be scanning a QR + // code. + if tc.receivingSupportsScan && tc.sendingSupportsShow { + assert.Contains(t, tc.receivingCallbacks.GetScanQRCodeTransactions(), txnID) + } + + // Check that the m.key.verification.ready event has the correct + // content. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + assert.Len(t, sendingInbox, 1) + readyEvt := sendingInbox[0].Content.AsVerificationReady() + assert.Equal(t, txnID, readyEvt.TransactionID) + assert.Equal(t, receivingDeviceID, readyEvt.FromDevice) + assert.ElementsMatch(t, tc.expectedVerificationMethods, readyEvt.Methods) + + // Receive the m.key.verification.ready event on the sending + // device. + ts.DispatchToDevice(t, ctx, sendingClient) + + // Ensure that the sending device got a notification about the + // transaction being ready. + assert.Contains(t, tc.sendingCallbacks.GetVerificationsReadyTransactions(), txnID) + + // Ensure that if the sending device should show a QR code that it + // has the correct content. + if tc.receivingSupportsScan && tc.sendingSupportsShow { + sendingShownQRCode := tc.sendingCallbacks.GetQRCodeShown(txnID) + require.NotNil(t, sendingShownQRCode) + assert.Equal(t, txnID, sendingShownQRCode.TransactionID) + assert.NotEmpty(t, sendingShownQRCode.SharedSecret) + } + + // Check for whether the sending device should be scanning a QR + // code. + if tc.sendingSupportsScan && tc.receivingSupportsShow { + assert.Contains(t, tc.sendingCallbacks.GetScanQRCodeTransactions(), txnID) + } + }) + } +} + +// TestAcceptSelfVerificationCancelOnNonParticipatingDevices ensures that we do +// not regress https://github.com/mautrix/go/pull/230. +func TestVerification_Accept_CancelOnNonParticipatingDevices(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + ts, sendingClient, receivingClient, sendingCryptoStore, receivingCryptoStore, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + + nonParticipatingDeviceID1 := id.DeviceID("non-participating1") + nonParticipatingDeviceID2 := id.DeviceID("non-participating2") + addDeviceID(ctx, sendingCryptoStore, aliceUserID, nonParticipatingDeviceID1) + addDeviceID(ctx, sendingCryptoStore, aliceUserID, nonParticipatingDeviceID2) + addDeviceID(ctx, receivingCryptoStore, aliceUserID, nonParticipatingDeviceID1) + addDeviceID(ctx, receivingCryptoStore, aliceUserID, nonParticipatingDeviceID2) + + _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + assert.NoError(t, err) + + // Send the verification request from the sender device and accept it on + // the receiving device. + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + + // Receive the m.key.verification.ready event on the sending device. + ts.DispatchToDevice(t, ctx, sendingClient) + + // The sending and receiving devices should not have any cancellation + // events in their inboxes. + assert.Empty(t, ts.DeviceInbox[aliceUserID][sendingDeviceID]) + assert.Empty(t, ts.DeviceInbox[aliceUserID][receivingDeviceID]) + + // There should now be cancellation events in the non-participating devices + // inboxes (in addition to the request event). + assert.Len(t, ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID1], 2) + assert.Len(t, ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID2], 2) + assert.Equal(t, ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID1][1], ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID2][1]) + cancellationEvent := ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID1][1].Content.AsVerificationCancel() + assert.Equal(t, txnID, cancellationEvent.TransactionID) + assert.Equal(t, event.VerificationCancelCodeAccepted, cancellationEvent.Code) +} + +func TestVerification_ErrorOnDoubleAccept(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + + _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + + txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID) + require.NoError(t, err) + err = receivingHelper.AcceptVerification(ctx, txnID) + assert.ErrorContains(t, err, "transaction is not in the requested state") +} + +// TestVerification_CancelOnDoubleStart ensures that the receiving device +// cancels both transactions if the sending device starts two verifications. +// +// This test ensures that the following bullet point from [Section 10.12.2.2.1 +// of the Spec] is followed: +// +// - When the same device attempts to initiate multiple verification attempts, +// the recipient should cancel all attempts with that device. +// +// [Section 10.12.2.2.1 of the Spec]: https://spec.matrix.org/v1.10/client-server-api/#error-and-exception-handling +func TestVerification_CancelOnDoubleStart(t *testing.T) { + ctx := log.Logger.WithContext(context.TODO()) + ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) + sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) + + _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") + require.NoError(t, err) + + // Send and accept the first verification request. + txnID1, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + err = receivingHelper.AcceptVerification(ctx, txnID1) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.ready event + + // Send a second verification request + txnID2, err := sendingHelper.StartVerification(ctx, aliceUserID) + require.NoError(t, err) + ts.DispatchToDevice(t, ctx, receivingClient) + + // Ensure that the sending device received a cancellation event for both of + // the ongoing transactions. + sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] + require.Len(t, sendingInbox, 2) + cancelEvt1 := sendingInbox[0].Content.AsVerificationCancel() + cancelEvt2 := sendingInbox[1].Content.AsVerificationCancel() + cancelledTxnIDs := []id.VerificationTransactionID{cancelEvt1.TransactionID, cancelEvt2.TransactionID} + assert.Contains(t, cancelledTxnIDs, txnID1) + assert.Contains(t, cancelledTxnIDs, txnID2) + assert.Equal(t, event.VerificationCancelCodeUnexpectedMessage, cancelEvt1.Code) + assert.Equal(t, event.VerificationCancelCodeUnexpectedMessage, cancelEvt2.Code) + assert.Equal(t, "received multiple verification requests from the same device", cancelEvt1.Reason) + assert.Equal(t, "received multiple verification requests from the same device", cancelEvt2.Reason) + + assert.NotNil(t, receivingCallbacks.GetVerificationCancellation(txnID1)) + assert.NotNil(t, receivingCallbacks.GetVerificationCancellation(txnID2)) + ts.DispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.cancel events + assert.NotNil(t, sendingCallbacks.GetVerificationCancellation(txnID1)) + assert.NotNil(t, sendingCallbacks.GetVerificationCancellation(txnID2)) +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationstore.go b/mautrix-patched/crypto/verificationhelper/verificationstore.go new file mode 100644 index 00000000..1eb8f752 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationstore.go @@ -0,0 +1,159 @@ +package verificationhelper + +import ( + "context" + "errors" + "fmt" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ErrUnknownVerificationTransaction = errors.New("unknown transaction ID") + +type VerificationState int + +const ( + VerificationStateRequested VerificationState = iota + VerificationStateReady + + VerificationStateTheirQRScanned // We scanned their QR code + VerificationStateOurQRScanned // They scanned our QR code + + VerificationStateSASStarted // An SAS verification has been started + VerificationStateSASAccepted // An SAS verification has been accepted + VerificationStateSASKeysExchanged // An SAS verification has exchanged keys + VerificationStateSASMACExchanged // An SAS verification has exchanged MACs +) + +func (step VerificationState) String() string { + switch step { + case VerificationStateRequested: + return "requested" + case VerificationStateReady: + return "ready" + case VerificationStateTheirQRScanned: + return "their_qr_scanned" + case VerificationStateOurQRScanned: + return "our_qr_scanned" + case VerificationStateSASStarted: + return "sas_started" + case VerificationStateSASAccepted: + return "sas_accepted" + case VerificationStateSASKeysExchanged: + return "sas_keys_exchanged" + case VerificationStateSASMACExchanged: + return "sas_mac" + default: + return fmt.Sprintf("VerificationState(%d)", step) + } +} + +type VerificationTransaction struct { + ExpirationTime jsontime.UnixMilli `json:"expiration_time,omitempty"` + + // RoomID is the room ID if the verification is happening in a room or + // empty if it is a to-device verification. + RoomID id.RoomID `json:"room_id,omitempty"` + + // VerificationState is the current step of the verification flow. + VerificationState VerificationState `json:"verification_state"` + // TransactionID is the ID of the verification transaction. + TransactionID id.VerificationTransactionID `json:"transaction_id"` + + // TheirDeviceID is the device ID of the device that either made the + // initial request or accepted our request. + TheirDeviceID id.DeviceID `json:"their_device_id,omitempty"` + // TheirUserID is the user ID of the other user. + TheirUserID id.UserID `json:"their_user_id,omitempty"` + // TheirSupportedMethods is a list of verification methods that the other + // device supports. + TheirSupportedMethods []event.VerificationMethod `json:"their_supported_methods,omitempty"` + + // SentToDeviceIDs is a list of devices which the initial request was sent + // to. This is only used for to-device verification requests, and is meant + // to be used to send cancellation requests to all other devices when a + // verification request is accepted via a m.key.verification.ready event. + SentToDeviceIDs []id.DeviceID `json:"sent_to_device_ids,omitempty"` + + // QRCodeSharedSecret is the shared secret that was encoded in the QR code + // that we showed. + QRCodeSharedSecret []byte `json:"qr_code_shared_secret,omitempty"` + + StartedByUs bool `json:"started_by_us,omitempty"` // Whether the verification was started by us + StartEventContent *event.VerificationStartEventContent `json:"start_event_content,omitempty"` // The m.key.verification.start event content + Commitment []byte `json:"committment,omitempty"` // The commitment from the m.key.verification.accept event + MACMethod event.MACMethod `json:"mac_method,omitempty"` // The method used to calculate the MAC + EphemeralKey *ECDHPrivateKey `json:"ephemeral_key,omitempty"` // The ephemeral key + EphemeralPublicKeyShared bool `json:"ephemeral_public_key_shared,omitempty"` // Whether this device's ephemeral public key has been shared + OtherPublicKey *ECDHPublicKey `json:"other_public_key,omitempty"` // The other device's ephemeral public key + ReceivedTheirMAC bool `json:"received_their_mac,omitempty"` // Whether we have received their MAC + SentOurMAC bool `json:"sent_our_mac,omitempty"` // Whether we have sent our MAC + ReceivedTheirDone bool `json:"received_their_done,omitempty"` // Whether we have received their done event + SentOurDone bool `json:"sent_our_done,omitempty"` // Whether we have sent our done event +} + +type VerificationStore interface { + // DeleteVerification deletes a verification transaction by ID + DeleteVerification(ctx context.Context, txnID id.VerificationTransactionID) error + // GetVerificationTransaction gets a verification transaction by ID + GetVerificationTransaction(ctx context.Context, txnID id.VerificationTransactionID) (VerificationTransaction, error) + // SaveVerificationTransaction saves a verification transaction by ID + SaveVerificationTransaction(ctx context.Context, txn VerificationTransaction) error + // FindVerificationTransactionForUserDevice finds a verification + // transaction by user and device ID + FindVerificationTransactionForUserDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (VerificationTransaction, error) + // GetAllVerificationTransactions returns all of the verification + // transactions. This is used to reset the cancellation timeouts. + GetAllVerificationTransactions(ctx context.Context) ([]VerificationTransaction, error) +} + +type InMemoryVerificationStore struct { + txns map[id.VerificationTransactionID]VerificationTransaction +} + +var _ VerificationStore = (*InMemoryVerificationStore)(nil) + +func NewInMemoryVerificationStore() *InMemoryVerificationStore { + return &InMemoryVerificationStore{ + txns: map[id.VerificationTransactionID]VerificationTransaction{}, + } +} + +func (i *InMemoryVerificationStore) DeleteVerification(ctx context.Context, txnID id.VerificationTransactionID) error { + if _, ok := i.txns[txnID]; !ok { + return ErrUnknownVerificationTransaction + } + delete(i.txns, txnID) + return nil +} + +func (i *InMemoryVerificationStore) GetVerificationTransaction(ctx context.Context, txnID id.VerificationTransactionID) (VerificationTransaction, error) { + if _, ok := i.txns[txnID]; !ok { + return VerificationTransaction{}, ErrUnknownVerificationTransaction + } + return i.txns[txnID], nil +} + +func (i *InMemoryVerificationStore) SaveVerificationTransaction(ctx context.Context, txn VerificationTransaction) error { + i.txns[txn.TransactionID] = txn + return nil +} + +func (i *InMemoryVerificationStore) FindVerificationTransactionForUserDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (VerificationTransaction, error) { + for _, existingTxn := range i.txns { + if existingTxn.TheirUserID == userID && existingTxn.TheirDeviceID == deviceID { + return existingTxn, nil + } + } + return VerificationTransaction{}, ErrUnknownVerificationTransaction +} + +func (i *InMemoryVerificationStore) GetAllVerificationTransactions(ctx context.Context) (txns []VerificationTransaction, err error) { + for _, txn := range i.txns { + txns = append(txns, txn) + } + return +} diff --git a/mautrix-patched/crypto/verificationhelper/verificationstore_test.go b/mautrix-patched/crypto/verificationhelper/verificationstore_test.go new file mode 100644 index 00000000..e64153b1 --- /dev/null +++ b/mautrix-patched/crypto/verificationhelper/verificationstore_test.go @@ -0,0 +1,85 @@ +package verificationhelper_test + +import ( + "context" + "database/sql" + "errors" + + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/crypto/verificationhelper" + "maunium.net/go/mautrix/id" +) + +type SQLiteVerificationStore struct { + db *sql.DB +} + +const ( + selectVerifications = `SELECT transaction_data FROM verifications` + getVerificationByTransactionID = selectVerifications + ` WHERE transaction_id = ?1` + getVerificationByUserDeviceID = selectVerifications + ` + WHERE transaction_data->>'their_user_id' = ?1 + AND transaction_data->>'their_device_id' = ?2 + ` + deleteVerificationsQuery = `DELETE FROM verifications WHERE transaction_id = ?1` +) + +var _ verificationhelper.VerificationStore = (*SQLiteVerificationStore)(nil) + +func NewSQLiteVerificationStore(ctx context.Context, db *sql.DB) (*SQLiteVerificationStore, error) { + _, err := db.ExecContext(ctx, ` + CREATE TABLE verifications ( + transaction_id TEXT PRIMARY KEY NOT NULL, + transaction_data JSONB NOT NULL + ); + CREATE INDEX verifications_user_device_id ON + verifications(transaction_data->>'their_user_id', transaction_data->>'their_device_id'); + `) + return &SQLiteVerificationStore{db}, err +} + +func (s *SQLiteVerificationStore) GetAllVerificationTransactions(ctx context.Context) ([]verificationhelper.VerificationTransaction, error) { + rows, err := s.db.QueryContext(ctx, selectVerifications) + return dbutil.NewRowIterWithError(rows, func(dbutil.Scannable) (txn verificationhelper.VerificationTransaction, err error) { + err = rows.Scan(&dbutil.JSON{Data: &txn}) + return + }, err).AsList() +} + +func (vq *SQLiteVerificationStore) GetVerificationTransaction(ctx context.Context, txnID id.VerificationTransactionID) (txn verificationhelper.VerificationTransaction, err error) { + zerolog.Ctx(ctx).Warn().Stringer("transaction_id", txnID).Msg("Getting verification transaction") + row := vq.db.QueryRowContext(ctx, getVerificationByTransactionID, txnID) + err = row.Scan(&dbutil.JSON{Data: &txn}) + if errors.Is(err, sql.ErrNoRows) { + err = verificationhelper.ErrUnknownVerificationTransaction + } + return +} + +func (vq *SQLiteVerificationStore) FindVerificationTransactionForUserDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (txn verificationhelper.VerificationTransaction, err error) { + row := vq.db.QueryRowContext(ctx, getVerificationByUserDeviceID, userID, deviceID) + err = row.Scan(&dbutil.JSON{Data: &txn}) + if errors.Is(err, sql.ErrNoRows) { + err = verificationhelper.ErrUnknownVerificationTransaction + } + return +} + +func (vq *SQLiteVerificationStore) SaveVerificationTransaction(ctx context.Context, txn verificationhelper.VerificationTransaction) (err error) { + zerolog.Ctx(ctx).Debug().Any("transaction", &txn).Msg("Saving verification transaction") + _, err = vq.db.ExecContext(ctx, ` + INSERT INTO verifications (transaction_id, transaction_data) + VALUES (?1, ?2) + ON CONFLICT (transaction_id) DO UPDATE + SET transaction_data=excluded.transaction_data + `, txn.TransactionID, &dbutil.JSON{Data: &txn}) + return +} + +func (vq *SQLiteVerificationStore) DeleteVerification(ctx context.Context, txnID id.VerificationTransactionID) (err error) { + _, err = vq.db.ExecContext(ctx, deleteVerificationsQuery, txnID) + return +} diff --git a/mautrix-patched/error.go b/mautrix-patched/error.go new file mode 100644 index 00000000..987dfb5a --- /dev/null +++ b/mautrix-patched/error.go @@ -0,0 +1,258 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "go.mau.fi/util/exhttp" + "go.mau.fi/util/exmaps" + "golang.org/x/exp/maps" +) + +// Common error codes from https://matrix.org/docs/spec/client_server/latest#api-standards +// +// Can be used with errors.Is() to check the response code without casting the error: +// +// err := client.Sync() +// if errors.Is(err, MUnknownToken) { +// // logout +// } +var ( + // Generic error for when the server encounters an error and it does not have a more specific error code. + // Note that `errors.Is` will check the error message rather than code for M_UNKNOWNs. + MUnknown = RespError{ErrCode: "M_UNKNOWN", StatusCode: http.StatusInternalServerError} + // Forbidden access, e.g. joining a room without permission, failed login. + MForbidden = RespError{ErrCode: "M_FORBIDDEN", StatusCode: http.StatusForbidden} + // Unrecognized request, e.g. the endpoint does not exist or is not implemented. + MUnrecognized = RespError{ErrCode: "M_UNRECOGNIZED", StatusCode: http.StatusNotFound} + // The access token specified was not recognised. + MUnknownToken = RespError{ErrCode: "M_UNKNOWN_TOKEN", StatusCode: http.StatusUnauthorized} + // No access token was specified for the request. + MMissingToken = RespError{ErrCode: "M_MISSING_TOKEN", StatusCode: http.StatusUnauthorized} + // Request contained valid JSON, but it was malformed in some way, e.g. missing required keys, invalid values for keys. + MBadJSON = RespError{ErrCode: "M_BAD_JSON", StatusCode: http.StatusBadRequest} + // Request did not contain valid JSON. + MNotJSON = RespError{ErrCode: "M_NOT_JSON", StatusCode: http.StatusBadRequest} + // No resource was found for this request. + MNotFound = RespError{ErrCode: "M_NOT_FOUND", StatusCode: http.StatusNotFound} + // Too many requests have been sent in a short period of time. Wait a while then try again. + MLimitExceeded = RespError{ErrCode: "M_LIMIT_EXCEEDED", StatusCode: http.StatusTooManyRequests} + // The user ID associated with the request has been deactivated. + // Typically for endpoints that prove authentication, such as /login. + MUserDeactivated = RespError{ErrCode: "M_USER_DEACTIVATED"} + // Encountered when trying to register a user ID which has been taken. + MUserInUse = RespError{ErrCode: "M_USER_IN_USE", StatusCode: http.StatusBadRequest} + // Encountered when trying to register a user ID which is not valid. + MInvalidUsername = RespError{ErrCode: "M_INVALID_USERNAME", StatusCode: http.StatusBadRequest} + // Sent when the room alias given to the createRoom API is already in use. + MRoomInUse = RespError{ErrCode: "M_ROOM_IN_USE", StatusCode: http.StatusBadRequest} + // The state change requested cannot be performed, such as attempting to unban a user who is not banned. + MBadState = RespError{ErrCode: "M_BAD_STATE"} + // The request or entity was too large. + MTooLarge = RespError{ErrCode: "M_TOO_LARGE", StatusCode: http.StatusRequestEntityTooLarge} + // The resource being requested is reserved by an application service, or the application service making the request has not created the resource. + MExclusive = RespError{ErrCode: "M_EXCLUSIVE", StatusCode: http.StatusBadRequest} + // The client's request to create a room used a room version that the server does not support. + MUnsupportedRoomVersion = RespError{ErrCode: "M_UNSUPPORTED_ROOM_VERSION"} + // The client attempted to join a room that has a version the server does not support. + // Inspect the room_version property of the error response for the room's version. + MIncompatibleRoomVersion = RespError{ErrCode: "M_INCOMPATIBLE_ROOM_VERSION"} + // The client specified a parameter that has the wrong value. + MInvalidParam = RespError{ErrCode: "M_INVALID_PARAM", StatusCode: http.StatusBadRequest} + // The client specified a room key backup version that is not the current room key backup version for the user. + MWrongRoomKeysVersion = RespError{ErrCode: "M_WRONG_ROOM_KEYS_VERSION", StatusCode: http.StatusForbidden} + + MURLNotSet = RespError{ErrCode: "M_URL_NOT_SET"} + MBadStatus = RespError{ErrCode: "M_BAD_STATUS"} + MConnectionTimeout = RespError{ErrCode: "M_CONNECTION_TIMEOUT"} + MConnectionFailed = RespError{ErrCode: "M_CONNECTION_FAILED"} + + MUnredactedContentDeleted = RespError{ErrCode: "FI.MAU.MSC2815_UNREDACTED_CONTENT_DELETED"} + MUnredactedContentNotReceived = RespError{ErrCode: "FI.MAU.MSC2815_UNREDACTED_CONTENT_NOT_RECEIVED"} +) + +var ( + ErrClientIsNil = errors.New("client is nil") + ErrClientHasNoHomeserver = errors.New("client has no homeserver set") + + ErrResponseTooLong = errors.New("response content length too long") + ErrBodyReadReachedLimit = errors.New("reached response size limit while reading body") + + // Special error that indicates we should retry canceled contexts. Note that on it's own this + // is useless, the context itself must also be replaced. + ErrContextCancelRetry = errors.New("retry canceled context") +) + +// HTTPError An HTTP Error response, which may wrap an underlying native Go Error. +type HTTPError struct { + Request *http.Request + Response *http.Response + ResponseBody string + + WrappedError error + RespError *RespError + Message string +} + +func (e HTTPError) Is(err error) bool { + return (e.RespError != nil && errors.Is(e.RespError, err)) || (e.WrappedError != nil && errors.Is(e.WrappedError, err)) +} + +func (e HTTPError) IsStatus(code int) bool { + return e.Response != nil && e.Response.StatusCode == code +} + +func (e HTTPError) Error() string { + if e.WrappedError != nil { + return fmt.Sprintf("%s: %v", e.Message, e.WrappedError) + } else if e.RespError != nil { + msg := e.RespError.Err + if e.RespError.InternalError != "" { + msg = e.RespError.InternalError + } + return fmt.Sprintf("%s (HTTP %d): %s", e.RespError.ErrCode, e.Response.StatusCode, msg) + } else { + msg := fmt.Sprintf("HTTP %d", e.Response.StatusCode) + if len(e.ResponseBody) > 0 { + msg = fmt.Sprintf("%s: %s", msg, e.ResponseBody) + } + return msg + } +} + +func (e HTTPError) Unwrap() error { + if e.WrappedError != nil { + return e.WrappedError + } else if e.RespError != nil { + return *e.RespError + } + return nil +} + +// RespError is the standard JSON error response from Homeservers. It also implements the Golang "error" interface. +// See https://spec.matrix.org/v1.2/client-server-api/#api-standards +type RespError struct { + ErrCode string + Err string + ExtraData map[string]any + + StatusCode int + ExtraHeader map[string]string + + CanRetry bool + InternalError string +} + +func (e *RespError) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, &e.ExtraData) + if err != nil { + return err + } + e.ErrCode, _ = e.ExtraData["errcode"].(string) + e.Err, _ = e.ExtraData["error"].(string) + e.CanRetry, _ = e.ExtraData["com.beeper.can_retry"].(bool) + e.InternalError, _ = e.ExtraData["fi.mau.internal_error"].(string) + return nil +} + +func (e *RespError) MarshalJSON() ([]byte, error) { + data := exmaps.NonNilClone(e.ExtraData) + data["errcode"] = e.ErrCode + data["error"] = e.Err + if e.CanRetry { + data["com.beeper.can_retry"] = e.CanRetry + } else { + delete(data, "com.beeper.can_retry") + } + if e.InternalError != "" { + data["fi.mau.internal_error"] = e.InternalError + } else { + delete(data, "fi.mau.internal_error") + } + return json.Marshal(data) +} + +func (e RespError) Write(w http.ResponseWriter) { + if w == nil { + return + } + statusCode := e.StatusCode + if statusCode == 0 { + statusCode = http.StatusInternalServerError + } + for key, value := range e.ExtraHeader { + w.Header().Set(key, value) + } + exhttp.WriteJSONResponse(w, statusCode, &e) +} + +func (e RespError) WithMessage(msg string, args ...any) RespError { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + e.Err = msg + return e +} + +func (e RespError) WithStatus(status int) RespError { + e.StatusCode = status + return e +} + +func (e RespError) WithCanRetry(canRetry bool) RespError { + e.CanRetry = canRetry + return e +} + +func (e RespError) WithInternalError(err error) RespError { + e.InternalError = err.Error() + return e +} + +func (e RespError) WithExtraData(extraData map[string]any) RespError { + e.ExtraData = exmaps.NonNilClone(e.ExtraData) + maps.Copy(e.ExtraData, extraData) + return e +} + +func (e RespError) WithExtraField(key string, value any) RespError { + e.ExtraData = exmaps.NonNilClone(e.ExtraData) + e.ExtraData[key] = value + return e +} + +func (e RespError) WithExtraHeader(key, value string) RespError { + e.ExtraHeader = exmaps.NonNilClone(e.ExtraHeader) + e.ExtraHeader[key] = value + return e +} + +func (e RespError) WithExtraHeaders(headers map[string]string) RespError { + e.ExtraHeader = exmaps.NonNilClone(e.ExtraHeader) + maps.Copy(e.ExtraHeader, headers) + return e +} + +// Error returns the errcode and error message. +func (e RespError) Error() string { + return e.ErrCode + ": " + e.Err +} + +func (e RespError) Is(err error) bool { + e2, ok := err.(RespError) + if !ok { + return false + } + if e.ErrCode == "M_UNKNOWN" && e2.ErrCode == "M_UNKNOWN" { + return e.Err == e2.Err + } + return e2.ErrCode == e.ErrCode +} diff --git a/mautrix-patched/event/accountdata.go b/mautrix-patched/event/accountdata.go new file mode 100644 index 00000000..223919a1 --- /dev/null +++ b/mautrix-patched/event/accountdata.go @@ -0,0 +1,119 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "strings" + "time" + + "maunium.net/go/mautrix/id" +) + +// TagEventContent represents the content of a m.tag room account data event. +// https://spec.matrix.org/v1.2/client-server-api/#mtag +type TagEventContent struct { + Tags Tags `json:"tags"` +} + +type Tags map[RoomTag]TagMetadata + +type RoomTag string + +const ( + RoomTagFavourite RoomTag = "m.favourite" + RoomTagLowPriority RoomTag = "m.lowpriority" + RoomTagServerNotice RoomTag = "m.server_notice" +) + +func (rt RoomTag) IsUserDefined() bool { + return strings.HasPrefix(string(rt), "u.") +} + +func (rt RoomTag) String() string { + return string(rt) +} + +func (rt RoomTag) Name() string { + if rt.IsUserDefined() { + return string(rt[2:]) + } + switch rt { + case RoomTagFavourite: + return "Favourite" + case RoomTagLowPriority: + return "Low priority" + case RoomTagServerNotice: + return "Server notice" + default: + return "" + } +} + +// Deprecated: type alias +type Tag = TagMetadata + +type TagMetadata struct { + Order json.Number `json:"order,omitempty"` + + MauDoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` +} + +// DirectChatsEventContent represents the content of a m.direct account data event. +// https://spec.matrix.org/v1.2/client-server-api/#mdirect +type DirectChatsEventContent map[id.UserID][]id.RoomID + +// FullyReadEventContent represents the content of a m.fully_read account data event. +// https://spec.matrix.org/v1.2/client-server-api/#mfully_read +type FullyReadEventContent struct { + EventID id.EventID `json:"event_id"` +} + +// IgnoredUserListEventContent represents the content of a m.ignored_user_list account data event. +// https://spec.matrix.org/v1.2/client-server-api/#mignored_user_list +type IgnoredUserListEventContent struct { + IgnoredUsers map[id.UserID]IgnoredUser `json:"ignored_users"` +} + +type IgnoredUser struct { + // This is an empty object +} + +type MarkedUnreadEventContent struct { + Unread bool `json:"unread"` +} + +type BeeperMuteEventContent struct { + MutedUntil int64 `json:"muted_until,omitempty"` +} + +func (bmec *BeeperMuteEventContent) IsMuted() bool { + return bmec.MutedUntil < 0 || (bmec.MutedUntil > 0 && bmec.GetMutedUntilTime().After(time.Now())) +} + +var MutedForever = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC) + +func (bmec *BeeperMuteEventContent) GetMutedUntilTime() time.Time { + if bmec.MutedUntil < 0 { + return MutedForever + } else if bmec.MutedUntil > 0 { + return time.UnixMilli(bmec.MutedUntil) + } + return time.Time{} +} + +func (bmec *BeeperMuteEventContent) GetMuteDuration() time.Duration { + ts := bmec.GetMutedUntilTime() + now := time.Now() + if ts.Before(now) { + return 0 + } else if ts == MutedForever { + return -1 + } else { + return ts.Sub(now) + } +} diff --git a/mautrix-patched/event/audio.go b/mautrix-patched/event/audio.go new file mode 100644 index 00000000..9eeb8edb --- /dev/null +++ b/mautrix-patched/event/audio.go @@ -0,0 +1,21 @@ +package event + +import ( + "encoding/json" +) + +type MSC1767Audio struct { + Duration int `json:"duration"` + Waveform []int `json:"waveform"` +} + +type serializableMSC1767Audio MSC1767Audio + +func (ma *MSC1767Audio) MarshalJSON() ([]byte, error) { + if ma.Waveform == nil { + ma.Waveform = []int{} + } + return json.Marshal((*serializableMSC1767Audio)(ma)) +} + +type MSC3245Voice struct{} diff --git a/mautrix-patched/event/beeper.go b/mautrix-patched/event/beeper.go new file mode 100644 index 00000000..fc784c5d --- /dev/null +++ b/mautrix-patched/event/beeper.go @@ -0,0 +1,383 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/base32" + "encoding/binary" + "encoding/json" + "fmt" + "html" + "regexp" + "strconv" + "strings" + + "go.mau.fi/util/jsonbytes" + + "maunium.net/go/mautrix/id" +) + +type MessageStatusReason string + +const ( + MessageStatusGenericError MessageStatusReason = "m.event_not_handled" + MessageStatusUnsupported MessageStatusReason = "com.beeper.unsupported_event" + MessageStatusUndecryptable MessageStatusReason = "com.beeper.undecryptable_event" + MessageStatusTooOld MessageStatusReason = "m.event_too_old" + MessageStatusNetworkError MessageStatusReason = "m.foreign_network_error" + MessageStatusNoPermission MessageStatusReason = "m.no_permission" + MessageStatusBridgeUnavailable MessageStatusReason = "m.bridge_unavailable" +) + +type MessageStatus string + +const ( + MessageStatusSuccess MessageStatus = "SUCCESS" + MessageStatusPending MessageStatus = "PENDING" + MessageStatusRetriable MessageStatus = "FAIL_RETRIABLE" + MessageStatusFail MessageStatus = "FAIL_PERMANENT" +) + +type BeeperMessageStatusEventContent struct { + Network string `json:"network,omitempty"` + RelatesTo RelatesTo `json:"m.relates_to"` + Status MessageStatus `json:"status"` + Reason MessageStatusReason `json:"reason,omitempty"` + // Deprecated: clients were showing this to users even though they aren't supposed to. + // Use InternalError for error messages that should be included in bug reports, but not shown in the UI. + Error string `json:"error,omitempty"` + InternalError string `json:"internal_error,omitempty"` + Message string `json:"message,omitempty"` + + LastRetry id.EventID `json:"last_retry,omitempty"` + + TargetTxnID string `json:"relates_to_txn_id,omitempty"` + + MutateEventKey string `json:"mutate_event_key,omitempty"` + + // Indicates the set of users to whom the event was delivered. If nil, then + // the client should not expect delivered status at any later point. If not + // nil (even if empty), this field indicates which users the event was + // delivered to. + DeliveredToUsers *[]id.UserID `json:"delivered_to_users,omitempty"` +} + +type BeeperRelatesTo struct { + EventID id.EventID `json:"event_id,omitempty"` + RoomID id.RoomID `json:"room_id,omitempty"` + Type RelationType `json:"rel_type,omitempty"` +} + +type BeeperTranscriptionEventContent struct { + Text []ExtensibleText `json:"m.text,omitempty"` + Model string `json:"com.beeper.transcription.model,omitempty"` + RelatesTo BeeperRelatesTo `json:"com.beeper.relates_to,omitempty"` +} + +type BeeperRetryMetadata struct { + OriginalEventID id.EventID `json:"original_event_id"` + RetryCount int `json:"retry_count"` + // last_retry is also present, but not used by bridges +} + +type BeeperRoomKeyAckEventContent struct { + RoomID id.RoomID `json:"room_id"` + SessionID id.SessionID `json:"session_id"` + FirstMessageIndex int `json:"first_message_index"` +} + +type BeeperChatDeleteEventContent struct { + DeleteForEveryone bool `json:"delete_for_everyone,omitempty"` + FromMessageRequest bool `json:"from_message_request,omitempty"` +} + +type BeeperAcceptMessageRequestEventContent struct { + // Whether this was triggered by a message rather than an explicit event + IsImplicit bool `json:"-"` +} + +type BeeperSendStateEventContent struct { + Type string `json:"type"` + StateKey string `json:"state_key"` + Content Content `json:"content"` +} + +type IntOrString int + +func (ios *IntOrString) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + var str string + err := json.Unmarshal(data, &str) + if err != nil { + return err + } + intVal, err := strconv.Atoi(str) + if err != nil { + return err + } + *ios = IntOrString(intVal) + return nil + } + return json.Unmarshal(data, (*int)(ios)) +} + +type LinkPreview struct { + CanonicalURL string `json:"og:url,omitempty"` + Title string `json:"og:title,omitempty"` + Type string `json:"og:type,omitempty"` + Description string `json:"og:description,omitempty"` + SiteName string `json:"og:site_name,omitempty"` + + ImageURL id.ContentURIString `json:"og:image,omitempty"` + + ImageSize IntOrString `json:"matrix:image:size,omitempty"` + ImageWidth IntOrString `json:"og:image:width,omitempty"` + ImageHeight IntOrString `json:"og:image:height,omitempty"` + ImageType string `json:"og:image:type,omitempty"` +} + +// BeeperLinkPreview contains the data for a bundled URL preview as specified in MSC4095 +// +// https://github.com/matrix-org/matrix-spec-proposals/pull/4095 +type BeeperLinkPreview struct { + LinkPreview + + MatchedURL string `json:"matched_url,omitempty"` + ImageEncryption *EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` + ImageBlurhash string `json:"matrix:image:blurhash,omitempty"` +} + +type BeeperProfileExtra struct { + RemoteID string `json:"com.beeper.bridge.remote_id,omitempty"` + Identifiers []string `json:"com.beeper.bridge.identifiers,omitempty"` + Service string `json:"com.beeper.bridge.service,omitempty"` + Network string `json:"com.beeper.bridge.network,omitempty"` + IsBridgeBot bool `json:"com.beeper.bridge.is_bridge_bot,omitempty"` + IsNetworkBot bool `json:"com.beeper.bridge.is_network_bot,omitempty"` +} + +type BeeperPerMessageProfile struct { + ID string `json:"id"` + Displayname string `json:"displayname,omitempty"` + AvatarURL *id.ContentURIString `json:"avatar_url,omitempty"` + AvatarFile *EncryptedFileInfo `json:"avatar_file,omitempty"` + HasFallback bool `json:"has_fallback,omitempty"` +} + +type BeeperActionMessageType string + +const ( + BeeperActionMessageCall BeeperActionMessageType = "call" +) + +type BeeperActionMessageCallType string + +const ( + BeeperActionMessageCallTypeVoice BeeperActionMessageCallType = "voice" + BeeperActionMessageCallTypeVideo BeeperActionMessageCallType = "video" +) + +type BeeperActionMessage struct { + Type BeeperActionMessageType `json:"type"` + CallType BeeperActionMessageCallType `json:"call_type,omitempty"` +} + +func (content *MessageEventContent) AddPerMessageProfileFallback() { + if content.BeeperPerMessageProfile == nil || content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { + return + } + content.BeeperPerMessageProfile.HasFallback = true + content.EnsureHasHTML() + content.Body = fmt.Sprintf("%s: %s", content.BeeperPerMessageProfile.Displayname, content.Body) + content.FormattedBody = fmt.Sprintf( + "%s: %s", + html.EscapeString(content.BeeperPerMessageProfile.Displayname), + content.FormattedBody, + ) +} + +var HTMLProfileFallbackRegex = regexp.MustCompile(`([^<]+): `) + +func (content *MessageEventContent) RemovePerMessageProfileFallback() { + if content.NewContent != nil && content.NewContent != content { + content.NewContent.RemovePerMessageProfileFallback() + } + if content == nil || content.BeeperPerMessageProfile == nil || !content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { + return + } + content.BeeperPerMessageProfile.HasFallback = false + content.Body = strings.TrimPrefix(content.Body, content.BeeperPerMessageProfile.Displayname+": ") + if content.Format == FormatHTML { + content.FormattedBody = HTMLProfileFallbackRegex.ReplaceAllLiteralString(content.FormattedBody, "") + } +} + +type BeeperStreamInfo struct { + UserID id.UserID `json:"user_id"` + DeviceID id.DeviceID `json:"device_id,omitempty"` + Type string `json:"type"` + ExpiryMS int64 `json:"expiry_ms,omitempty"` + MaxBufferedUpdates int `json:"max_buffered_updates,omitempty"` + Encryption *BeeperStreamEncryptionInfo `json:"encryption,omitempty"` +} + +func (info *BeeperStreamInfo) Clone() *BeeperStreamInfo { + if info == nil { + return nil + } + cloned := *info + if info.Encryption != nil { + enc := *info.Encryption + enc.Key = append(jsonbytes.UnpaddedBytes(nil), info.Encryption.Key...) + cloned.Encryption = &enc + } + return &cloned +} + +func (info *BeeperStreamInfo) Validate() error { + if info == nil { + return fmt.Errorf("missing beeper stream descriptor") + } else if info.UserID == "" || info.Type == "" { + return fmt.Errorf("missing beeper stream descriptor fields") + } else if info.MaxBufferedUpdates < 0 { + return fmt.Errorf("invalid beeper stream max buffered updates %d", info.MaxBufferedUpdates) + } + if info.Encryption == nil { + return nil + } + if info.Encryption.Algorithm != id.AlgorithmBeeperStreamV1 { + return fmt.Errorf("unsupported beeper stream encryption algorithm %q", info.Encryption.Algorithm) + } else if len(info.Encryption.Key) == 0 { + return fmt.Errorf("missing beeper stream encryption key") + } + return nil +} + +type BeeperStreamEncryptionInfo struct { + Algorithm id.Algorithm `json:"algorithm"` + Key jsonbytes.UnpaddedBytes `json:"key"` +} + +type BeeperStreamSubscribeEventContent struct { + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` + DeviceID id.DeviceID `json:"device_id"` + ExpiryMS int64 `json:"expiry_ms"` +} + +type BeeperStreamUpdateEventContent struct { + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` + Updates []map[string]any `json:"updates,omitempty"` +} + +type BeeperEncodedOrder struct { + order int64 + suborder int16 +} + +func NewBeeperEncodedOrder(order int64, suborder int16) *BeeperEncodedOrder { + return &BeeperEncodedOrder{order: order, suborder: suborder} +} + +func BeeperEncodedOrderFromString(str string) (*BeeperEncodedOrder, error) { + order, suborder, err := decodeIntPair(str) + if err != nil { + return nil, err + } + return &BeeperEncodedOrder{order: order, suborder: suborder}, nil +} + +func (b *BeeperEncodedOrder) String() string { + if b == nil { + return "" + } + return encodeIntPair(b.order, b.suborder) +} + +func (b *BeeperEncodedOrder) OrderPair() (int64, int16) { + if b == nil { + return 0, 0 + } + return b.order, b.suborder +} + +func (b *BeeperEncodedOrder) IsZero() bool { + return b == nil || (b.order == 0 && b.suborder == 0) +} + +func (b *BeeperEncodedOrder) MarshalJSON() ([]byte, error) { + return []byte(`"` + b.String() + `"`), nil +} + +func (b *BeeperEncodedOrder) UnmarshalJSON(data []byte) error { + if b == nil { + return fmt.Errorf("BeeperEncodedOrder: receiver is nil") + } + str := string(data) + if len(str) < 2 { + return fmt.Errorf("invalid encoded order string: %s", str) + } + decoded, err := BeeperEncodedOrderFromString(str[1 : len(str)-1]) + if err != nil { + return err + } + b.order, b.suborder = decoded.order, decoded.suborder + return nil +} + +// encodeIntPair encodes an int64 and an int16 into a lexicographically sortable string +func encodeIntPair(a int64, b int16) string { + // Create a buffer to hold the binary representation of the integers. + // Will need 8 bytes for the int64 and 2 bytes for the int16. + var buf [10]byte + + // Flip the sign bit of each integer to map the entire int range to uint + // in a way that preserves the order of the original integers. + // + // Explanation: + // - By XORing with (1 << 63), we flip the most significant bit (sign bit) of the int64 value. + // - Negative numbers (which have a sign bit of 1) become smaller uint64 values. + // - Non-negative numbers (with a sign bit of 0) become larger uint64 values. + // - This mapping preserves the original ordering when the uint64 values are compared. + binary.BigEndian.PutUint64(buf[0:8], uint64(a)^(1<<63)) + binary.BigEndian.PutUint16(buf[8:10], uint16(b)^(1<<15)) + + // Encode the buffer into a Base32 string without padding using the Hex encoding. + // + // Explanation: + // - Base32 encoding converts binary data into a text representation using 32 ASCII characters. + // - Using Base32HexEncoding ensures that the characters are in lexicographical order. + // - Disabling padding results in a consistent string length, which is important for sorting. + encoded := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]) + + return encoded +} + +// decodeIntPair decodes a string produced by encodeIntPair back into the original int64 and int16 values +func decodeIntPair(encoded string) (int64, int16, error) { + // Decode the Base32 string back into the original byte buffer. + buf, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return 0, 0, fmt.Errorf("failed to decode string: %w", err) + } + + // Check that the decoded buffer has the expected length. + if len(buf) != 10 { + return 0, 0, fmt.Errorf("invalid encoded string length: expected 10 bytes, got %d", len(buf)) + } + + // Read the uint values from the buffer using big-endian byte order. + aPos := binary.BigEndian.Uint64(buf[0:8]) + bPos := binary.BigEndian.Uint16(buf[8:10]) + + // Reverse the sign bit flip to retrieve the original values. + a := int64(aPos ^ (1 << 63)) + b := int16(bPos ^ (1 << 15)) + + return a, b, nil +} diff --git a/mautrix-patched/event/capabilities.d.ts b/mautrix-patched/event/capabilities.d.ts new file mode 100644 index 00000000..a608647e --- /dev/null +++ b/mautrix-patched/event/capabilities.d.ts @@ -0,0 +1,231 @@ +/** + * The content of the `com.beeper.room_features` state event. + */ +export interface RoomFeatures { + /** + * Supported formatting features. If omitted, no formatting is supported. + * + * Capability level 0 means the corresponding HTML tags/attributes are ignored + * and will be treated as if they don't exist, which means that children will + * be rendered, but attributes will be dropped. + */ + formatting?: Record + /** + * Supported file message types and their features. + * + * If a message type isn't listed here, it should be treated as support level -2 (will be rejected). + */ + file?: Record + /** + * Supported state event types and their parameters. Currently, there are no parameters, + * but it is likely there will be some in the future (like max name/topic length, avatar mime types, etc.). + * + * Events that are not listed or have a support level of zero or below should be treated as unsupported. + * + * Clients should at least check `m.room.name`, `m.room.topic`, and `m.room.avatar` here. + * `m.room.member` will not be listed here, as it's controlled by the member_actions field. + * `com.beeper.disappearing_timer` should be listed here, but the parameters are in the disappearing_timer field for now. + */ + state?: Record + /** + * Supported member actions and their support levels. + * + * Actions that are not listed or have a support level of zero or below should be treated as unsupported. + */ + member_actions?: Record + + /** Maximum length of normal text messages. */ + max_text_length?: integer + + /** Whether location messages (`m.location`) are supported. */ + location_message?: CapabilitySupportLevel + /** Whether polls are supported. */ + poll?: CapabilitySupportLevel + /** Whether replying in a thread is supported. */ + thread?: CapabilitySupportLevel + /** Whether replying to a specific message is supported. */ + reply?: CapabilitySupportLevel + + /** Whether edits are supported. */ + edit?: CapabilitySupportLevel + /** How many times can an individual message be edited. */ + edit_max_count?: integer + /** How old messages can be edited, in seconds. */ + edit_max_age?: seconds + /** Whether deleting messages for everyone is supported */ + delete?: CapabilitySupportLevel + /** How old messages can be deleted for everyone, in seconds. */ + delete_max_age?: seconds + /** Whether deleting messages just for yourself is supported. No message age limit. */ + delete_for_me?: boolean + /** + * Whether outgoing redactions should set the `com.beeper.dont_render_redacted_placeholder` field to indicate + * clients shouldn't render them in the timeline. Incoming redactions can just read the field and don't need to + * care about this capability flag. + */ + delete_hide_placeholder?: boolean + /** Allowed configuration options for disappearing timers. */ + disappearing_timer?: DisappearingTimerCapability + + /** Whether reactions are supported. */ + reaction?: CapabilitySupportLevel + /** How many reactions can be added to a single message. */ + reaction_count?: integer + /** + * The Unicode emojis allowed for reactions. If omitted, all emojis are allowed. + * Emojis in this list must include variation selector 16 if allowed in the Unicode spec. + */ + allowed_reactions?: string[] + /** Whether custom emoji reactions are allowed. */ + custom_emoji_reactions?: boolean + + /** Whether deleting the chat for yourself is supported. */ + delete_chat?: boolean + /** Whether deleting the chat for all participants is supported. */ + delete_chat_for_everyone?: boolean + /** What can be done with message requests? */ + message_request?: { + accept_with_message?: CapabilitySupportLevel + accept_with_button?: CapabilitySupportLevel + } +} + +declare type integer = number +declare type seconds = integer +declare type milliseconds = integer +declare type MIMEClass = "image" | "audio" | "video" | "text" | "font" | "model" | "application" +declare type MIMETypeOrPattern = + "*/*" + | `${MIMEClass}/*` + | `${MIMEClass}/${string}` + | `${MIMEClass}/${string}; ${string}` + +export enum MemberAction { + Ban = "ban", + Kick = "kick", + Leave = "leave", + RevokeInvite = "revoke_invite", + Invite = "invite", +} + +declare type EventType = string + +// This is an object for future extensibility (e.g. max name/topic length) +export interface StateFeatures { + level: CapabilitySupportLevel +} + +export enum CapabilityMsgType { + // Real message types used in the `msgtype` field + Image = "m.image", + File = "m.file", + Audio = "m.audio", + Video = "m.video", + + // Pseudo types only used in capabilities + /** An `m.audio` message that has `"org.matrix.msc3245.voice": {}` */ + Voice = "org.matrix.msc3245.voice", + /** An `m.video` message that has `"info": {"fi.mau.gif": true}`, or an `m.image` message of type `image/gif` */ + GIF = "fi.mau.gif", + /** An `m.sticker` event, no `msgtype` field */ + Sticker = "m.sticker", +} + +export interface FileFeatures { + /** + * The supported MIME types or type patterns and their support levels. + * + * If a mime type doesn't match any pattern provided, + * it should be treated as support level -2 (will be rejected). + */ + mime_types: Record + + /** The support level for captions within this file message type */ + caption?: CapabilitySupportLevel + /** The maximum length for captions (only applicable if captions are supported). */ + max_caption_length?: integer + /** The maximum file size as bytes. */ + max_size?: integer + /** For images and videos, the maximum width as pixels. */ + max_width?: integer + /** For images and videos, the maximum height as pixels. */ + max_height?: integer + /** For videos and audio files, the maximum duration as seconds. */ + max_duration?: seconds + + /** Can this type of file be sent as view-once media? */ + view_once?: boolean +} + +export enum DisappearingType { + None = "", + AfterRead = "after_read", + AfterSend = "after_send", +} + +export interface DisappearingTimerCapability { + types: DisappearingType[] + /** Allowed timer values. If omitted, any timer is allowed. */ + timers?: milliseconds[] + /** + * Whether clients should omit the empty disappearing_timer object in messages that they don't want to disappear + * + * Generally, bridged rooms will want the object to be always present, while native Matrix rooms don't, + * so the hardcoded features for Matrix rooms should set this to true, while bridges will not. + */ + omit_empty_timer?: true +} + +/** + * The support level for a feature. These are integers rather than booleans + * to accurately represent what the bridge is doing and hopefully make the + * state event more generally useful. Our clients should check for > 0 to + * determine if the feature should be allowed. + */ +export enum CapabilitySupportLevel { + /** The feature is unsupported and messages using it will be rejected. */ + Rejected = -2, + /** The feature is unsupported and has no fallback. The message will go through, but data may be lost. */ + Dropped = -1, + /** The feature is unsupported, but may have a fallback. The nature of the fallback depends on the context. */ + Unsupported = 0, + /** The feature is partially supported (e.g. it may be converted to a different format). */ + PartialSupport = 1, + /** The feature is fully supported and can be safely used. */ + FullySupported = 2, +} + +/** + * A formatting feature that consists of specific HTML tags and/or attributes. + */ +export enum FormattingFeature { + Bold = "bold", // strong, b + Italic = "italic", // em, i + Underline = "underline", // u + Strikethrough = "strikethrough", // del, s + InlineCode = "inline_code", // code + CodeBlock = "code_block", // pre + code + SyntaxHighlighting = "code_block.syntax_highlighting", //

+	Blockquote = "blockquote", // blockquote
+	InlineLink = "inline_link", // a
+	UserLink = "user_link", // 
+	RoomLink = "room_link", // 
+	EventLink = "event_link", // 
+	AtRoomMention = "at_room_mention", // @room (no html tag)
+	UnorderedList = "unordered_list", // ul + li
+	OrderedList = "ordered_list", // ol + li
+	ListStart = "ordered_list.start", // 
    + ListJumpValue = "ordered_list.jump_value", //
  1. + CustomEmoji = "custom_emoji", // + Spoiler = "spoiler", // + SpoilerReason = "spoiler.reason", // + TextForegroundColor = "color.foreground", // + TextBackgroundColor = "color.background", // + HorizontalLine = "horizontal_line", // hr + Headers = "headers", // h1, h2, h3, h4, h5, h6 + Superscript = "superscript", // sup + Subscript = "subscript", // sub + Math = "math", // + DetailsSummary = "details_summary", //
    ......
    + Table = "table", // table, thead, tbody, tr, th, td +} diff --git a/mautrix-patched/event/capabilities.go b/mautrix-patched/event/capabilities.go new file mode 100644 index 00000000..b5c226ac --- /dev/null +++ b/mautrix-patched/event/capabilities.go @@ -0,0 +1,416 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "mime" + "slices" + "strings" + + "go.mau.fi/util/exerrors" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "golang.org/x/exp/constraints" + "golang.org/x/exp/maps" +) + +type RoomFeatures struct { + ID string `json:"id,omitempty"` + + // N.B. New fields need to be added to the Hash function to be included in the deduplication hash. + + Formatting FormattingFeatureMap `json:"formatting,omitempty"` + File FileFeatureMap `json:"file,omitempty"` + State StateFeatureMap `json:"state,omitempty"` + MemberActions MemberFeatureMap `json:"member_actions,omitempty"` + + MaxTextLength int `json:"max_text_length,omitempty"` + + LocationMessage CapabilitySupportLevel `json:"location_message,omitempty"` + Poll CapabilitySupportLevel `json:"poll,omitempty"` + Thread CapabilitySupportLevel `json:"thread,omitempty"` + Reply CapabilitySupportLevel `json:"reply,omitempty"` + + Edit CapabilitySupportLevel `json:"edit,omitempty"` + EditMaxCount int `json:"edit_max_count,omitempty"` + EditMaxAge *jsontime.Seconds `json:"edit_max_age,omitempty"` + Delete CapabilitySupportLevel `json:"delete,omitempty"` + DeleteForMe bool `json:"delete_for_me,omitempty"` + DeleteMaxAge *jsontime.Seconds `json:"delete_max_age,omitempty"` + DeleteHide bool `json:"delete_hide_placeholder,omitempty"` + + DisappearingTimer *DisappearingTimerCapability `json:"disappearing_timer,omitempty"` + + Reaction CapabilitySupportLevel `json:"reaction,omitempty"` + ReactionCount int `json:"reaction_count,omitempty"` + AllowedReactions []string `json:"allowed_reactions,omitempty"` + CustomEmojiReactions bool `json:"custom_emoji_reactions,omitempty"` + + ReadReceipts bool `json:"read_receipts,omitempty"` + TypingNotifications bool `json:"typing_notifications,omitempty"` + Archive bool `json:"archive,omitempty"` + MarkAsUnread bool `json:"mark_as_unread,omitempty"` + DeleteChat bool `json:"delete_chat,omitempty"` + DeleteChatForEveryone bool `json:"delete_chat_for_everyone,omitempty"` + + MessageRequest *MessageRequestFeatures `json:"message_request,omitempty"` + + PerMessageProfileRelay bool `json:"-"` +} + +func (rf *RoomFeatures) GetID() string { + if rf.ID != "" { + return rf.ID + } + return base64.RawURLEncoding.EncodeToString(rf.Hash()) +} + +func (rf *RoomFeatures) Clone() *RoomFeatures { + if rf == nil { + return nil + } + clone := *rf + clone.File = clone.File.Clone() + clone.Formatting = maps.Clone(clone.Formatting) + clone.State = clone.State.Clone() + clone.MemberActions = clone.MemberActions.Clone() + clone.EditMaxAge = ptr.Clone(clone.EditMaxAge) + clone.DeleteMaxAge = ptr.Clone(clone.DeleteMaxAge) + clone.DisappearingTimer = clone.DisappearingTimer.Clone() + clone.AllowedReactions = slices.Clone(clone.AllowedReactions) + clone.MessageRequest = clone.MessageRequest.Clone() + return &clone +} + +type MemberFeatureMap map[MemberAction]CapabilitySupportLevel + +func (mfm MemberFeatureMap) Clone() MemberFeatureMap { + return maps.Clone(mfm) +} + +type MemberAction string + +const ( + MemberActionBan MemberAction = "ban" + MemberActionKick MemberAction = "kick" + MemberActionLeave MemberAction = "leave" + MemberActionRevokeInvite MemberAction = "revoke_invite" + MemberActionInvite MemberAction = "invite" +) + +type StateFeatureMap map[string]*StateFeatures + +func (sfm StateFeatureMap) Clone() StateFeatureMap { + dup := maps.Clone(sfm) + for key, value := range dup { + dup[key] = value.Clone() + } + return dup +} + +type StateFeatures struct { + Level CapabilitySupportLevel `json:"level"` +} + +func (sf *StateFeatures) Clone() *StateFeatures { + if sf == nil { + return nil + } + clone := *sf + return &clone +} + +func (sf *StateFeatures) Hash() []byte { + return sf.Level.Hash() +} + +type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel + +type FileFeatureMap map[CapabilityMsgType]*FileFeatures + +func (ffm FileFeatureMap) Clone() FileFeatureMap { + dup := maps.Clone(ffm) + for key, value := range dup { + dup[key] = value.Clone() + } + return dup +} + +type DisappearingTimerCapability struct { + Types []DisappearingType `json:"types"` + Timers []jsontime.Milliseconds `json:"timers,omitempty"` + + OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` +} + +func (dtc *DisappearingTimerCapability) Clone() *DisappearingTimerCapability { + if dtc == nil { + return nil + } + clone := *dtc + clone.Types = slices.Clone(clone.Types) + clone.Timers = slices.Clone(clone.Timers) + return &clone +} + +func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { + if dtc == nil || content == nil || content.Type == DisappearingTypeNone { + return true + } + return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer)) +} + +type MessageRequestFeatures struct { + AcceptWithMessage CapabilitySupportLevel `json:"accept_with_message,omitempty"` + AcceptWithButton CapabilitySupportLevel `json:"accept_with_button,omitempty"` +} + +func (mrf *MessageRequestFeatures) Clone() *MessageRequestFeatures { + return ptr.Clone(mrf) +} + +func (mrf *MessageRequestFeatures) Hash() []byte { + if mrf == nil { + return nil + } + hasher := sha256.New() + hashValue(hasher, "accept_with_message", mrf.AcceptWithMessage) + hashValue(hasher, "accept_with_button", mrf.AcceptWithButton) + return hasher.Sum(nil) +} + +type CapabilityMsgType = MessageType + +// Message types which are used for event capability signaling, but aren't real values for the msgtype field. +const ( + CapMsgVoice CapabilityMsgType = "org.matrix.msc3245.voice" + CapMsgGIF CapabilityMsgType = "fi.mau.gif" + CapMsgSticker CapabilityMsgType = "m.sticker" +) + +type CapabilitySupportLevel int + +func (csl CapabilitySupportLevel) Partial() bool { + return csl >= CapLevelPartialSupport +} + +func (csl CapabilitySupportLevel) Full() bool { + return csl >= CapLevelFullySupported +} + +func (csl CapabilitySupportLevel) Reject() bool { + return csl <= CapLevelRejected +} + +const ( + CapLevelRejected CapabilitySupportLevel = -2 // The feature is unsupported and messages using it will be rejected. + CapLevelDropped CapabilitySupportLevel = -1 // The feature is unsupported and has no fallback. The message will go through, but data may be lost. + CapLevelUnsupported CapabilitySupportLevel = 0 // The feature is unsupported, but may have a fallback. + CapLevelPartialSupport CapabilitySupportLevel = 1 // The feature is partially supported (e.g. it may be converted to a different format). + CapLevelFullySupported CapabilitySupportLevel = 2 // The feature is fully supported and can be safely used. +) + +type FormattingFeature string + +const ( + FmtBold FormattingFeature = "bold" // strong, b + FmtItalic FormattingFeature = "italic" // em, i + FmtUnderline FormattingFeature = "underline" // u + FmtStrikethrough FormattingFeature = "strikethrough" // del, s + FmtInlineCode FormattingFeature = "inline_code" // code + FmtCodeBlock FormattingFeature = "code_block" // pre + code + FmtSyntaxHighlighting FormattingFeature = "code_block.syntax_highlighting" //
    
    +	FmtBlockquote          FormattingFeature = "blockquote"                     // blockquote
    +	FmtInlineLink          FormattingFeature = "inline_link"                    // a
    +	FmtUserLink            FormattingFeature = "user_link"                      // 
    +	FmtRoomLink            FormattingFeature = "room_link"                      // 
    +	FmtEventLink           FormattingFeature = "event_link"                     // 
    +	FmtAtRoomMention       FormattingFeature = "at_room_mention"                // @room (no html tag)
    +	FmtUnorderedList       FormattingFeature = "unordered_list"                 // ul + li
    +	FmtOrderedList         FormattingFeature = "ordered_list"                   // ol + li
    +	FmtListStart           FormattingFeature = "ordered_list.start"             // 
      + FmtListJumpValue FormattingFeature = "ordered_list.jump_value" //
    1. + FmtCustomEmoji FormattingFeature = "custom_emoji" // + FmtSpoiler FormattingFeature = "spoiler" // + FmtSpoilerReason FormattingFeature = "spoiler.reason" // + FmtTextForegroundColor FormattingFeature = "color.foreground" // + FmtTextBackgroundColor FormattingFeature = "color.background" // + FmtHorizontalLine FormattingFeature = "horizontal_line" // hr + FmtHeaders FormattingFeature = "headers" // h1, h2, h3, h4, h5, h6 + FmtSuperscript FormattingFeature = "superscript" // sup + FmtSubscript FormattingFeature = "subscript" // sub + FmtMath FormattingFeature = "math" // + FmtDetailsSummary FormattingFeature = "details_summary" //
      ......
      + FmtTable FormattingFeature = "table" // table, thead, tbody, tr, th, td +) + +type FileFeatures struct { + // N.B. New fields need to be added to the Hash function to be included in the deduplication hash. + + MimeTypes map[string]CapabilitySupportLevel `json:"mime_types"` + + Caption CapabilitySupportLevel `json:"caption,omitempty"` + MaxCaptionLength int `json:"max_caption_length,omitempty"` + + MaxSize int64 `json:"max_size,omitempty"` + MaxWidth int `json:"max_width,omitempty"` + MaxHeight int `json:"max_height,omitempty"` + MaxDuration *jsontime.Seconds `json:"max_duration,omitempty"` + + ViewOnce bool `json:"view_once,omitempty"` +} + +func (ff *FileFeatures) GetMimeSupport(inputType string) CapabilitySupportLevel { + match, ok := ff.MimeTypes[inputType] + if ok { + return match + } + if strings.IndexByte(inputType, ';') != -1 { + plainMime, _, _ := mime.ParseMediaType(inputType) + if plainMime != "" { + if match, ok = ff.MimeTypes[plainMime]; ok { + return match + } + } + } + if slash := strings.IndexByte(inputType, '/'); slash > 0 { + generalType := fmt.Sprintf("%s/*", inputType[:slash]) + if match, ok = ff.MimeTypes[generalType]; ok { + return match + } + } + match, ok = ff.MimeTypes["*/*"] + if ok { + return match + } + return CapLevelRejected +} + +type hashable interface { + Hash() []byte +} + +func hashMap[Key ~string, Value hashable](w io.Writer, name string, data map[Key]Value) { + keys := maps.Keys(data) + slices.Sort(keys) + exerrors.Must(w.Write([]byte(name))) + for _, key := range keys { + exerrors.Must(w.Write([]byte(key))) + exerrors.Must(w.Write(data[key].Hash())) + exerrors.Must(w.Write([]byte{0})) + } +} + +func hashValue(w io.Writer, name string, data hashable) { + exerrors.Must(w.Write([]byte(name))) + exerrors.Must(w.Write(data.Hash())) +} + +func hashInt[T constraints.Integer](w io.Writer, name string, data T) { + exerrors.Must(w.Write(binary.BigEndian.AppendUint64([]byte(name), uint64(data)))) +} + +func hashBool[T ~bool](w io.Writer, name string, data T) { + exerrors.Must(w.Write([]byte(name))) + if data { + exerrors.Must(w.Write([]byte{1})) + } else { + exerrors.Must(w.Write([]byte{0})) + } +} + +func (csl CapabilitySupportLevel) Hash() []byte { + return []byte{byte(csl + 128)} +} + +func (rf *RoomFeatures) Hash() []byte { + hasher := sha256.New() + + hashMap(hasher, "formatting", rf.Formatting) + hashMap(hasher, "file", rf.File) + hashMap(hasher, "state", rf.State) + hashMap(hasher, "member_actions", rf.MemberActions) + + hashInt(hasher, "max_text_length", rf.MaxTextLength) + + hashValue(hasher, "location_message", rf.LocationMessage) + hashValue(hasher, "poll", rf.Poll) + hashValue(hasher, "thread", rf.Thread) + hashValue(hasher, "reply", rf.Reply) + + hashValue(hasher, "edit", rf.Edit) + hashInt(hasher, "edit_max_count", rf.EditMaxCount) + hashInt(hasher, "edit_max_age", rf.EditMaxAge.Get()) + + hashValue(hasher, "delete", rf.Delete) + hashBool(hasher, "delete_for_me", rf.DeleteForMe) + hashInt(hasher, "delete_max_age", rf.DeleteMaxAge.Get()) + hashBool(hasher, "delete_hide_placeholder", rf.DeleteHide) + hashValue(hasher, "disappearing_timer", rf.DisappearingTimer) + + hashValue(hasher, "reaction", rf.Reaction) + hashInt(hasher, "reaction_count", rf.ReactionCount) + hasher.Write([]byte("allowed_reactions")) + for _, reaction := range rf.AllowedReactions { + hasher.Write([]byte(reaction)) + } + hashBool(hasher, "custom_emoji_reactions", rf.CustomEmojiReactions) + + hashBool(hasher, "read_receipts", rf.ReadReceipts) + hashBool(hasher, "typing_notifications", rf.TypingNotifications) + hashBool(hasher, "archive", rf.Archive) + hashBool(hasher, "mark_as_unread", rf.MarkAsUnread) + hashBool(hasher, "delete_chat", rf.DeleteChat) + hashBool(hasher, "delete_chat_for_everyone", rf.DeleteChatForEveryone) + hashValue(hasher, "message_request", rf.MessageRequest) + + return hasher.Sum(nil) +} + +func (dtc *DisappearingTimerCapability) Hash() []byte { + if dtc == nil { + return nil + } + hasher := sha256.New() + hasher.Write([]byte("types")) + for _, t := range dtc.Types { + hasher.Write([]byte(t)) + } + hasher.Write([]byte("timers")) + for _, timer := range dtc.Timers { + hashInt(hasher, "", timer.Milliseconds()) + } + return hasher.Sum(nil) +} + +func (ff *FileFeatures) Hash() []byte { + hasher := sha256.New() + hashMap(hasher, "mime_types", ff.MimeTypes) + hashValue(hasher, "caption", ff.Caption) + hashInt(hasher, "max_caption_length", ff.MaxCaptionLength) + hashInt(hasher, "max_size", ff.MaxSize) + hashInt(hasher, "max_width", ff.MaxWidth) + hashInt(hasher, "max_height", ff.MaxHeight) + hashInt(hasher, "max_duration", ff.MaxDuration.Get()) + hashBool(hasher, "view_once", ff.ViewOnce) + return hasher.Sum(nil) +} + +func (ff *FileFeatures) Clone() *FileFeatures { + if ff == nil { + return nil + } + clone := *ff + clone.MimeTypes = maps.Clone(clone.MimeTypes) + clone.MaxDuration = ptr.Clone(clone.MaxDuration) + return &clone +} diff --git a/mautrix-patched/event/cmdschema/content.go b/mautrix-patched/event/cmdschema/content.go new file mode 100644 index 00000000..ce07c4c0 --- /dev/null +++ b/mautrix-patched/event/cmdschema/content.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "reflect" + "slices" + + "go.mau.fi/util/exsync" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type EventContent struct { + Command string `json:"command"` + Aliases []string `json:"aliases,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty"` + Description *event.ExtensibleTextContainer `json:"description,omitempty"` + TailParam string `json:"fi.mau.tail_parameter,omitempty"` +} + +func (ec *EventContent) Validate() error { + if ec == nil { + return fmt.Errorf("event content is nil") + } else if ec.Command == "" { + return fmt.Errorf("command is empty") + } + var tailFound bool + dupMap := exsync.NewSet[string]() + for i, p := range ec.Parameters { + if err := p.Validate(); err != nil { + return fmt.Errorf("parameter %q (#%d) is invalid: %w", ptr.Val(p).Key, i+1, err) + } else if !dupMap.Add(p.Key) { + return fmt.Errorf("duplicate parameter key %q at #%d", p.Key, i+1) + } else if p.Key == ec.TailParam { + tailFound = true + } else if tailFound && !p.Optional { + return fmt.Errorf("required parameter %q (#%d) is after tail parameter %q", p.Key, i+1, ec.TailParam) + } + } + if ec.TailParam != "" && !tailFound { + return fmt.Errorf("tail parameter %q not found in parameters", ec.TailParam) + } + return nil +} + +func (ec *EventContent) IsValid() bool { + return ec.Validate() == nil +} + +func (ec *EventContent) StateKey(owner id.UserID) string { + hash := sha256.Sum256([]byte(ec.Command + owner.String())) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +func (ec *EventContent) Equals(other *EventContent) bool { + if ec == nil || other == nil { + return ec == other + } + return ec.Command == other.Command && + slices.Equal(ec.Aliases, other.Aliases) && + slices.EqualFunc(ec.Parameters, other.Parameters, (*Parameter).Equals) && + ec.Description.Equals(other.Description) && + ec.TailParam == other.TailParam +} + +func init() { + event.TypeMap[event.StateMSC4391BotCommand] = reflect.TypeOf(EventContent{}) +} diff --git a/mautrix-patched/event/cmdschema/parameter.go b/mautrix-patched/event/cmdschema/parameter.go new file mode 100644 index 00000000..4193b297 --- /dev/null +++ b/mautrix-patched/event/cmdschema/parameter.go @@ -0,0 +1,286 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "fmt" + "slices" + + "go.mau.fi/util/exslices" + + "maunium.net/go/mautrix/event" +) + +type Parameter struct { + Key string `json:"key"` + Schema *ParameterSchema `json:"schema"` + Optional bool `json:"optional,omitempty"` + Description *event.ExtensibleTextContainer `json:"description,omitempty"` + DefaultValue any `json:"fi.mau.default_value,omitempty"` +} + +func (p *Parameter) Equals(other *Parameter) bool { + if p == nil || other == nil { + return p == other + } + return p.Key == other.Key && + p.Schema.Equals(other.Schema) && + p.Optional == other.Optional && + p.Description.Equals(other.Description) && + p.DefaultValue == other.DefaultValue // TODO this won't work for room/event ID values +} + +func (p *Parameter) Validate() error { + if p == nil { + return fmt.Errorf("parameter is nil") + } else if p.Key == "" { + return fmt.Errorf("key is empty") + } + return p.Schema.Validate() +} + +func (p *Parameter) IsValid() bool { + return p.Validate() == nil +} + +func (p *Parameter) GetDefaultValue() any { + if p != nil && p.DefaultValue != nil { + return p.DefaultValue + } else if p == nil || p.Optional { + return nil + } + return p.Schema.GetDefaultValue() +} + +type PrimitiveType string + +const ( + PrimitiveTypeString PrimitiveType = "string" + PrimitiveTypeInteger PrimitiveType = "integer" + PrimitiveTypeBoolean PrimitiveType = "boolean" + PrimitiveTypeServerName PrimitiveType = "server_name" + PrimitiveTypeUserID PrimitiveType = "user_id" + PrimitiveTypeRoomID PrimitiveType = "room_id" + PrimitiveTypeRoomAlias PrimitiveType = "room_alias" + PrimitiveTypeEventID PrimitiveType = "event_id" +) + +func (pt PrimitiveType) Schema() *ParameterSchema { + return &ParameterSchema{ + SchemaType: SchemaTypePrimitive, + Type: pt, + } +} + +func (pt PrimitiveType) IsValid() bool { + switch pt { + case PrimitiveTypeString, + PrimitiveTypeInteger, + PrimitiveTypeBoolean, + PrimitiveTypeServerName, + PrimitiveTypeUserID, + PrimitiveTypeRoomID, + PrimitiveTypeRoomAlias, + PrimitiveTypeEventID: + return true + default: + return false + } +} + +type SchemaType string + +const ( + SchemaTypePrimitive SchemaType = "primitive" + SchemaTypeArray SchemaType = "array" + SchemaTypeUnion SchemaType = "union" + SchemaTypeLiteral SchemaType = "literal" +) + +type ParameterSchema struct { + SchemaType SchemaType `json:"schema_type"` + Type PrimitiveType `json:"type,omitempty"` // Only for primitive + Items *ParameterSchema `json:"items,omitempty"` // Only for array + Variants []*ParameterSchema `json:"variants,omitempty"` // Only for union + Value any `json:"value,omitempty"` // Only for literal +} + +func Literal(value any) *ParameterSchema { + return &ParameterSchema{ + SchemaType: SchemaTypeLiteral, + Value: value, + } +} + +func Enum(values ...any) *ParameterSchema { + return Union(exslices.CastFunc(values, Literal)...) +} + +func flattenUnion(variants []*ParameterSchema) []*ParameterSchema { + var flattened []*ParameterSchema + for _, variant := range variants { + switch variant.SchemaType { + case SchemaTypeArray: + panic(fmt.Errorf("illegal array schema in union")) + case SchemaTypeUnion: + flattened = append(flattened, flattenUnion(variant.Variants)...) + default: + flattened = append(flattened, variant) + } + } + return flattened +} + +func Union(variants ...*ParameterSchema) *ParameterSchema { + needsFlattening := false + for _, variant := range variants { + if variant.SchemaType == SchemaTypeArray { + panic(fmt.Errorf("illegal array schema in union")) + } else if variant.SchemaType == SchemaTypeUnion { + needsFlattening = true + } + } + if needsFlattening { + variants = flattenUnion(variants) + } + return &ParameterSchema{ + SchemaType: SchemaTypeUnion, + Variants: variants, + } +} + +func Array(items *ParameterSchema) *ParameterSchema { + if items.SchemaType == SchemaTypeArray { + panic(fmt.Errorf("illegal array schema in array")) + } + return &ParameterSchema{ + SchemaType: SchemaTypeArray, + Items: items, + } +} + +func (ps *ParameterSchema) GetDefaultValue() any { + if ps == nil { + return nil + } + switch ps.SchemaType { + case SchemaTypePrimitive: + switch ps.Type { + case PrimitiveTypeInteger: + return 0 + case PrimitiveTypeBoolean: + return false + default: + return "" + } + case SchemaTypeArray: + return []any{} + case SchemaTypeUnion: + if len(ps.Variants) > 0 { + return ps.Variants[0].GetDefaultValue() + } + return nil + case SchemaTypeLiteral: + return ps.Value + default: + return nil + } +} + +func (ps *ParameterSchema) IsValid() bool { + return ps.validate("") == nil +} + +func (ps *ParameterSchema) Validate() error { + return ps.validate("") +} + +func (ps *ParameterSchema) validate(parent SchemaType) error { + if ps == nil { + return fmt.Errorf("schema is nil") + } + switch ps.SchemaType { + case SchemaTypePrimitive: + if !ps.Type.IsValid() { + return fmt.Errorf("invalid primitive type %s", ps.Type) + } else if ps.Items != nil || ps.Variants != nil || ps.Value != nil { + return fmt.Errorf("primitive schema has extra fields") + } + return nil + case SchemaTypeArray: + if parent != "" { + return fmt.Errorf("arrays can't be nested in other types") + } else if err := ps.Items.validate(ps.SchemaType); err != nil { + return fmt.Errorf("item schema is invalid: %w", err) + } else if ps.Type != "" || ps.Variants != nil || ps.Value != nil { + return fmt.Errorf("array schema has extra fields") + } + return nil + case SchemaTypeUnion: + if len(ps.Variants) == 0 { + return fmt.Errorf("no variants specified for union") + } else if parent != "" && parent != SchemaTypeArray { + return fmt.Errorf("unions can't be nested in anything other than arrays") + } + for i, v := range ps.Variants { + if err := v.validate(ps.SchemaType); err != nil { + return fmt.Errorf("variant #%d is invalid: %w", i+1, err) + } + } + if ps.Type != "" || ps.Items != nil || ps.Value != nil { + return fmt.Errorf("union schema has extra fields") + } + return nil + case SchemaTypeLiteral: + switch typedVal := ps.Value.(type) { + case string, float64, int, int64, json.Number, bool, RoomIDValue, *RoomIDValue: + // ok + case map[string]any: + if typedVal["type"] != "event_id" && typedVal["type"] != "room_id" { + return fmt.Errorf("literal value has invalid map data") + } + default: + return fmt.Errorf("literal value has unsupported type %T", ps.Value) + } + if ps.Type != "" || ps.Items != nil || ps.Variants != nil { + return fmt.Errorf("literal schema has extra fields") + } + return nil + default: + return fmt.Errorf("invalid schema type %s", ps.SchemaType) + } +} + +func (ps *ParameterSchema) Equals(other *ParameterSchema) bool { + if ps == nil || other == nil { + return ps == other + } + return ps.SchemaType == other.SchemaType && + ps.Type == other.Type && + ps.Items.Equals(other.Items) && + slices.EqualFunc(ps.Variants, other.Variants, (*ParameterSchema).Equals) && + ps.Value == other.Value // TODO this won't work for room/event ID values +} + +func (ps *ParameterSchema) AllowsPrimitive(prim PrimitiveType) bool { + switch ps.SchemaType { + case SchemaTypePrimitive: + return ps.Type == prim + case SchemaTypeUnion: + for _, variant := range ps.Variants { + if variant.AllowsPrimitive(prim) { + return true + } + } + return false + case SchemaTypeArray: + return ps.Items.AllowsPrimitive(prim) + default: + return false + } +} diff --git a/mautrix-patched/event/cmdschema/parse.go b/mautrix-patched/event/cmdschema/parse.go new file mode 100644 index 00000000..92e69b60 --- /dev/null +++ b/mautrix-patched/event/cmdschema/parse.go @@ -0,0 +1,478 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const botArrayOpener = "<" +const botArrayCloser = ">" + +func parseQuoted(val string) (parsed, remaining string, quoted bool) { + if len(val) == 0 { + return + } + if !strings.HasPrefix(val, `"`) { + spaceIdx := strings.IndexByte(val, ' ') + if spaceIdx == -1 { + parsed = val + } else { + parsed = val[:spaceIdx] + remaining = strings.TrimLeft(val[spaceIdx+1:], " ") + } + return + } + val = val[1:] + var buf strings.Builder + for { + quoteIdx := strings.IndexByte(val, '"') + var valUntilQuote string + if quoteIdx == -1 { + valUntilQuote = val + } else { + valUntilQuote = val[:quoteIdx] + } + escapeIdx := strings.IndexByte(valUntilQuote, '\\') + if escapeIdx >= 0 { + buf.WriteString(val[:escapeIdx]) + if len(val) > escapeIdx+1 { + buf.WriteByte(val[escapeIdx+1]) + } + val = val[min(escapeIdx+2, len(val)):] + } else if quoteIdx >= 0 { + buf.WriteString(val[:quoteIdx]) + val = val[quoteIdx+1:] + break + } else if buf.Len() == 0 { + // Unterminated quote, no escape characters, val is the whole input + return val, "", true + } else { + // Unterminated quote, but there were escape characters previously + buf.WriteString(val) + val = "" + break + } + } + return buf.String(), strings.TrimLeft(val, " "), true +} + +// ParseInput tries to parse the given text into a bot command event matching this command definition. +// +// If the prefix doesn't match, this will return a nil content and nil error. +// If the prefix does match, some content is always returned, but there may still be an error if parsing failed. +func (ec *EventContent) ParseInput(owner id.UserID, sigils []string, input string) (content *event.MessageEventContent, err error) { + prefix := ec.parsePrefix(input, sigils, owner.String()) + if prefix == "" { + return nil, nil + } + content = &event.MessageEventContent{ + MsgType: event.MsgText, + Body: input, + Mentions: &event.Mentions{UserIDs: []id.UserID{owner}}, + MSC4391BotCommand: &event.MSC4391BotCommandInput{ + Command: ec.Command, + }, + } + content.MSC4391BotCommand.Arguments, err = ec.ParseArguments(input[len(prefix):]) + return content, err +} + +func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { + args := make(map[string]any) + var retErr error + setError := func(err error) { + if err != nil && retErr == nil { + retErr = err + } + } + processParameter := func(param *Parameter, isLast, isTail, isNamed bool) { + origInput := input + var nextVal string + var wasQuoted bool + if param.Schema.SchemaType == SchemaTypeArray { + hasOpener := strings.HasPrefix(input, botArrayOpener) + arrayClosed := false + if hasOpener { + input = input[len(botArrayOpener):] + if strings.HasPrefix(input, botArrayCloser) { + input = strings.TrimLeft(input[len(botArrayCloser):], " ") + arrayClosed = true + } + } + var collector []any + for len(input) > 0 && !arrayClosed { + //origInput = input + nextVal, input, wasQuoted = parseQuoted(input) + if !wasQuoted && hasOpener && strings.HasSuffix(nextVal, botArrayCloser) { + // The value wasn't quoted and has the array delimiter at the end, close the array + nextVal = strings.TrimRight(nextVal, botArrayCloser) + arrayClosed = true + } else if hasOpener && strings.HasPrefix(input, botArrayCloser) { + // The value was quoted or there was a space, and the next character is the + // array delimiter, close the array + input = strings.TrimLeft(input[len(botArrayCloser):], " ") + arrayClosed = true + } else if !hasOpener && !isLast { + // For array arguments in the middle without the <> delimiters, stop after the first item + arrayClosed = true + } + parsedVal, err := param.Schema.Items.ParseString(nextVal) + if err == nil { + collector = append(collector, parsedVal) + } else if hasOpener || isLast { + setError(fmt.Errorf("failed to parse item #%d of array %s: %w", len(collector)+1, param.Key, err)) + } else { + //input = origInput + } + } + args[param.Key] = collector + } else { + nextVal, input, wasQuoted = parseQuoted(input) + if (isLast || isTail) && !wasQuoted && len(input) > 0 { + // If the last argument is not quoted, just treat the rest of the string + // as the argument without escapes (arguments with escapes should be quoted). + nextVal += " " + input + input = "" + } + // Special case for named boolean parameters: if no value is given, treat it as true + if nextVal == "" && !wasQuoted && isNamed && param.Schema.AllowsPrimitive(PrimitiveTypeBoolean) { + args[param.Key] = true + return + } + if nextVal == "" && !wasQuoted && !isNamed && !param.Optional { + setError(fmt.Errorf("missing value for required parameter %s", param.Key)) + } + parsedVal, err := param.Schema.ParseString(nextVal) + if err != nil { + args[param.Key] = param.GetDefaultValue() + // For optional parameters that fail to parse, restore the input and try passing it as the next parameter + if param.Optional && !isLast && !isNamed { + input = strings.TrimLeft(origInput, " ") + } else if !param.Optional || isNamed { + setError(fmt.Errorf("failed to parse %s: %w", param.Key, err)) + } + } else { + args[param.Key] = parsedVal + } + } + } + skipParams := make([]bool, len(ec.Parameters)) + for i, param := range ec.Parameters { + for strings.HasPrefix(input, "--") { + nameEndIdx := strings.IndexAny(input, " =") + if nameEndIdx == -1 { + nameEndIdx = len(input) + } + overrideParam, paramIdx := ec.parameterByName(input[2:nameEndIdx]) + if overrideParam != nil { + // Trim the equals sign, but leave spaces alone to let parseQuoted treat it as empty input + input = strings.TrimPrefix(input[nameEndIdx:], "=") + skipParams[paramIdx] = true + processParameter(overrideParam, false, false, true) + } else { + break + } + } + isTail := param.Key == ec.TailParam + if skipParams[i] || (param.Optional && !isTail) { + continue + } + processParameter(param, i == len(ec.Parameters)-1, isTail, false) + } + jsonArgs, marshalErr := json.Marshal(args) + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal arguments: %w", marshalErr) + } + return jsonArgs, retErr +} + +func (ec *EventContent) parameterByName(name string) (*Parameter, int) { + for i, param := range ec.Parameters { + if strings.EqualFold(param.Key, name) { + return param, i + } + } + return nil, -1 +} + +func (ec *EventContent) parsePrefix(origInput string, sigils []string, owner string) (prefix string) { + input := origInput + var chosenSigil string + for _, sigil := range sigils { + if strings.HasPrefix(input, sigil) { + chosenSigil = sigil + break + } + } + if chosenSigil == "" { + return "" + } + input = input[len(chosenSigil):] + var chosenAlias string + if !strings.HasPrefix(input, ec.Command) { + for _, alias := range ec.Aliases { + if strings.HasPrefix(input, alias) { + chosenAlias = alias + break + } + } + if chosenAlias == "" { + return "" + } + } else { + chosenAlias = ec.Command + } + input = strings.TrimPrefix(input[len(chosenAlias):], owner) + if input == "" || input[0] == ' ' { + input = strings.TrimLeft(input, " ") + return origInput[:len(origInput)-len(input)] + } + return "" +} + +func (pt PrimitiveType) ValidateValue(value any) bool { + _, err := pt.NormalizeValue(value) + return err == nil +} + +func normalizeNumber(value any) (int, error) { + switch typedValue := value.(type) { + case int: + return typedValue, nil + case int64: + return int(typedValue), nil + case float64: + return int(typedValue), nil + case json.Number: + if i, err := typedValue.Int64(); err != nil { + return 0, fmt.Errorf("failed to parse json.Number: %w", err) + } else { + return int(i), nil + } + default: + return 0, fmt.Errorf("unsupported type %T for integer", value) + } +} + +func (pt PrimitiveType) NormalizeValue(value any) (any, error) { + switch pt { + case PrimitiveTypeInteger: + return normalizeNumber(value) + case PrimitiveTypeBoolean: + bv, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("unsupported type %T for boolean", value) + } + return bv, nil + case PrimitiveTypeString, PrimitiveTypeServerName: + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("unsupported type %T for string", value) + } + return str, pt.validateStringValue(str) + case PrimitiveTypeUserID, PrimitiveTypeRoomAlias: + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("unsupported type %T for user ID or room alias", value) + } else if plainErr := pt.validateStringValue(str); plainErr == nil { + return str, nil + } else if parsed, err := id.ParseMatrixURIOrMatrixToURL(str); err != nil { + return nil, fmt.Errorf("couldn't parse %q as plain ID nor matrix URI: %w / %w", value, plainErr, err) + } else if parsed.Sigil1 == '@' && pt == PrimitiveTypeUserID { + return parsed.UserID(), nil + } else if parsed.Sigil1 == '#' && pt == PrimitiveTypeRoomAlias { + return parsed.RoomAlias(), nil + } else { + return nil, fmt.Errorf("unexpected sigil %c for user ID or room alias", parsed.Sigil1) + } + case PrimitiveTypeRoomID, PrimitiveTypeEventID: + riv, err := NormalizeRoomIDValue(value) + if err != nil { + return nil, err + } + return riv, riv.Validate() + default: + return nil, fmt.Errorf("cannot normalize value for argument type %s", pt) + } +} + +func (pt PrimitiveType) validateStringValue(value string) error { + switch pt { + case PrimitiveTypeString: + return nil + case PrimitiveTypeServerName: + if !id.ValidateServerName(value) { + return fmt.Errorf("invalid server name: %q", value) + } + return nil + case PrimitiveTypeUserID: + _, _, err := id.UserID(value).ParseAndValidateRelaxed() + return err + case PrimitiveTypeRoomAlias: + sigil, localpart, serverName := id.ParseCommonIdentifier(value) + if sigil != '#' || localpart == "" || serverName == "" { + return fmt.Errorf("invalid room alias: %q", value) + } else if !id.ValidateServerName(serverName) { + return fmt.Errorf("invalid server name in room alias: %q", serverName) + } + return nil + default: + panic(fmt.Errorf("validateStringValue called with invalid type %s", pt)) + } +} + +func parseBoolean(val string) (bool, error) { + if len(val) == 0 { + return false, fmt.Errorf("cannot parse empty string as boolean") + } + switch strings.ToLower(val) { + case "t", "true", "y", "yes", "1": + return true, nil + case "f", "false", "n", "no", "0": + return false, nil + default: + return false, fmt.Errorf("invalid boolean string: %q", val) + } +} + +var markdownLinkRegex = regexp.MustCompile(`^\[.+]\(([^)]+)\)$`) + +func parseRoomOrEventID(value string) (*RoomIDValue, error) { + if strings.HasPrefix(value, "[") && strings.Contains(value, "](") && strings.HasSuffix(value, ")") { + matches := markdownLinkRegex.FindStringSubmatch(value) + if len(matches) == 2 { + value = matches[1] + } + } + parsed, err := id.ParseMatrixURIOrMatrixToURL(value) + if err != nil && strings.HasPrefix(value, "!") { + return &RoomIDValue{ + Type: PrimitiveTypeRoomID, + RoomID: id.RoomID(value), + }, nil + } + if err != nil { + return nil, err + } else if parsed.Sigil1 != '!' { + return nil, fmt.Errorf("unexpected sigil %c for room ID", parsed.Sigil1) + } else if parsed.MXID2 != "" && parsed.Sigil2 != '$' { + return nil, fmt.Errorf("unexpected sigil %c for event ID", parsed.Sigil2) + } + valType := PrimitiveTypeRoomID + if parsed.MXID2 != "" { + valType = PrimitiveTypeEventID + } + return &RoomIDValue{ + Type: valType, + RoomID: parsed.RoomID(), + Via: parsed.Via, + EventID: parsed.EventID(), + }, nil +} + +func (pt PrimitiveType) ParseString(value string) (any, error) { + switch pt { + case PrimitiveTypeInteger: + return strconv.Atoi(value) + case PrimitiveTypeBoolean: + return parseBoolean(value) + case PrimitiveTypeString, PrimitiveTypeServerName, PrimitiveTypeUserID: + return value, pt.validateStringValue(value) + case PrimitiveTypeRoomAlias: + plainErr := pt.validateStringValue(value) + if plainErr == nil { + return value, nil + } + parsed, err := id.ParseMatrixURIOrMatrixToURL(value) + if err != nil { + return nil, fmt.Errorf("couldn't parse %q as plain room alias nor matrix URI: %w / %w", value, plainErr, err) + } else if parsed.Sigil1 != '#' { + return nil, fmt.Errorf("unexpected sigil %c for room alias", parsed.Sigil1) + } + return parsed.RoomAlias(), nil + case PrimitiveTypeRoomID, PrimitiveTypeEventID: + parsed, err := parseRoomOrEventID(value) + if err != nil { + return nil, err + } else if pt != parsed.Type { + return nil, fmt.Errorf("mismatching argument type: expected %s but got %s", pt, parsed.Type) + } + return parsed, nil + default: + return nil, fmt.Errorf("cannot parse string for argument type %s", pt) + } +} + +func (ps *ParameterSchema) ParseString(value string) (any, error) { + if ps == nil { + return nil, fmt.Errorf("parameter schema is nil") + } + switch ps.SchemaType { + case SchemaTypePrimitive: + return ps.Type.ParseString(value) + case SchemaTypeLiteral: + switch typedValue := ps.Value.(type) { + case string: + if value == typedValue { + return typedValue, nil + } else { + return nil, fmt.Errorf("literal value %q does not match %q", typedValue, value) + } + case int, int64, float64, json.Number: + expectedVal, _ := normalizeNumber(typedValue) + intVal, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf("failed to parse integer literal: %w", err) + } else if intVal != expectedVal { + return nil, fmt.Errorf("literal value %d does not match %d", expectedVal, intVal) + } + return intVal, nil + case bool: + boolVal, err := parseBoolean(value) + if err != nil { + return nil, fmt.Errorf("failed to parse boolean literal: %w", err) + } else if boolVal != typedValue { + return nil, fmt.Errorf("literal value %t does not match %t", typedValue, boolVal) + } + return boolVal, nil + case RoomIDValue, *RoomIDValue, map[string]any, json.RawMessage: + expectedVal, _ := NormalizeRoomIDValue(typedValue) + parsed, err := parseRoomOrEventID(value) + if err != nil { + return nil, fmt.Errorf("failed to parse room or event ID literal: %w", err) + } else if !parsed.Equals(expectedVal) { + return nil, fmt.Errorf("literal value %s does not match %s", expectedVal, parsed) + } + return parsed, nil + default: + return nil, fmt.Errorf("unsupported literal type %T", ps.Value) + } + case SchemaTypeUnion: + var errs []error + for _, variant := range ps.Variants { + if parsed, err := variant.ParseString(value); err == nil { + return parsed, nil + } else { + errs = append(errs, err) + } + } + return nil, fmt.Errorf("no union variant matched: %w", errors.Join(errs...)) + case SchemaTypeArray: + return nil, fmt.Errorf("cannot parse string for array schema type") + default: + return nil, fmt.Errorf("unknown schema type %s", ps.SchemaType) + } +} diff --git a/mautrix-patched/event/cmdschema/parse_test.go b/mautrix-patched/event/cmdschema/parse_test.go new file mode 100644 index 00000000..1e0d1817 --- /dev/null +++ b/mautrix-patched/event/cmdschema/parse_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exbytes" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/event/cmdschema/testdata" +) + +type QuoteParseOutput struct { + Parsed string + Remaining string + Quoted bool +} + +func (qpo *QuoteParseOutput) UnmarshalJSON(data []byte) error { + var arr []any + if err := json.Unmarshal(data, &arr); err != nil { + return err + } + qpo.Parsed = arr[0].(string) + qpo.Remaining = arr[1].(string) + qpo.Quoted = arr[2].(bool) + return nil +} + +type QuoteParseTestData struct { + Name string `json:"name"` + Input string `json:"input"` + Output QuoteParseOutput `json:"output"` +} + +func loadFile[T any](name string) (into T) { + quoteData := exerrors.Must(testdata.FS.ReadFile(name)) + exerrors.PanicIfNotNil(json.Unmarshal(quoteData, &into)) + return +} + +func TestParseQuoted(t *testing.T) { + qptd := loadFile[[]QuoteParseTestData]("parse_quote.json") + for _, test := range qptd { + t.Run(test.Name, func(t *testing.T) { + parsed, remaining, quoted := parseQuoted(test.Input) + assert.Equalf(t, test.Output, QuoteParseOutput{ + Parsed: parsed, + Remaining: remaining, + Quoted: quoted, + }, "Failed with input `%s`", test.Input) + // Note: can't just test that requoted == input, because some inputs + // have unnecessary escapes which won't survive roundtripping + t.Run("roundtrip", func(t *testing.T) { + requoted := quoteString(parsed) + " " + remaining + reparsed, newRemaining, _ := parseQuoted(requoted) + assert.Equal(t, parsed, reparsed) + assert.Equal(t, remaining, newRemaining) + }) + }) + } +} + +type CommandTestData struct { + Spec *EventContent + Tests []*CommandTestUnit +} + +type CommandTestUnit struct { + Name string `json:"name"` + Input string `json:"input"` + Broken string `json:"broken,omitempty"` + Error bool `json:"error"` + Output json.RawMessage `json:"output"` +} + +func compactJSON(input json.RawMessage) json.RawMessage { + var buf bytes.Buffer + exerrors.PanicIfNotNil(json.Compact(&buf, input)) + return buf.Bytes() +} + +func TestMSC4391BotCommandEventContent_ParseInput(t *testing.T) { + for _, cmd := range exerrors.Must(testdata.FS.ReadDir("commands")) { + t.Run(strings.TrimSuffix(cmd.Name(), ".json"), func(t *testing.T) { + ctd := loadFile[CommandTestData]("commands/" + cmd.Name()) + for _, test := range ctd.Tests { + outputStr := exbytes.UnsafeString(compactJSON(test.Output)) + t.Run(test.Name, func(t *testing.T) { + if test.Broken != "" { + t.Skip(test.Broken) + } + output, err := ctd.Spec.ParseInput("@testbot", []string{"/"}, test.Input) + if test.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + if outputStr == "null" { + assert.Nil(t, output) + } else { + assert.Equal(t, ctd.Spec.Command, output.MSC4391BotCommand.Command) + assert.Equalf(t, outputStr, exbytes.UnsafeString(output.MSC4391BotCommand.Arguments), "Input: %s", test.Input) + } + }) + } + }) + } +} diff --git a/mautrix-patched/event/cmdschema/roomid.go b/mautrix-patched/event/cmdschema/roomid.go new file mode 100644 index 00000000..98c421fc --- /dev/null +++ b/mautrix-patched/event/cmdschema/roomid.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "maunium.net/go/mautrix/id" +) + +var ParameterSchemaJoinableRoom = Union( + PrimitiveTypeRoomID.Schema(), + PrimitiveTypeRoomAlias.Schema(), +) + +type RoomIDValue struct { + Type PrimitiveType `json:"type"` + RoomID id.RoomID `json:"id"` + Via []string `json:"via,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` +} + +func NormalizeRoomIDValue(input any) (riv *RoomIDValue, err error) { + switch typedValue := input.(type) { + case map[string]any, json.RawMessage: + var raw json.RawMessage + if raw, err = json.Marshal(input); err != nil { + err = fmt.Errorf("failed to roundtrip room ID value: %w", err) + } else if err = json.Unmarshal(raw, &riv); err != nil { + err = fmt.Errorf("failed to roundtrip room ID value: %w", err) + } + case *RoomIDValue: + riv = typedValue + case RoomIDValue: + riv = &typedValue + default: + err = fmt.Errorf("unsupported type %T for room or event ID", input) + } + return +} + +func (riv *RoomIDValue) String() string { + return riv.URI().String() +} + +func (riv *RoomIDValue) URI() *id.MatrixURI { + if riv == nil { + return nil + } + switch riv.Type { + case PrimitiveTypeRoomID: + return riv.RoomID.URI(riv.Via...) + case PrimitiveTypeEventID: + return riv.RoomID.EventURI(riv.EventID, riv.Via...) + default: + return nil + } +} + +func (riv *RoomIDValue) Equals(other *RoomIDValue) bool { + if riv == nil || other == nil { + return riv == other + } + return riv.Type == other.Type && + riv.RoomID == other.RoomID && + riv.EventID == other.EventID && + slices.Equal(riv.Via, other.Via) +} + +func (riv *RoomIDValue) Validate() error { + if riv == nil { + return fmt.Errorf("value is nil") + } + switch riv.Type { + case PrimitiveTypeRoomID: + if riv.EventID != "" { + return fmt.Errorf("event ID must be empty for room ID type") + } + case PrimitiveTypeEventID: + if !strings.HasPrefix(riv.EventID.String(), "$") { + return fmt.Errorf("event ID not valid: %q", riv.EventID) + } + default: + return fmt.Errorf("unexpected type %s for room/event ID value", riv.Type) + } + for _, via := range riv.Via { + if !id.ValidateServerName(via) { + return fmt.Errorf("invalid server name %q in vias", via) + } + } + sigil, localpart, serverName := id.ParseCommonIdentifier(riv.RoomID) + if sigil != '!' { + return fmt.Errorf("room ID does not start with !: %q", riv.RoomID) + } else if localpart == "" && serverName == "" { + return fmt.Errorf("room ID has empty localpart and server name: %q", riv.RoomID) + } else if serverName != "" && !id.ValidateServerName(serverName) { + return fmt.Errorf("invalid server name %q in room ID", serverName) + } + return nil +} + +func (riv *RoomIDValue) IsValid() bool { + return riv.Validate() == nil +} + +type RoomIDOrString string + +func (ros *RoomIDOrString) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return fmt.Errorf("empty data for room ID or string") + } + if data[0] == '"' { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + *ros = RoomIDOrString(str) + return nil + } + var riv RoomIDValue + if err := json.Unmarshal(data, &riv); err != nil { + return err + } else if err = riv.Validate(); err != nil { + return err + } + *ros = RoomIDOrString(riv.String()) + return nil +} diff --git a/mautrix-patched/event/cmdschema/stringify.go b/mautrix-patched/event/cmdschema/stringify.go new file mode 100644 index 00000000..c5c57c53 --- /dev/null +++ b/mautrix-patched/event/cmdschema/stringify.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package cmdschema + +import ( + "encoding/json" + "strconv" + "strings" +) + +var quoteEscaper = strings.NewReplacer( + `"`, `\"`, + `\`, `\\`, +) + +const charsToQuote = ` \` + botArrayOpener + botArrayCloser + +func quoteString(val string) string { + if val == "" { + return `""` + } + val = quoteEscaper.Replace(val) + if strings.ContainsAny(val, charsToQuote) { + return `"` + val + `"` + } + return val +} + +func (ec *EventContent) StringifyArgs(args any) string { + var argMap map[string]any + switch typedArgs := args.(type) { + case json.RawMessage: + err := json.Unmarshal(typedArgs, &argMap) + if err != nil { + return "" + } + case map[string]any: + argMap = typedArgs + default: + if b, err := json.Marshal(args); err != nil { + return "" + } else if err = json.Unmarshal(b, &argMap); err != nil { + return "" + } + } + parts := make([]string, 0, len(ec.Parameters)) + for i, param := range ec.Parameters { + isLast := i == len(ec.Parameters)-1 + val := argMap[param.Key] + if val == nil { + val = param.DefaultValue + if val == nil && !param.Optional { + val = param.Schema.GetDefaultValue() + } + } + if val == nil { + continue + } + var stringified string + if param.Schema.SchemaType == SchemaTypeArray { + stringified = arrayArgumentToString(val, isLast) + } else { + stringified = singleArgumentToString(val) + } + if stringified != "" { + parts = append(parts, stringified) + } + } + return strings.Join(parts, " ") +} + +func arrayArgumentToString(val any, isLast bool) string { + valArr, ok := val.([]any) + if !ok { + return "" + } + parts := make([]string, 0, len(valArr)) + for _, elem := range valArr { + stringified := singleArgumentToString(elem) + if stringified != "" { + parts = append(parts, stringified) + } + } + joinedParts := strings.Join(parts, " ") + if isLast && len(parts) > 0 { + return joinedParts + } + return botArrayOpener + joinedParts + botArrayCloser +} + +func singleArgumentToString(val any) string { + switch typedVal := val.(type) { + case string: + return quoteString(typedVal) + case json.Number: + return typedVal.String() + case bool: + return strconv.FormatBool(typedVal) + case int: + return strconv.Itoa(typedVal) + case int64: + return strconv.FormatInt(typedVal, 10) + case float64: + return strconv.FormatInt(int64(typedVal), 10) + case map[string]any, json.RawMessage, RoomIDValue, *RoomIDValue: + normalized, err := NormalizeRoomIDValue(typedVal) + if err != nil { + return "" + } + uri := normalized.URI() + if uri == nil { + return "" + } + return quoteString(uri.String()) + default: + return "" + } +} diff --git a/mautrix-patched/event/cmdschema/testdata/commands.schema.json b/mautrix-patched/event/cmdschema/testdata/commands.schema.json new file mode 100644 index 00000000..e53382db --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/commands.schema.json @@ -0,0 +1,281 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema#", + "$id": "commands.schema.json", + "title": "ParseInput test cases", + "description": "JSON schema for test case files containing command specifications and test cases", + "type": "object", + "required": [ + "spec", + "tests" + ], + "additionalProperties": false, + "properties": { + "spec": { + "title": "MSC4391 Command Description", + "description": "JSON schema defining the structure of a bot command event content", + "type": "object", + "required": [ + "command" + ], + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "description": "The command name that triggers this bot command" + }, + "aliases": { + "type": "array", + "description": "Alternative names/aliases for this command", + "items": { + "type": "string" + } + }, + "parameters": { + "type": "array", + "description": "List of parameters accepted by this command", + "items": { + "$ref": "#/$defs/Parameter" + } + }, + "description": { + "$ref": "#/$defs/ExtensibleTextContainer", + "description": "Human-readable description of the command" + }, + "fi.mau.tail_parameter": { + "type": "string", + "description": "The key of the parameter that accepts remaining arguments as tail text" + }, + "source": { + "type": "string", + "description": "The user ID of the bot that responds to this command" + } + } + }, + "tests": { + "type": "array", + "description": "Array of test cases for the command", + "items": { + "type": "object", + "description": "A single test case for command parsing", + "required": [ + "name", + "input" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the test case" + }, + "input": { + "type": "string", + "description": "The command input string to parse" + }, + "output": { + "description": "The expected parsed parameter values, or null if the parsing is expected to fail", + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "error": { + "type": "boolean", + "description": "Whether parsing should result in an error. May still produce output.", + "default": false + } + } + } + } + }, + "$defs": { + "ExtensibleTextContainer": { + "type": "object", + "description": "Container for text that can have multiple representations", + "required": [ + "m.text" + ], + "properties": { + "m.text": { + "type": "array", + "description": "Array of text representations in different formats", + "items": { + "$ref": "#/$defs/ExtensibleText" + } + } + } + }, + "ExtensibleText": { + "type": "object", + "description": "A text representation with a specific MIME type", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string", + "description": "The text content" + }, + "mimetype": { + "type": "string", + "description": "The MIME type of the text (e.g., text/plain, text/html)", + "default": "text/plain", + "examples": [ + "text/plain", + "text/html" + ] + } + } + }, + "Parameter": { + "type": "object", + "description": "A parameter definition for a command", + "required": [ + "key", + "schema" + ], + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The identifier for this parameter" + }, + "schema": { + "$ref": "#/$defs/ParameterSchema", + "description": "The schema defining the type and structure of this parameter" + }, + "optional": { + "type": "boolean", + "description": "Whether this parameter is optional", + "default": false + }, + "description": { + "$ref": "#/$defs/ExtensibleTextContainer", + "description": "Human-readable description of this parameter" + }, + "fi.mau.default_value": { + "description": "Default value for this parameter if not provided" + } + } + }, + "ParameterSchema": { + "type": "object", + "description": "Schema definition for a parameter value", + "required": [ + "schema_type" + ], + "additionalProperties": false, + "properties": { + "schema_type": { + "type": "string", + "enum": [ + "primitive", + "array", + "union", + "literal" + ], + "description": "The type of schema" + } + }, + "allOf": [ + { + "if": { + "properties": { + "schema_type": { + "const": "primitive" + } + } + }, + "then": { + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "integer", + "boolean", + "server_name", + "user_id", + "room_id", + "room_alias", + "event_id" + ], + "description": "The primitive type (only for schema_type: primitive)" + } + } + } + }, + { + "if": { + "properties": { + "schema_type": { + "const": "array" + } + } + }, + "then": { + "required": [ + "items" + ], + "properties": { + "items": { + "$ref": "#/$defs/ParameterSchema", + "description": "The schema for array items (only for schema_type: array)" + } + } + } + }, + { + "if": { + "properties": { + "schema_type": { + "const": "union" + } + } + }, + "then": { + "required": [ + "variants" + ], + "properties": { + "variants": { + "type": "array", + "description": "The possible variants (only for schema_type: union)", + "items": { + "$ref": "#/$defs/ParameterSchema" + }, + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "schema_type": { + "const": "literal" + } + } + }, + "then": { + "required": [ + "value" + ], + "properties": { + "value": { + "description": "The literal value (only for schema_type: literal)" + } + } + } + } + ] + } + } +} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/flags.json b/mautrix-patched/event/cmdschema/testdata/commands/flags.json new file mode 100644 index 00000000..6ce1f4da --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/commands/flags.json @@ -0,0 +1,126 @@ +{ + "$schema": "../commands.schema.json#", + "spec": { + "command": "flag", + "source": "@testbot", + "parameters": [ + { + "key": "meow", + "schema": { + "schema_type": "primitive", + "type": "string" + } + }, + { + "key": "user", + "schema": { + "schema_type": "primitive", + "type": "user_id" + }, + "optional": true + }, + { + "key": "woof", + "schema": { + "schema_type": "primitive", + "type": "boolean" + }, + "optional": true, + "fi.mau.default_value": false + } + ], + "fi.mau.tail_parameter": "user" + }, + "tests": [ + { + "name": "no flags", + "input": "/flag mrrp", + "output": { + "meow": "mrrp", + "user": null + } + }, + { + "name": "no flags, has tail", + "input": "/flag mrrp @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com" + } + }, + { + "name": "named flag at start", + "input": "/flag --woof=yes mrrp @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "boolean flag without value", + "input": "/flag --woof mrrp @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "user id flag without value", + "input": "/flag --user --woof mrrp", + "error": true, + "output": { + "meow": "mrrp", + "user": null, + "woof": true + } + }, + { + "name": "named flag in the middle", + "input": "/flag mrrp --woof=yes @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "named flag in the middle with different value", + "input": "/flag mrrp --woof=no @user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": false + } + }, + { + "name": "all variables named", + "input": "/flag --woof=no --meow=mrrp --user=@user:example.com", + "output": { + "meow": "mrrp", + "user": "@user:example.com", + "woof": false + } + }, + { + "name": "all variables named with quotes", + "input": "/flag --woof --meow=\"meow meow mrrp\" --user=\"@user:example.com\"", + "output": { + "meow": "meow meow mrrp", + "user": "@user:example.com", + "woof": true + } + }, + { + "name": "invalid value for named parameter", + "input": "/flag --user=meowings mrrp --woof", + "error": true, + "output": { + "meow": "mrrp", + "user": null, + "woof": true + } + } + ] +} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json b/mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json new file mode 100644 index 00000000..1351c292 --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json @@ -0,0 +1,85 @@ +{ + "$schema": "../commands.schema.json#", + "spec": { + "command": "test room reference", + "source": "@testbot", + "parameters": [ + { + "key": "room", + "schema": { + "schema_type": "union", + "variants": [ + { + "schema_type": "primitive", + "type": "room_id" + }, + { + "schema_type": "primitive", + "type": "room_alias" + } + ] + } + } + ] + }, + "tests": [ + { + "name": "room alias", + "input": "/test room reference #test:matrix.org", + "output": { + "room": "#test:matrix.org" + } + }, + { + "name": "room id", + "input": "/test room reference !aiwVrNhPwbGBNjqlNu:matrix.org", + "output": { + "room": { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" + } + } + }, + { + "name": "room id matrix.to link", + "input": "/test room reference https://matrix.to/#/!aiwVrNhPwbGBNjqlNu:matrix.org?via=example.com", + "output": { + "room": { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org", + "via": [ + "example.com" + ] + } + } + }, + { + "name": "room id matrix.to link with url encoding", + "input": "/test room reference https://matrix.to/#/!%23test%2Froom%0Aversion%20%3Cu%3E11%3C%2Fu%3E%2C%20with%20%40%F0%9F%90%88%EF%B8%8F%3Amaunium.net?via=maunium.net", + "broken": "Go's url.URL does url decoding on the fragment, which breaks splitting the path segments properly", + "output": { + "room": { + "type": "room_id", + "id": "!#test/room\nversion 11, with @🐈️:maunium.net", + "via": [ + "maunium.net" + ] + } + } + }, + { + "name": "room id matrix: URI", + "input": "/test room reference matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", + "output": { + "room": { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + "via": [ + "maunium.net", + "matrix.org" + ] + } + } + } + ] +} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json b/mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json new file mode 100644 index 00000000..aa266054 --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json @@ -0,0 +1,106 @@ +{ + "$schema": "../commands.schema.json#", + "spec": { + "command": "test room reference", + "source": "@testbot", + "parameters": [ + { + "key": "rooms", + "schema": { + "schema_type": "array", + "items": { + "schema_type": "union", + "variants": [ + { + "schema_type": "primitive", + "type": "room_id" + }, + { + "schema_type": "primitive", + "type": "room_alias" + } + ] + } + } + } + ] + }, + "tests": [ + { + "name": "room alias", + "input": "/test room reference #test:matrix.org", + "output": { + "rooms": [ + "#test:matrix.org" + ] + } + }, + { + "name": "room id", + "input": "/test room reference !aiwVrNhPwbGBNjqlNu:matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" + } + ] + } + }, + { + "name": "two room ids", + "input": "/test room reference !mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ !aiwVrNhPwbGBNjqlNu:matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ" + }, + { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" + } + ] + } + }, + { + "name": "room id matrix: URI", + "input": "/test room reference matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + "via": [ + "maunium.net", + "matrix.org" + ] + } + ] + } + }, + { + "name": "room id matrix: URI and matrix.to URL", + "input": "/test room reference https://matrix.to/#/!aiwVrNhPwbGBNjqlNu:matrix.org?via=example.com matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", + "output": { + "rooms": [ + { + "type": "room_id", + "id": "!aiwVrNhPwbGBNjqlNu:matrix.org", + "via": [ + "example.com" + ] + }, + { + "type": "room_id", + "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + "via": [ + "maunium.net", + "matrix.org" + ] + } + ] + } + } + ] +} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/simple.json b/mautrix-patched/event/cmdschema/testdata/commands/simple.json new file mode 100644 index 00000000..94667323 --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/commands/simple.json @@ -0,0 +1,46 @@ +{ + "$schema": "../commands.schema.json#", + "spec": { + "command": "test simple", + "source": "@testbot", + "parameters": [ + { + "key": "meow", + "schema": { + "schema_type": "primitive", + "type": "string" + } + } + ] + }, + "tests": [ + { + "name": "success", + "input": "/test simple mrrp", + "output": { + "meow": "mrrp" + } + }, + { + "name": "directed success", + "input": "/test simple@testbot mrrp", + "output": { + "meow": "mrrp" + } + }, + { + "name": "missing parameter", + "input": "/test simple", + "error": true, + "output": { + "meow": "" + } + }, + { + "name": "directed at another bot", + "input": "/test simple@anotherbot mrrp", + "error": false, + "output": null + } + ] +} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/tail.json b/mautrix-patched/event/cmdschema/testdata/commands/tail.json new file mode 100644 index 00000000..9782f8ec --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/commands/tail.json @@ -0,0 +1,60 @@ +{ + "$schema": "../commands.schema.json#", + "spec": { + "command": "tail", + "source": "@testbot", + "parameters": [ + { + "key": "meow", + "schema": { + "schema_type": "primitive", + "type": "string" + } + }, + { + "key": "reason", + "schema": { + "schema_type": "primitive", + "type": "string" + }, + "optional": true + }, + { + "key": "woof", + "schema": { + "schema_type": "primitive", + "type": "boolean" + }, + "optional": true + } + ], + "fi.mau.tail_parameter": "reason" + }, + "tests": [ + { + "name": "no tail or flag", + "input": "/tail mrrp", + "output": { + "meow": "mrrp", + "reason": "" + } + }, + { + "name": "tail, no flag", + "input": "/tail mrrp meow meow", + "output": { + "meow": "mrrp", + "reason": "meow meow" + } + }, + { + "name": "flag before tail", + "input": "/tail mrrp --woof meow meow", + "output": { + "meow": "mrrp", + "reason": "meow meow", + "woof": true + } + } + ] +} diff --git a/mautrix-patched/event/cmdschema/testdata/data.go b/mautrix-patched/event/cmdschema/testdata/data.go new file mode 100644 index 00000000..eceea3d2 --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/data.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package testdata + +import ( + "embed" +) + +//go:embed * +var FS embed.FS diff --git a/mautrix-patched/event/cmdschema/testdata/parse_quote.json b/mautrix-patched/event/cmdschema/testdata/parse_quote.json new file mode 100644 index 00000000..8f52b7f5 --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/parse_quote.json @@ -0,0 +1,30 @@ +[ + {"name": "empty string", "input": "", "output": ["", "", false]}, + {"name": "single word", "input": "meow", "output": ["meow", "", false]}, + {"name": "two words", "input": "meow woof", "output": ["meow", "woof", false]}, + {"name": "many words", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, + {"name": "extra spaces", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, + {"name": "trailing space", "input": "meow ", "output": ["meow", "", false]}, + {"name": "only spaces", "input": " ", "output": ["", "", false]}, + {"name": "leading spaces", "input": " meow woof", "output": ["", "meow woof", false]}, + {"name": "backslash at end unquoted", "input": "meow\\ woof", "output": ["meow\\", "woof", false]}, + {"name": "quoted word", "input": "\"meow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, + {"name": "quoted words", "input": "\"meow meow\" mrrp", "output": ["meow meow", "mrrp", true]}, + {"name": "spaces in quotes", "input": "\" meow meow \" mrrp", "output": [" meow meow ", "mrrp", true]}, + {"name": "empty quoted string", "input": "\"\"", "output": ["", "", true]}, + {"name": "empty quoted with trailing", "input": "\"\" meow", "output": ["", "meow", true]}, + {"name": "quote no space before next", "input": "\"meow\"woof", "output": ["meow", "woof", true]}, + {"name": "just opening quote", "input": "\"", "output": ["", "", true]}, + {"name": "quote then space then text", "input": "\" meow", "output": [" meow", "", true]}, + {"name": "quotes after word", "input": "meow \" meow mrrp \"", "output": ["meow", "\" meow mrrp \"", false]}, + {"name": "escaped quote", "input": "\"meow\\\" meow\" mrrp", "output": ["meow\" meow", "mrrp", true]}, + {"name": "missing end quote", "input": "\"meow meow mrrp", "output": ["meow meow mrrp", "", true]}, + {"name": "missing end quote with escaped quote", "input": "\"meow\\\" meow mrrp", "output": ["meow\" meow mrrp", "", true]}, + {"name": "quote in the middle", "input": "me\"ow meow mrrp", "output": ["me\"ow", "meow mrrp", false]}, + {"name": "backslash in the middle", "input": "me\\ow meow mrrp", "output": ["me\\ow", "meow mrrp", false]}, + {"name": "other escaped character", "input": "\"m\\eow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, + {"name": "escaped backslashes", "input": "\"m\\\\e\\\"ow\\\\\" meow mrrp", "output": ["m\\e\"ow\\", "meow mrrp", true]}, + {"name": "just quotes", "input": "\"\\\"\\\"\\\\\\\"\" meow", "output": ["\"\"\\\"", "meow", true]}, + {"name": "escape at eof", "input": "\"meow\\", "output": ["meow", "", true]}, + {"name": "escaped backslash at eof", "input": "\"meow\\\\", "output": ["meow\\", "", true]} +] diff --git a/mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json b/mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json new file mode 100644 index 00000000..9f249116 --- /dev/null +++ b/mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema#", + "$id": "parse_quote.schema.json", + "title": "parseQuote test cases", + "description": "Test cases for the parseQuoted function", + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "input", + "output" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the test case" + }, + "input": { + "type": "string", + "description": "Input string to be parsed" + }, + "output": { + "type": "array", + "description": "Expected output of parsing: [first word, remaining text, was quoted]", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "type": "string", + "description": "First parsed word" + }, + { + "type": "string", + "description": "Remaining text after the first word" + }, + { + "type": "boolean", + "description": "Whether the first word was quoted" + } + ] + } + }, + "additionalProperties": false + } +} diff --git a/mautrix-patched/event/content.go b/mautrix-patched/event/content.go new file mode 100644 index 00000000..3a9b0024 --- /dev/null +++ b/mautrix-patched/event/content.go @@ -0,0 +1,613 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" +) + +// TypeMap is a mapping from event type to the content struct type. +// This is used by Content.ParseRaw() for creating the correct type of struct. +var TypeMap = map[Type]reflect.Type{ + StateMember: reflect.TypeOf(MemberEventContent{}), + StateThirdPartyInvite: reflect.TypeOf(ThirdPartyInviteEventContent{}), + StatePowerLevels: reflect.TypeOf(PowerLevelsEventContent{}), + StateCanonicalAlias: reflect.TypeOf(CanonicalAliasEventContent{}), + StateRoomName: reflect.TypeOf(RoomNameEventContent{}), + StateRoomAvatar: reflect.TypeOf(RoomAvatarEventContent{}), + StateServerACL: reflect.TypeOf(ServerACLEventContent{}), + StateTopic: reflect.TypeOf(TopicEventContent{}), + StateTombstone: reflect.TypeOf(TombstoneEventContent{}), + StateCreate: reflect.TypeOf(CreateEventContent{}), + StateJoinRules: reflect.TypeOf(JoinRulesEventContent{}), + StateHistoryVisibility: reflect.TypeOf(HistoryVisibilityEventContent{}), + StateGuestAccess: reflect.TypeOf(GuestAccessEventContent{}), + StatePinnedEvents: reflect.TypeOf(PinnedEventsEventContent{}), + StatePolicyRoom: reflect.TypeOf(ModPolicyContent{}), + StatePolicyServer: reflect.TypeOf(ModPolicyContent{}), + StatePolicyUser: reflect.TypeOf(ModPolicyContent{}), + StateEncryption: reflect.TypeOf(EncryptionEventContent{}), + StateBridge: reflect.TypeOf(BridgeEventContent{}), + StateHalfShotBridge: reflect.TypeOf(BridgeEventContent{}), + StateSpaceParent: reflect.TypeOf(SpaceParentEventContent{}), + StateSpaceChild: reflect.TypeOf(SpaceChildEventContent{}), + + StateRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}), + StateUnstableRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}), + + StateImagePack: reflect.TypeOf(ImagePackEventContent{}), + StateUnstableImagePack: reflect.TypeOf(ImagePackEventContent{}), + + StateLegacyPolicyRoom: reflect.TypeOf(ModPolicyContent{}), + StateLegacyPolicyServer: reflect.TypeOf(ModPolicyContent{}), + StateLegacyPolicyUser: reflect.TypeOf(ModPolicyContent{}), + StateUnstablePolicyRoom: reflect.TypeOf(ModPolicyContent{}), + StateUnstablePolicyServer: reflect.TypeOf(ModPolicyContent{}), + StateUnstablePolicyUser: reflect.TypeOf(ModPolicyContent{}), + + StateElementFunctionalMembers: reflect.TypeOf(ElementFunctionalMembersContent{}), + StateBeeperRoomFeatures: reflect.TypeOf(RoomFeatures{}), + StateBeeperDisappearingTimer: reflect.TypeOf(BeeperDisappearingTimer{}), + + EventMessage: reflect.TypeOf(MessageEventContent{}), + EventSticker: reflect.TypeOf(MessageEventContent{}), + EventEncrypted: reflect.TypeOf(EncryptedEventContent{}), + EventRedaction: reflect.TypeOf(RedactionEventContent{}), + EventReaction: reflect.TypeOf(ReactionEventContent{}), + + EventUnstablePollStart: reflect.TypeOf(PollStartEventContent{}), + EventUnstablePollResponse: reflect.TypeOf(PollResponseEventContent{}), + + BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), + BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), + BeeperDeleteChat: reflect.TypeOf(BeeperChatDeleteEventContent{}), + BeeperAcceptMessageRequest: reflect.TypeOf(BeeperAcceptMessageRequestEventContent{}), + BeeperSendState: reflect.TypeOf(BeeperSendStateEventContent{}), + + AccountDataRoomTags: reflect.TypeOf(TagEventContent{}), + AccountDataDirectChats: reflect.TypeOf(DirectChatsEventContent{}), + AccountDataFullyRead: reflect.TypeOf(FullyReadEventContent{}), + AccountDataIgnoredUserList: reflect.TypeOf(IgnoredUserListEventContent{}), + AccountDataMarkedUnread: reflect.TypeOf(MarkedUnreadEventContent{}), + AccountDataBeeperMute: reflect.TypeOf(BeeperMuteEventContent{}), + + AccountDataImagePackRooms: reflect.TypeOf(ImagePackRoomsEventContent{}), + AccountDataUnstableImagePackRooms: reflect.TypeOf(ImagePackRoomsEventContent{}), + + EphemeralEventTyping: reflect.TypeOf(TypingEventContent{}), + EphemeralEventReceipt: reflect.TypeOf(ReceiptEventContent{}), + EphemeralEventPresence: reflect.TypeOf(PresenceEventContent{}), + + InRoomVerificationReady: reflect.TypeOf(VerificationReadyEventContent{}), + InRoomVerificationStart: reflect.TypeOf(VerificationStartEventContent{}), + InRoomVerificationDone: reflect.TypeOf(VerificationDoneEventContent{}), + InRoomVerificationCancel: reflect.TypeOf(VerificationCancelEventContent{}), + + InRoomVerificationAccept: reflect.TypeOf(VerificationAcceptEventContent{}), + InRoomVerificationKey: reflect.TypeOf(VerificationKeyEventContent{}), + InRoomVerificationMAC: reflect.TypeOf(VerificationMACEventContent{}), + + ToDeviceRoomKey: reflect.TypeOf(RoomKeyEventContent{}), + ToDeviceForwardedRoomKey: reflect.TypeOf(ForwardedRoomKeyEventContent{}), + ToDeviceRoomKeyRequest: reflect.TypeOf(RoomKeyRequestEventContent{}), + ToDeviceEncrypted: reflect.TypeOf(EncryptedEventContent{}), + ToDeviceRoomKeyWithheld: reflect.TypeOf(RoomKeyWithheldEventContent{}), + ToDeviceSecretRequest: reflect.TypeOf(SecretRequestEventContent{}), + ToDeviceSecretSend: reflect.TypeOf(SecretSendEventContent{}), + ToDeviceDummy: reflect.TypeOf(DummyEventContent{}), + + ToDeviceVerificationRequest: reflect.TypeOf(VerificationRequestEventContent{}), + ToDeviceVerificationReady: reflect.TypeOf(VerificationReadyEventContent{}), + ToDeviceVerificationStart: reflect.TypeOf(VerificationStartEventContent{}), + ToDeviceVerificationDone: reflect.TypeOf(VerificationDoneEventContent{}), + ToDeviceVerificationCancel: reflect.TypeOf(VerificationCancelEventContent{}), + + ToDeviceVerificationAccept: reflect.TypeOf(VerificationAcceptEventContent{}), + ToDeviceVerificationKey: reflect.TypeOf(VerificationKeyEventContent{}), + ToDeviceVerificationMAC: reflect.TypeOf(VerificationMACEventContent{}), + + ToDeviceOrgMatrixRoomKeyWithheld: reflect.TypeOf(RoomKeyWithheldEventContent{}), + + ToDeviceBeeperRoomKeyAck: reflect.TypeOf(BeeperRoomKeyAckEventContent{}), + ToDeviceBeeperStreamSubscribe: reflect.TypeOf(BeeperStreamSubscribeEventContent{}), + ToDeviceBeeperStreamUpdate: reflect.TypeOf(BeeperStreamUpdateEventContent{}), + + CallInvite: reflect.TypeOf(CallInviteEventContent{}), + CallCandidates: reflect.TypeOf(CallCandidatesEventContent{}), + CallAnswer: reflect.TypeOf(CallAnswerEventContent{}), + CallReject: reflect.TypeOf(CallRejectEventContent{}), + CallSelectAnswer: reflect.TypeOf(CallSelectAnswerEventContent{}), + CallNegotiate: reflect.TypeOf(CallNegotiateEventContent{}), + CallHangup: reflect.TypeOf(CallHangupEventContent{}), +} + +// Content stores the content of a Matrix event. +// +// By default, the raw JSON bytes are stored in VeryRaw and parsed into a map[string]interface{} in the Raw field. +// Additionally, you can call ParseRaw with the correct event type to parse the (VeryRaw) content into a nicer struct, +// which you can then access from Parsed or via the helper functions. +// +// When being marshaled into JSON, the data in Parsed will be marshaled first and then recursively merged +// with the data in Raw. Values in Raw are preferred, but nested objects will be recursed into before merging, +// rather than overriding the whole object with the one in Raw). +// If one of them is nil, then only the other is used. If both (Parsed and Raw) are nil, VeryRaw is used instead. +type Content struct { + VeryRaw json.RawMessage + Raw map[string]interface{} + Parsed interface{} +} + +type Relatable interface { + GetRelatesTo() *RelatesTo + OptionalGetRelatesTo() *RelatesTo + SetRelatesTo(rel *RelatesTo) +} + +func (content *Content) UnmarshalJSON(data []byte) error { + content.VeryRaw = bytes.Clone(data) + err := json.Unmarshal(data, &content.Raw) + return err +} + +func (content *Content) MarshalJSON() ([]byte, error) { + if content.Raw == nil { + if content.Parsed == nil { + if content.VeryRaw == nil { + return []byte("{}"), nil + } + return content.VeryRaw, nil + } + return json.Marshal(content.Parsed) + } else if content.Parsed != nil { + // TODO this whole thing is incredibly hacky + // It needs to produce JSON, where: + // * content.Parsed is applied after content.Raw + // * MarshalJSON() is respected inside content.Parsed + // * Custom field inside nested objects of content.Raw are preserved, + // even if content.Parsed contains the higher-level objects. + // * content.Raw is not modified + + unparsed, err := json.Marshal(content.Parsed) + if err != nil { + return nil, err + } + + var rawParsed map[string]interface{} + err = json.Unmarshal(unparsed, &rawParsed) + if err != nil { + return nil, err + } + + output := make(map[string]interface{}) + for key, value := range content.Raw { + output[key] = value + } + + mergeMaps(output, rawParsed) + return json.Marshal(output) + } + return json.Marshal(content.Raw) +} + +// Deprecated: use errors.Is directly +func IsUnsupportedContentType(err error) bool { + return errors.Is(err, ErrUnsupportedContentType) +} + +var ErrContentAlreadyParsed = errors.New("content is already parsed") +var ErrUnsupportedContentType = errors.New("unsupported event type") + +func (content *Content) GetRaw() map[string]interface{} { + if content.Raw == nil { + content.Raw = make(map[string]interface{}) + } + return content.Raw +} + +func (content *Content) ParseRaw(evtType Type) error { + if content.Parsed != nil { + return ErrContentAlreadyParsed + } + structType, ok := TypeMap[evtType] + if !ok { + return fmt.Errorf("%w %s", ErrUnsupportedContentType, evtType.Repr()) + } + content.Parsed = reflect.New(structType).Interface() + return json.Unmarshal(content.VeryRaw, &content.Parsed) +} + +func mergeMaps(into, from map[string]interface{}) { + for key, newValue := range from { + existingValue, ok := into[key] + if !ok { + into[key] = newValue + continue + } + existingValueMap, okEx := existingValue.(map[string]interface{}) + newValueMap, okNew := newValue.(map[string]interface{}) + if okEx && okNew { + mergeMaps(existingValueMap, newValueMap) + } else { + into[key] = newValue + } + } +} + +func CastOrDefault[T any](content *Content) *T { + casted, ok := content.Parsed.(*T) + if ok { + return casted + } + casted2, _ := content.Parsed.(T) + return &casted2 +} + +// Helper cast functions below + +func (content *Content) AsMember() *MemberEventContent { + casted, ok := content.Parsed.(*MemberEventContent) + if !ok { + return &MemberEventContent{} + } + return casted +} +func (content *Content) AsPowerLevels() *PowerLevelsEventContent { + casted, ok := content.Parsed.(*PowerLevelsEventContent) + if !ok { + return &PowerLevelsEventContent{} + } + return casted +} +func (content *Content) AsCanonicalAlias() *CanonicalAliasEventContent { + casted, ok := content.Parsed.(*CanonicalAliasEventContent) + if !ok { + return &CanonicalAliasEventContent{} + } + return casted +} +func (content *Content) AsRoomName() *RoomNameEventContent { + casted, ok := content.Parsed.(*RoomNameEventContent) + if !ok { + return &RoomNameEventContent{} + } + return casted +} +func (content *Content) AsRoomAvatar() *RoomAvatarEventContent { + casted, ok := content.Parsed.(*RoomAvatarEventContent) + if !ok { + return &RoomAvatarEventContent{} + } + return casted +} +func (content *Content) AsTopic() *TopicEventContent { + casted, ok := content.Parsed.(*TopicEventContent) + if !ok { + return &TopicEventContent{} + } + return casted +} +func (content *Content) AsTombstone() *TombstoneEventContent { + casted, ok := content.Parsed.(*TombstoneEventContent) + if !ok { + return &TombstoneEventContent{} + } + return casted +} +func (content *Content) AsCreate() *CreateEventContent { + casted, ok := content.Parsed.(*CreateEventContent) + if !ok { + return &CreateEventContent{} + } + return casted +} +func (content *Content) AsJoinRules() *JoinRulesEventContent { + casted, ok := content.Parsed.(*JoinRulesEventContent) + if !ok { + return &JoinRulesEventContent{} + } + return casted +} +func (content *Content) AsHistoryVisibility() *HistoryVisibilityEventContent { + casted, ok := content.Parsed.(*HistoryVisibilityEventContent) + if !ok { + return &HistoryVisibilityEventContent{} + } + return casted +} +func (content *Content) AsGuestAccess() *GuestAccessEventContent { + casted, ok := content.Parsed.(*GuestAccessEventContent) + if !ok { + return &GuestAccessEventContent{} + } + return casted +} +func (content *Content) AsPinnedEvents() *PinnedEventsEventContent { + casted, ok := content.Parsed.(*PinnedEventsEventContent) + if !ok { + return &PinnedEventsEventContent{} + } + return casted +} +func (content *Content) AsEncryption() *EncryptionEventContent { + casted, ok := content.Parsed.(*EncryptionEventContent) + if !ok { + return &EncryptionEventContent{} + } + return casted +} +func (content *Content) AsBridge() *BridgeEventContent { + casted, ok := content.Parsed.(*BridgeEventContent) + if !ok { + return &BridgeEventContent{} + } + return casted +} +func (content *Content) AsSpaceChild() *SpaceChildEventContent { + casted, ok := content.Parsed.(*SpaceChildEventContent) + if !ok { + return &SpaceChildEventContent{} + } + return casted +} +func (content *Content) AsSpaceParent() *SpaceParentEventContent { + casted, ok := content.Parsed.(*SpaceParentEventContent) + if !ok { + return &SpaceParentEventContent{} + } + return casted +} +func (content *Content) AsElementFunctionalMembers() *ElementFunctionalMembersContent { + casted, ok := content.Parsed.(*ElementFunctionalMembersContent) + if !ok { + return &ElementFunctionalMembersContent{} + } + return casted +} +func (content *Content) AsMessage() *MessageEventContent { + casted, ok := content.Parsed.(*MessageEventContent) + if !ok { + return &MessageEventContent{} + } + return casted +} +func (content *Content) AsEncrypted() *EncryptedEventContent { + casted, ok := content.Parsed.(*EncryptedEventContent) + if !ok { + return &EncryptedEventContent{} + } + return casted +} +func (content *Content) AsRedaction() *RedactionEventContent { + casted, ok := content.Parsed.(*RedactionEventContent) + if !ok { + return &RedactionEventContent{} + } + return casted +} +func (content *Content) AsReaction() *ReactionEventContent { + casted, ok := content.Parsed.(*ReactionEventContent) + if !ok { + return &ReactionEventContent{} + } + return casted +} +func (content *Content) AsTag() *TagEventContent { + casted, ok := content.Parsed.(*TagEventContent) + if !ok { + return &TagEventContent{} + } + return casted +} +func (content *Content) AsDirectChats() *DirectChatsEventContent { + casted, ok := content.Parsed.(*DirectChatsEventContent) + if !ok { + return &DirectChatsEventContent{} + } + return casted +} +func (content *Content) AsFullyRead() *FullyReadEventContent { + casted, ok := content.Parsed.(*FullyReadEventContent) + if !ok { + return &FullyReadEventContent{} + } + return casted +} +func (content *Content) AsIgnoredUserList() *IgnoredUserListEventContent { + casted, ok := content.Parsed.(*IgnoredUserListEventContent) + if !ok { + return &IgnoredUserListEventContent{} + } + return casted +} +func (content *Content) AsMarkedUnread() *MarkedUnreadEventContent { + casted, ok := content.Parsed.(*MarkedUnreadEventContent) + if !ok { + return &MarkedUnreadEventContent{} + } + return casted +} +func (content *Content) AsTyping() *TypingEventContent { + casted, ok := content.Parsed.(*TypingEventContent) + if !ok { + return &TypingEventContent{} + } + return casted +} +func (content *Content) AsReceipt() *ReceiptEventContent { + casted, ok := content.Parsed.(*ReceiptEventContent) + if !ok { + return &ReceiptEventContent{} + } + return casted +} +func (content *Content) AsPresence() *PresenceEventContent { + casted, ok := content.Parsed.(*PresenceEventContent) + if !ok { + return &PresenceEventContent{} + } + return casted +} +func (content *Content) AsRoomKey() *RoomKeyEventContent { + casted, ok := content.Parsed.(*RoomKeyEventContent) + if !ok { + return &RoomKeyEventContent{} + } + return casted +} +func (content *Content) AsForwardedRoomKey() *ForwardedRoomKeyEventContent { + casted, ok := content.Parsed.(*ForwardedRoomKeyEventContent) + if !ok { + return &ForwardedRoomKeyEventContent{} + } + return casted +} +func (content *Content) AsRoomKeyRequest() *RoomKeyRequestEventContent { + casted, ok := content.Parsed.(*RoomKeyRequestEventContent) + if !ok { + return &RoomKeyRequestEventContent{} + } + return casted +} +func (content *Content) AsRoomKeyWithheld() *RoomKeyWithheldEventContent { + casted, ok := content.Parsed.(*RoomKeyWithheldEventContent) + if !ok { + return &RoomKeyWithheldEventContent{} + } + return casted +} +func (content *Content) AsBeeperStreamSubscribe() *BeeperStreamSubscribeEventContent { + casted, ok := content.Parsed.(*BeeperStreamSubscribeEventContent) + if !ok { + return &BeeperStreamSubscribeEventContent{} + } + return casted +} + +func (content *Content) AsBeeperStreamUpdate() *BeeperStreamUpdateEventContent { + casted, ok := content.Parsed.(*BeeperStreamUpdateEventContent) + if !ok { + return &BeeperStreamUpdateEventContent{} + } + return casted +} + +func (content *Content) AsCallInvite() *CallInviteEventContent { + casted, ok := content.Parsed.(*CallInviteEventContent) + if !ok { + return &CallInviteEventContent{} + } + return casted +} +func (content *Content) AsCallCandidates() *CallCandidatesEventContent { + casted, ok := content.Parsed.(*CallCandidatesEventContent) + if !ok { + return &CallCandidatesEventContent{} + } + return casted +} +func (content *Content) AsCallAnswer() *CallAnswerEventContent { + casted, ok := content.Parsed.(*CallAnswerEventContent) + if !ok { + return &CallAnswerEventContent{} + } + return casted +} +func (content *Content) AsCallReject() *CallRejectEventContent { + casted, ok := content.Parsed.(*CallRejectEventContent) + if !ok { + return &CallRejectEventContent{} + } + return casted +} +func (content *Content) AsCallSelectAnswer() *CallSelectAnswerEventContent { + casted, ok := content.Parsed.(*CallSelectAnswerEventContent) + if !ok { + return &CallSelectAnswerEventContent{} + } + return casted +} +func (content *Content) AsCallNegotiate() *CallNegotiateEventContent { + casted, ok := content.Parsed.(*CallNegotiateEventContent) + if !ok { + return &CallNegotiateEventContent{} + } + return casted +} +func (content *Content) AsCallHangup() *CallHangupEventContent { + casted, ok := content.Parsed.(*CallHangupEventContent) + if !ok { + return &CallHangupEventContent{} + } + return casted +} +func (content *Content) AsModPolicy() *ModPolicyContent { + casted, ok := content.Parsed.(*ModPolicyContent) + if !ok { + return &ModPolicyContent{} + } + return casted +} +func (content *Content) AsVerificationRequest() *VerificationRequestEventContent { + casted, ok := content.Parsed.(*VerificationRequestEventContent) + if !ok { + return &VerificationRequestEventContent{} + } + return casted +} +func (content *Content) AsVerificationReady() *VerificationReadyEventContent { + casted, ok := content.Parsed.(*VerificationReadyEventContent) + if !ok { + return &VerificationReadyEventContent{} + } + return casted +} +func (content *Content) AsVerificationStart() *VerificationStartEventContent { + casted, ok := content.Parsed.(*VerificationStartEventContent) + if !ok { + return &VerificationStartEventContent{} + } + return casted +} +func (content *Content) AsVerificationDone() *VerificationDoneEventContent { + casted, ok := content.Parsed.(*VerificationDoneEventContent) + if !ok { + return &VerificationDoneEventContent{} + } + return casted +} +func (content *Content) AsVerificationCancel() *VerificationCancelEventContent { + casted, ok := content.Parsed.(*VerificationCancelEventContent) + if !ok { + return &VerificationCancelEventContent{} + } + return casted +} +func (content *Content) AsVerificationAccept() *VerificationAcceptEventContent { + casted, ok := content.Parsed.(*VerificationAcceptEventContent) + if !ok { + return &VerificationAcceptEventContent{} + } + return casted +} +func (content *Content) AsVerificationKey() *VerificationKeyEventContent { + casted, ok := content.Parsed.(*VerificationKeyEventContent) + if !ok { + return &VerificationKeyEventContent{} + } + return casted +} +func (content *Content) AsVerificationMAC() *VerificationMACEventContent { + casted, ok := content.Parsed.(*VerificationMACEventContent) + if !ok { + return &VerificationMACEventContent{} + } + return casted +} diff --git a/mautrix-patched/event/delayed.go b/mautrix-patched/event/delayed.go new file mode 100644 index 00000000..fefb62af --- /dev/null +++ b/mautrix-patched/event/delayed.go @@ -0,0 +1,70 @@ +package event + +import ( + "encoding/json" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/id" +) + +type ScheduledDelayedEvent struct { + DelayID id.DelayID `json:"delay_id"` + RoomID id.RoomID `json:"room_id"` + Type Type `json:"type"` + StateKey *string `json:"state_key,omitempty"` + Delay int64 `json:"delay"` + RunningSince jsontime.UnixMilli `json:"running_since"` + Content Content `json:"content"` +} + +func (e ScheduledDelayedEvent) AsEvent(eventID id.EventID, ts jsontime.UnixMilli) (*Event, error) { + evt := &Event{ + ID: eventID, + RoomID: e.RoomID, + Type: e.Type, + StateKey: e.StateKey, + Content: e.Content, + Timestamp: ts.UnixMilli(), + } + return evt, evt.Content.ParseRaw(evt.Type) +} + +type FinalisedDelayedEvent struct { + DelayedEvent *ScheduledDelayedEvent `json:"scheduled_event"` + Outcome DelayOutcome `json:"outcome"` + Reason DelayReason `json:"reason"` + Error json.RawMessage `json:"error,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` + Timestamp jsontime.UnixMilli `json:"origin_server_ts"` +} + +type DelayStatus string + +var ( + DelayStatusScheduled DelayStatus = "scheduled" + DelayStatusFinalised DelayStatus = "finalised" +) + +type DelayAction string + +var ( + DelayActionSend DelayAction = "send" + DelayActionCancel DelayAction = "cancel" + DelayActionRestart DelayAction = "restart" +) + +type DelayOutcome string + +var ( + DelayOutcomeSend DelayOutcome = "send" + DelayOutcomeCancel DelayOutcome = "cancel" +) + +type DelayReason string + +var ( + DelayReasonAction DelayReason = "action" + DelayReasonError DelayReason = "error" + DelayReasonDelay DelayReason = "delay" +) diff --git a/mautrix-patched/event/encryption.go b/mautrix-patched/event/encryption.go new file mode 100644 index 00000000..d3d41d1a --- /dev/null +++ b/mautrix-patched/event/encryption.go @@ -0,0 +1,211 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "fmt" + + "go.mau.fi/util/jsonbytes" + + "maunium.net/go/mautrix/id" +) + +// EncryptionEventContent represents the content of a m.room.encryption state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomencryption +type EncryptionEventContent struct { + // The encryption algorithm to be used to encrypt messages sent in this room. Must be 'm.megolm.v1.aes-sha2'. + Algorithm id.Algorithm `json:"algorithm"` + // How long the session should be used before changing it. 604800000 (a week) is the recommended default. + RotationPeriodMillis int64 `json:"rotation_period_ms,omitempty"` + // How many messages should be sent before changing the session. 100 is the recommended default. + RotationPeriodMessages int `json:"rotation_period_msgs,omitempty"` +} + +// EncryptedEventContent represents the content of a m.room.encrypted message event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomencrypted +// +// Note that sender_key and device_id are deprecated in Megolm events as of https://github.com/matrix-org/matrix-spec-proposals/pull/3700 +type EncryptedEventContent struct { + Algorithm id.Algorithm `json:"algorithm"` + SenderKey id.SenderKey `json:"sender_key,omitempty"` + // Deprecated: Matrix v1.3 + DeviceID id.DeviceID `json:"device_id,omitempty"` + // Only present for Megolm events + SessionID id.SessionID `json:"session_id,omitempty"` + // Only present for beeper stream events + StreamID string `json:"stream_id,omitempty"` + // Only present for beeper stream events + IV jsonbytes.UnpaddedBytes `json:"iv,omitempty"` + + Ciphertext json.RawMessage `json:"ciphertext"` + + MegolmCiphertext []byte `json:"-"` + // Only present for beeper stream events + BeeperStreamCiphertext jsonbytes.UnpaddedBytes `json:"-"` + OlmCiphertext OlmCiphertexts `json:"-"` + + RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` + Mentions *Mentions `json:"m.mentions,omitempty"` +} + +type OlmCiphertexts map[id.Curve25519]struct { + Body string `json:"body"` + Type id.OlmMsgType `json:"type"` +} + +type serializableEncryptedEventContent EncryptedEventContent + +func (content *EncryptedEventContent) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*serializableEncryptedEventContent)(content)) + if err != nil { + return err + } + switch content.Algorithm { + case id.AlgorithmOlmV1: + content.OlmCiphertext = make(OlmCiphertexts) + return json.Unmarshal(content.Ciphertext, &content.OlmCiphertext) + case id.AlgorithmMegolmV1: + if len(content.Ciphertext) == 0 || content.Ciphertext[0] != '"' || content.Ciphertext[len(content.Ciphertext)-1] != '"' { + return fmt.Errorf("ciphertext %w", id.ErrInputNotJSONString) + } + content.MegolmCiphertext = content.Ciphertext[1 : len(content.Ciphertext)-1] + case id.AlgorithmBeeperStreamV1: + return json.Unmarshal(content.Ciphertext, &content.BeeperStreamCiphertext) + } + return nil +} + +func (content *EncryptedEventContent) MarshalJSON() ([]byte, error) { + var err error + switch content.Algorithm { + case id.AlgorithmOlmV1: + content.Ciphertext, err = json.Marshal(content.OlmCiphertext) + case id.AlgorithmMegolmV1: + content.Ciphertext = make([]byte, len(content.MegolmCiphertext)+2) + content.Ciphertext[0] = '"' + content.Ciphertext[len(content.Ciphertext)-1] = '"' + copy(content.Ciphertext[1:len(content.Ciphertext)-1], content.MegolmCiphertext) + case id.AlgorithmBeeperStreamV1: + content.Ciphertext, err = json.Marshal(content.BeeperStreamCiphertext) + } + if err != nil { + return nil, err + } + return json.Marshal((*serializableEncryptedEventContent)(content)) +} + +// RoomKeyEventContent represents the content of a m.room_key to_device event. +// https://spec.matrix.org/v1.2/client-server-api/#mroom_key +type RoomKeyEventContent struct { + Algorithm id.Algorithm `json:"algorithm"` + RoomID id.RoomID `json:"room_id"` + SessionID id.SessionID `json:"session_id"` + SessionKey string `json:"session_key"` + + MaxAge int64 `json:"com.beeper.max_age_ms,omitempty"` + MaxMessages int `json:"com.beeper.max_messages,omitempty"` + IsScheduled bool `json:"com.beeper.is_scheduled,omitempty"` +} + +// ForwardedRoomKeyEventContent represents the content of a m.forwarded_room_key to_device event. +// https://spec.matrix.org/v1.2/client-server-api/#mforwarded_room_key +type ForwardedRoomKeyEventContent struct { + RoomKeyEventContent + SenderKey id.SenderKey `json:"sender_key"` + SenderClaimedKey id.Ed25519 `json:"sender_claimed_ed25519_key"` + ForwardingKeyChain []string `json:"forwarding_curve25519_key_chain"` +} + +type KeyRequestAction string + +const ( + KeyRequestActionRequest = "request" + KeyRequestActionCancel = "request_cancellation" +) + +// RoomKeyRequestEventContent represents the content of a m.room_key_request to_device event. +// https://spec.matrix.org/v1.2/client-server-api/#mroom_key_request +type RoomKeyRequestEventContent struct { + Body RequestedKeyInfo `json:"body"` + Action KeyRequestAction `json:"action"` + RequestingDeviceID id.DeviceID `json:"requesting_device_id"` + RequestID string `json:"request_id"` +} + +type RequestedKeyInfo struct { + Algorithm id.Algorithm `json:"algorithm"` + RoomID id.RoomID `json:"room_id"` + SessionID id.SessionID `json:"session_id"` + // Deprecated: Matrix v1.3 + SenderKey id.SenderKey `json:"sender_key"` +} + +type RoomKeyWithheldCode string + +const ( + RoomKeyWithheldBlacklisted RoomKeyWithheldCode = "m.blacklisted" + RoomKeyWithheldUnverified RoomKeyWithheldCode = "m.unverified" + RoomKeyWithheldUnauthorized RoomKeyWithheldCode = "m.unauthorised" + RoomKeyWithheldUnavailable RoomKeyWithheldCode = "m.unavailable" + RoomKeyWithheldNoOlmSession RoomKeyWithheldCode = "m.no_olm" + + RoomKeyWithheldBeeperRedacted RoomKeyWithheldCode = "com.beeper.redacted" +) + +type RoomKeyWithheldEventContent struct { + RoomID id.RoomID `json:"room_id,omitempty"` + Algorithm id.Algorithm `json:"algorithm"` + SessionID id.SessionID `json:"session_id,omitempty"` + SenderKey id.SenderKey `json:"sender_key"` + Code RoomKeyWithheldCode `json:"code"` + Reason string `json:"reason,omitempty"` +} + +const groupSessionWithheldMsg = "group session has been withheld: %s" + +func (withheld *RoomKeyWithheldEventContent) Error() string { + switch withheld.Code { + case RoomKeyWithheldBlacklisted, RoomKeyWithheldUnverified, RoomKeyWithheldUnauthorized, RoomKeyWithheldUnavailable, RoomKeyWithheldNoOlmSession: + return fmt.Sprintf(groupSessionWithheldMsg, withheld.Code) + default: + return fmt.Sprintf(groupSessionWithheldMsg+" (%s)", withheld.Code, withheld.Reason) + } +} + +func (withheld *RoomKeyWithheldEventContent) Is(other error) bool { + otherWithheld, ok := other.(*RoomKeyWithheldEventContent) + if !ok { + return false + } + return withheld.Code == "" || otherWithheld.Code == "" || withheld.Code == otherWithheld.Code +} + +type SecretRequestAction string + +func (a SecretRequestAction) String() string { + return string(a) +} + +const ( + SecretRequestRequest = "request" + SecretRequestCancellation = "request_cancellation" +) + +type SecretRequestEventContent struct { + Name id.Secret `json:"name,omitempty"` + Action SecretRequestAction `json:"action"` + RequestingDeviceID id.DeviceID `json:"requesting_device_id"` + RequestID string `json:"request_id"` +} + +type SecretSendEventContent struct { + RequestID string `json:"request_id"` + Secret string `json:"secret"` +} + +type DummyEventContent struct{} diff --git a/mautrix-patched/event/ephemeral.go b/mautrix-patched/event/ephemeral.go new file mode 100644 index 00000000..f447404b --- /dev/null +++ b/mautrix-patched/event/ephemeral.go @@ -0,0 +1,140 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "time" + + "maunium.net/go/mautrix/id" +) + +// TypingEventContent represents the content of a m.typing ephemeral event. +// https://spec.matrix.org/v1.2/client-server-api/#mtyping +type TypingEventContent struct { + UserIDs []id.UserID `json:"user_ids"` +} + +// ReceiptEventContent represents the content of a m.receipt ephemeral event. +// https://spec.matrix.org/v1.2/client-server-api/#mreceipt +type ReceiptEventContent map[id.EventID]Receipts + +func (rec ReceiptEventContent) Set(evtID id.EventID, receiptType ReceiptType, userID id.UserID, receipt ReadReceipt) { + rec.GetOrCreate(evtID).GetOrCreate(receiptType).Set(userID, receipt) +} + +func (rec ReceiptEventContent) GetOrCreate(evt id.EventID) Receipts { + receipts, ok := rec[evt] + if !ok { + receipts = make(Receipts) + rec[evt] = receipts + } + return receipts +} + +type ReceiptType string + +const ( + ReceiptTypeRead ReceiptType = "m.read" + ReceiptTypeReadPrivate ReceiptType = "m.read.private" +) + +type Receipts map[ReceiptType]UserReceipts + +func (rps Receipts) GetOrCreate(receiptType ReceiptType) UserReceipts { + read, ok := rps[receiptType] + if !ok { + read = make(UserReceipts) + rps[receiptType] = read + } + return read +} + +type UserReceipts map[id.UserID]ReadReceipt + +func (ur UserReceipts) Set(userID id.UserID, receipt ReadReceipt) { + ur[userID] = receipt +} + +type ThreadID = id.EventID + +const ReadReceiptThreadMain ThreadID = "main" + +type ReadReceipt struct { + Timestamp time.Time + + // Thread ID for thread-specific read receipts from MSC3771 + ThreadID ThreadID + + // Extra contains any unknown fields in the read receipt event. + // Most servers don't allow clients to set them, so this will be empty in most cases. + Extra map[string]interface{} +} + +func (rr *ReadReceipt) UnmarshalJSON(data []byte) error { + // Hacky compatibility hack against crappy clients that send double-encoded read receipts. + // TODO is this actually needed? clients can't currently set custom content in receipts 🤔 + if data[0] == '"' && data[len(data)-1] == '"' { + var strData string + err := json.Unmarshal(data, &strData) + if err != nil { + return err + } + data = []byte(strData) + } + + var parsed map[string]interface{} + err := json.Unmarshal(data, &parsed) + if err != nil { + return err + } + threadID, _ := parsed["thread_id"].(string) + ts, tsOK := parsed["ts"].(float64) + delete(parsed, "thread_id") + delete(parsed, "ts") + *rr = ReadReceipt{ + ThreadID: ThreadID(threadID), + Extra: parsed, + } + if tsOK { + rr.Timestamp = time.UnixMilli(int64(ts)) + } + return nil +} + +func (rr ReadReceipt) MarshalJSON() ([]byte, error) { + data := rr.Extra + if data == nil { + data = make(map[string]interface{}) + } + if rr.ThreadID != "" { + data["thread_id"] = rr.ThreadID + } + if !rr.Timestamp.IsZero() { + data["ts"] = rr.Timestamp.UnixMilli() + } + return json.Marshal(data) +} + +type Presence string + +const ( + PresenceOnline Presence = "online" + PresenceOffline Presence = "offline" + PresenceUnavailable Presence = "unavailable" +) + +// PresenceEventContent represents the content of a m.presence ephemeral event. +// https://spec.matrix.org/v1.2/client-server-api/#mpresence +type PresenceEventContent struct { + Presence Presence `json:"presence"` + Displayname string `json:"displayname,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + LastActiveAgo int64 `json:"last_active_ago,omitempty"` + CurrentlyActive bool `json:"currently_active,omitempty"` + StatusMessage string `json:"status_msg,omitempty"` +} diff --git a/mautrix-patched/event/events.go b/mautrix-patched/event/events.go new file mode 100644 index 00000000..a5915059 --- /dev/null +++ b/mautrix-patched/event/events.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "time" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/id" +) + +// Event represents a single Matrix event. +type Event struct { + StateKey *string `json:"state_key,omitempty"` // The state key for the event. Only present on State Events. + Sender id.UserID `json:"sender,omitempty"` // The user ID of the sender of the event + Type Type `json:"type"` // The event type + Timestamp int64 `json:"origin_server_ts,omitempty"` // The unix timestamp when this message was sent by the origin server + ID id.EventID `json:"event_id,omitempty"` // The unique ID of this event + RoomID id.RoomID `json:"room_id,omitempty"` // The room the event was sent to. May be nil (e.g. for presence) + Content Content `json:"content"` // The JSON content of the event. + Redacts id.EventID `json:"redacts,omitempty"` // The event ID that was redacted if a m.room.redaction event + Unsigned Unsigned `json:"unsigned,omitempty"` // Unsigned content set by own homeserver. + + Sticky *Sticky `json:"msc4354_sticky,omitempty"` + + Mautrix MautrixInfo `json:"-"` + + ToUserID id.UserID `json:"to_user_id,omitempty"` // The user ID that the to-device event was sent to. Only present in MSC2409 appservice transactions. + ToDeviceID id.DeviceID `json:"to_device_id,omitempty"` // The device ID that the to-device event was sent to. Only present in MSC2409 appservice transactions. +} + +type eventForMarshaling struct { + StateKey *string `json:"state_key,omitempty"` + Sender id.UserID `json:"sender,omitempty"` + Type Type `json:"type"` + Timestamp int64 `json:"origin_server_ts,omitempty"` + ID id.EventID `json:"event_id,omitempty"` + RoomID id.RoomID `json:"room_id,omitempty"` + Content Content `json:"content"` + Redacts id.EventID `json:"redacts,omitempty"` + Unsigned *Unsigned `json:"unsigned,omitempty"` + + Sticky *Sticky `json:"msc4354_sticky,omitempty"` + + PrevContent *Content `json:"prev_content,omitempty"` + ReplacesState *id.EventID `json:"replaces_state,omitempty"` + + ToUserID id.UserID `json:"to_user_id,omitempty"` + ToDeviceID id.DeviceID `json:"to_device_id,omitempty"` +} + +// UnmarshalJSON unmarshals the event, including moving prev_content from the top level to inside unsigned. +func (evt *Event) UnmarshalJSON(data []byte) error { + var efm eventForMarshaling + err := json.Unmarshal(data, &efm) + if err != nil { + return err + } + evt.StateKey = efm.StateKey + evt.Sender = efm.Sender + evt.Type = efm.Type + evt.Timestamp = efm.Timestamp + evt.ID = efm.ID + evt.RoomID = efm.RoomID + evt.Content = efm.Content + evt.Redacts = efm.Redacts + evt.Sticky = efm.Sticky + if efm.Unsigned != nil { + evt.Unsigned = *efm.Unsigned + } + if efm.PrevContent != nil && evt.Unsigned.PrevContent == nil { + evt.Unsigned.PrevContent = efm.PrevContent + } + if efm.ReplacesState != nil && *efm.ReplacesState != "" && evt.Unsigned.ReplacesState == "" { + evt.Unsigned.ReplacesState = *efm.ReplacesState + } + evt.ToUserID = efm.ToUserID + evt.ToDeviceID = efm.ToDeviceID + return nil +} + +// MarshalJSON marshals the event, including omitting the unsigned field if it's empty. +// +// This is necessary because Unsigned is not a pointer (for convenience reasons), +// and encoding/json doesn't know how to check if a non-pointer struct is empty. +// +// TODO(tulir): maybe it makes more sense to make Unsigned a pointer and make an easy and safe way to access it? +func (evt *Event) MarshalJSON() ([]byte, error) { + unsigned := &evt.Unsigned + if unsigned.IsEmpty() { + unsigned = nil + } + return json.Marshal(&eventForMarshaling{ + StateKey: evt.StateKey, + Sender: evt.Sender, + Type: evt.Type, + Timestamp: evt.Timestamp, + ID: evt.ID, + RoomID: evt.RoomID, + Content: evt.Content, + Redacts: evt.Redacts, + Unsigned: unsigned, + Sticky: evt.Sticky, + ToUserID: evt.ToUserID, + ToDeviceID: evt.ToDeviceID, + }) +} + +type Sticky struct { + Duration jsontime.Milliseconds `json:"duration_ms"` +} + +const MaxStickyDuration = 1 * time.Hour + +func (sticky *Sticky) GetDuration() time.Duration { + if sticky != nil { + return max(0, min(sticky.Duration.Duration, MaxStickyDuration)) + } + return 0 +} + +func (evt *Event) GetStickyUntil() time.Time { + if evt == nil || evt.Sticky.GetDuration() == 0 { + return time.Time{} + } + return time.UnixMilli(evt.Timestamp).Add(evt.Sticky.GetDuration()) +} + +type MautrixInfo struct { + EventSource Source + + TrustState id.TrustState + ForwardedKeys bool + WasEncrypted bool + TrustSource *id.Device + + ReceivedAt time.Time + EditedAt time.Time + LastEditID id.EventID + DecryptionDuration time.Duration + + CheckpointSent bool + // When using MSC4222 and the state_after field, this field is set + // for timeline events to indicate they shouldn't update room state. + IgnoreState bool +} + +func (evt *Event) GetStateKey() string { + if evt.StateKey != nil { + return *evt.StateKey + } + return "" +} + +type Unsigned struct { + PrevContent *Content `json:"prev_content,omitempty"` + PrevSender id.UserID `json:"prev_sender,omitempty"` + Membership Membership `json:"membership,omitempty"` + ReplacesState id.EventID `json:"replaces_state,omitempty"` + Age int64 `json:"age,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + Relations *Relations `json:"m.relations,omitempty"` + RedactedBecause *Event `json:"redacted_because,omitempty"` + InviteRoomState []*Event `json:"invite_room_state,omitempty"` + + StickyDurationTTL jsontime.Milliseconds `json:"msc4354_sticky_duration_ttl_ms,omitzero"` + + BeeperHSOrder int64 `json:"com.beeper.hs.order,omitempty"` + BeeperHSSuborder int16 `json:"com.beeper.hs.suborder,omitempty"` + BeeperHSOrderString *BeeperEncodedOrder `json:"com.beeper.hs.order_string,omitempty"` + BeeperFromBackup bool `json:"com.beeper.from_backup,omitempty"` + + ElementSoftFailed bool `json:"io.element.synapse.soft_failed,omitempty"` + ElementPolicyServerSpammy bool `json:"io.element.synapse.policy_server_spammy,omitempty"` +} + +func (us *Unsigned) IsEmpty() bool { + return us.PrevContent == nil && us.PrevSender == "" && us.ReplacesState == "" && us.Age == 0 && us.Membership == "" && + us.TransactionID == "" && us.RedactedBecause == nil && us.InviteRoomState == nil && us.Relations == nil && + us.BeeperHSOrder == 0 && us.BeeperHSSuborder == 0 && us.BeeperHSOrderString.IsZero() && + !us.ElementSoftFailed && us.StickyDurationTTL.Duration == 0 +} diff --git a/mautrix-patched/event/eventsource.go b/mautrix-patched/event/eventsource.go new file mode 100644 index 00000000..44bbb68e --- /dev/null +++ b/mautrix-patched/event/eventsource.go @@ -0,0 +1,73 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "fmt" +) + +// Source represents the part of the sync response that an event came from. +type Source int + +const ( + SourcePresence Source = 1 << iota + SourceJoin + SourceInvite + SourceLeave + SourceAccountData + SourceTimeline + SourceSticky + SourceState + SourceEphemeral + SourceToDevice + SourceDecrypted +) + +const primaryTypes = SourcePresence | SourceAccountData | SourceToDevice | SourceTimeline | SourceState +const roomSections = SourceJoin | SourceInvite | SourceLeave +const roomableTypes = SourceAccountData | SourceTimeline | SourceState +const encryptableTypes = roomableTypes | SourceToDevice + +func (es Source) String() string { + var typeName string + switch es & primaryTypes { + case SourcePresence: + typeName = "presence" + case SourceAccountData: + typeName = "account data" + case SourceToDevice: + typeName = "to-device" + case SourceTimeline: + typeName = "timeline" + case SourceState: + typeName = "state" + default: + return fmt.Sprintf("unknown (%d)", es) + } + if es&roomableTypes != 0 { + switch es & roomSections { + case SourceJoin: + typeName = "joined room " + typeName + case SourceInvite: + typeName = "invited room " + typeName + case SourceLeave: + typeName = "left room " + typeName + default: + return fmt.Sprintf("unknown (%s+%d)", typeName, es) + } + es &^= roomSections + } + if es&encryptableTypes != 0 && es&SourceDecrypted != 0 { + typeName += " (decrypted)" + es &^= SourceDecrypted + } + es &^= primaryTypes + if es != 0 { + return fmt.Sprintf("unknown (%s+%d)", typeName, es) + } + return typeName +} diff --git a/mautrix-patched/event/imagepack.go b/mautrix-patched/event/imagepack.go new file mode 100644 index 00000000..a8fb583b --- /dev/null +++ b/mautrix-patched/event/imagepack.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "maunium.net/go/mautrix/id" +) + +type ImagePackImage struct { + URL id.ContentURIString `json:"url"` + Body string `json:"body,omitempty"` + Info *FileInfo `json:"info,omitempty"` +} + +type ImagePackUsage string + +const ( + ImagePackUsageEmoji ImagePackUsage = "emoticon" + ImagePackUsageSticker ImagePackUsage = "sticker" +) + +type ImagePackMetadata struct { + DisplayName string `json:"display_name,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + Usage []ImagePackUsage `json:"usage,omitempty"` + Attribution string `json:"attribution,omitempty"` + + BridgedPack *BridgedStickerPack `json:"fi.mau.bridged_pack,omitempty"` +} + +func (ipm ImagePackMetadata) IsZero() bool { + return ipm.DisplayName == "" && ipm.AvatarURL == "" && len(ipm.Usage) == 0 && ipm.Attribution == "" && ipm.BridgedPack == nil +} + +type ImagePackEventContent struct { + Images map[string]*ImagePackImage `json:"images"` + Metadata ImagePackMetadata `json:"pack,omitzero"` +} + +type ImagePackRoomsEventContent struct { + Rooms map[id.RoomID]map[string]struct{} `json:"rooms"` +} + +type ImageSource struct { + RoomID id.RoomID `json:"room_id"` + Via []string `json:"via,omitempty"` + StateKey string `json:"state_key"` + Shortcode string `json:"shortcode"` +} + +type BridgedStickerPack struct { + Network string `json:"network"` + URL string `json:"url"` +} + +type BridgedSticker struct { + Network string `json:"network"` + ID string `json:"id"` + Emoji string `json:"emoji,omitempty"` + PackURL string `json:"pack_url"` +} diff --git a/mautrix-patched/event/member.go b/mautrix-patched/event/member.go new file mode 100644 index 00000000..9956a36b --- /dev/null +++ b/mautrix-patched/event/member.go @@ -0,0 +1,69 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "maunium.net/go/mautrix/id" +) + +// Membership is an enum specifying the membership state of a room member. +type Membership string + +func (ms Membership) IsInviteOrJoin() bool { + return ms == MembershipJoin || ms == MembershipInvite +} + +func (ms Membership) IsLeaveOrBan() bool { + return ms == MembershipLeave || ms == MembershipBan +} + +// The allowed membership states as specified in spec section 10.5.5. +const ( + MembershipJoin Membership = "join" + MembershipLeave Membership = "leave" + MembershipInvite Membership = "invite" + MembershipBan Membership = "ban" + MembershipKnock Membership = "knock" +) + +// MemberEventContent represents the content of a m.room.member state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroommember +type MemberEventContent struct { + Membership Membership `json:"membership"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + Displayname string `json:"displayname,omitempty"` + IsDirect bool `json:"is_direct,omitempty"` + ThirdPartyInvite *ThirdPartyInvite `json:"third_party_invite,omitempty"` + Reason string `json:"reason,omitempty"` + JoinAuthorisedViaUsersServer id.UserID `json:"join_authorised_via_users_server,omitempty"` + MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` + + MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` +} + +type SignedThirdPartyInvite struct { + Token string `json:"token"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` + MXID string `json:"mxid"` +} + +type ThirdPartyInvite struct { + DisplayName string `json:"display_name"` + Signed SignedThirdPartyInvite `json:"signed"` +} + +type ThirdPartyInviteEventContent struct { + DisplayName string `json:"display_name"` + KeyValidityURL string `json:"key_validity_url"` + PublicKey id.Ed25519 `json:"public_key"` + PublicKeys []ThirdPartyInviteKey `json:"public_keys,omitempty"` +} + +type ThirdPartyInviteKey struct { + KeyValidityURL string `json:"key_validity_url,omitempty"` + PublicKey id.Ed25519 `json:"public_key"` +} diff --git a/mautrix-patched/event/message.go b/mautrix-patched/event/message.go new file mode 100644 index 00000000..1d4e1d62 --- /dev/null +++ b/mautrix-patched/event/message.go @@ -0,0 +1,501 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "fmt" + "html" + "slices" + "strconv" + "strings" + + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/id" +) + +// MessageType is the sub-type of a m.room.message event. +// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes +type MessageType string + +func (mt MessageType) IsText() bool { + switch mt { + case MsgText, MsgNotice, MsgEmote: + return true + default: + return false + } +} + +func (mt MessageType) IsMedia() bool { + switch mt { + case MsgImage, MsgVideo, MsgAudio, MsgFile, CapMsgSticker: + return true + default: + return false + } +} + +// Msgtypes +const ( + MsgText MessageType = "m.text" + MsgEmote MessageType = "m.emote" + MsgNotice MessageType = "m.notice" + MsgImage MessageType = "m.image" + MsgLocation MessageType = "m.location" + MsgVideo MessageType = "m.video" + MsgAudio MessageType = "m.audio" + MsgFile MessageType = "m.file" + + MsgVerificationRequest MessageType = "m.key.verification.request" + + MsgBeeperGallery MessageType = "com.beeper.gallery" +) + +// Format specifies the format of the formatted_body in m.room.message events. +// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes +type Format string + +// Message formats +const ( + FormatHTML Format = "org.matrix.custom.html" +) + +// RedactionEventContent represents the content of a m.room.redaction message event. +// +// https://spec.matrix.org/v1.8/client-server-api/#mroomredaction +type RedactionEventContent struct { + Reason string `json:"reason,omitempty"` + + // The event ID is here as of room v11. In old servers it may only be at the top level. + Redacts id.EventID `json:"redacts,omitempty"` + + DontRenderPlaceholder bool `json:"com.beeper.dont_render_redacted_placeholder,omitempty"` +} + +// ReactionEventContent represents the content of a m.reaction message event. +// This is not yet in a spec release, see https://github.com/matrix-org/matrix-doc/pull/1849 +type ReactionEventContent struct { + RelatesTo RelatesTo `json:"m.relates_to"` +} + +func (content *ReactionEventContent) GetRelatesTo() *RelatesTo { + return &content.RelatesTo +} + +func (content *ReactionEventContent) OptionalGetRelatesTo() *RelatesTo { + return &content.RelatesTo +} + +func (content *ReactionEventContent) SetRelatesTo(rel *RelatesTo) { + content.RelatesTo = *rel +} + +// MessageEventContent represents the content of a m.room.message event. +// +// It is also used to represent m.sticker events, as they are equivalent to m.room.message +// with the exception of the msgtype field. +// +// https://spec.matrix.org/v1.2/client-server-api/#mroommessage +type MessageEventContent struct { + // Base m.room.message fields + MsgType MessageType `json:"msgtype,omitempty"` + Body string `json:"body"` + + // Extra fields for text types + Format Format `json:"format,omitempty"` + FormattedBody string `json:"formatted_body,omitempty"` + + // Extra field for m.location + GeoURI string `json:"geo_uri,omitempty"` + + // Extra fields for media types + URL id.ContentURIString `json:"url,omitempty"` + Info *FileInfo `json:"info,omitempty"` + File *EncryptedFileInfo `json:"file,omitempty"` + + FileName string `json:"filename,omitempty"` + + Mentions *Mentions `json:"m.mentions,omitempty"` + + // Edits and relations + NewContent *MessageEventContent `json:"m.new_content,omitempty"` + RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` + + // In-room verification + To id.UserID `json:"to,omitempty"` + FromDevice id.DeviceID `json:"from_device,omitempty"` + Methods []VerificationMethod `json:"methods,omitempty"` + + replyFallbackRemoved bool + + ImageSourcePacks map[id.ContentURIString]*ImageSource `json:"com.beeper.msc4459.image_source_packs,omitempty"` + BridgedEmojis map[id.ContentURIString]*BridgedSticker `json:"com.beeper.bridged_emojis,omitempty"` + + MessageSendRetry *BeeperRetryMetadata `json:"com.beeper.message_send_retry,omitempty"` + BeeperGalleryImages []*MessageEventContent `json:"com.beeper.gallery.images,omitempty"` + BeeperGalleryCaption string `json:"com.beeper.gallery.caption,omitempty"` + BeeperGalleryCaptionHTML string `json:"com.beeper.gallery.caption_html,omitempty"` + BeeperPerMessageProfile *BeeperPerMessageProfile `json:"com.beeper.per_message_profile,omitempty"` + BeeperActionMessage *BeeperActionMessage `json:"com.beeper.action_message,omitempty"` + BeeperLinkPreviews []*BeeperLinkPreview `json:"com.beeper.linkpreviews,omitempty"` + BeeperStream *BeeperStreamInfo `json:"com.beeper.stream,omitempty"` + BeeperDisappearingTimer *BeeperDisappearingTimer `json:"com.beeper.disappearing_timer,omitempty"` + + MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"` + MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"` + + MSC4391BotCommand *MSC4391BotCommandInput `json:"org.matrix.msc4391.command,omitempty"` +} + +func (content *MessageEventContent) GetCapMsgType() CapabilityMsgType { + switch content.MsgType { + case CapMsgSticker: + return CapMsgSticker + case "": + if content.URL != "" || content.File != nil { + return CapMsgSticker + } + case MsgImage: + return MsgImage + case MsgAudio: + if content.MSC3245Voice != nil { + return CapMsgVoice + } + return MsgAudio + case MsgVideo: + if content.Info != nil && content.Info.MauGIF { + return CapMsgGIF + } + return MsgVideo + case MsgFile: + return MsgFile + } + return "" +} + +func (content *MessageEventContent) GetFileName() string { + if content.FileName != "" { + return content.FileName + } + return content.Body +} + +func (content *MessageEventContent) GetCaption() string { + if content.FileName != "" && content.Body != "" && content.Body != content.FileName { + return content.Body + } + return "" +} + +func (content *MessageEventContent) GetFormattedCaption() string { + if content.Format == FormatHTML && content.FormattedBody != "" { + return content.FormattedBody + } + return "" +} + +func (content *MessageEventContent) GetRelatesTo() *RelatesTo { + if content.RelatesTo == nil { + content.RelatesTo = &RelatesTo{} + } + return content.RelatesTo +} + +func (content *MessageEventContent) OptionalGetRelatesTo() *RelatesTo { + return content.RelatesTo +} + +func (content *MessageEventContent) SetRelatesTo(rel *RelatesTo) { + content.RelatesTo = rel +} + +func (content *MessageEventContent) SetEdit(original id.EventID) { + newContent := *content + content.NewContent = &newContent + content.RelatesTo = (&RelatesTo{}).SetReplace(original) + if content.MsgType == MsgText || content.MsgType == MsgNotice { + content.Body = "* " + content.Body + content.Mentions = &Mentions{} + if content.Format == FormatHTML && len(content.FormattedBody) > 0 { + content.FormattedBody = "* " + content.FormattedBody + } + // If the message is long, remove most of the useless edit fallback to avoid event size issues. + if len(content.Body) > 10000 { + content.FormattedBody = "" + content.Format = "" + content.Body = content.Body[:50] + "[edit fallback cut…]" + } + } +} + +// TextToHTML converts the given text to a HTML-safe representation by escaping HTML characters +// and replacing newlines with
      tags. +func TextToHTML(text string) string { + return strings.ReplaceAll(html.EscapeString(text), "\n", "
      ") +} + +// ReverseTextToHTML reverses the modifications made by TextToHTML, i.e. replaces
      tags with newlines +// and unescapes HTML escape codes. For actually parsing HTML, use the format package instead. +func ReverseTextToHTML(input string) string { + return html.UnescapeString(strings.ReplaceAll(input, "
      ", "\n")) +} + +func (content *MessageEventContent) EnsureHasHTML() { + if content.MsgType.IsMedia() && (content.FileName == "" || content.FileName == content.Body) { + content.FileName = content.Body + content.Body = "" + } + if len(content.FormattedBody) == 0 || content.Format != FormatHTML { + content.FormattedBody = TextToHTML(content.Body) + content.Format = FormatHTML + } +} + +func (content *MessageEventContent) GetFile() *EncryptedFileInfo { + if content.File == nil { + content.File = &EncryptedFileInfo{} + } + return content.File +} + +func (content *MessageEventContent) GetInfo() *FileInfo { + if content.Info == nil { + content.Info = &FileInfo{} + } + return content.Info +} + +type Mentions struct { + UserIDs []id.UserID `json:"user_ids,omitempty"` + Room bool `json:"room,omitempty"` +} + +func (m *Mentions) Add(userID id.UserID) { + if userID != "" && !slices.Contains(m.UserIDs, userID) { + m.UserIDs = append(m.UserIDs, userID) + } +} + +func (m *Mentions) Has(userID id.UserID) bool { + return m != nil && slices.Contains(m.UserIDs, userID) +} + +func (m *Mentions) Merge(other *Mentions) *Mentions { + if m == nil { + return other + } else if other == nil { + return m + } + return &Mentions{ + UserIDs: slices.Concat(m.UserIDs, other.UserIDs), + Room: m.Room || other.Room, + } +} + +type MSC4391BotCommandInputCustom[T any] struct { + Command string `json:"command"` + Arguments T `json:"arguments,omitempty"` +} + +type MSC4391BotCommandInput = MSC4391BotCommandInputCustom[json.RawMessage] + +type EncryptedFileInfo struct { + attachment.EncryptedFile + URL id.ContentURIString `json:"url"` +} + +type FileInfo struct { + MimeType string + ThumbnailInfo *FileInfo + ThumbnailURL id.ContentURIString + ThumbnailFile *EncryptedFileInfo + + Blurhash string + AnoaBlurhash string + + MauGIF bool + IsAnimated bool + + Width int + Height int + Duration int + Size int + + BridgedSticker *BridgedSticker + + Extra map[string]any +} + +type serializableFileInfo struct { + MimeType string `json:"mimetype,omitempty"` + ThumbnailInfo *serializableFileInfo `json:"thumbnail_info,omitempty"` + ThumbnailURL id.ContentURIString `json:"thumbnail_url,omitempty"` + ThumbnailFile *EncryptedFileInfo `json:"thumbnail_file,omitempty"` + + Blurhash string `json:"blurhash,omitempty"` + AnoaBlurhash string `json:"xyz.amorgan.blurhash,omitempty"` + + MauGIF bool `json:"fi.mau.gif,omitempty"` + IsAnimated bool `json:"is_animated,omitempty"` + + Width json.Number `json:"w,omitempty"` + Height json.Number `json:"h,omitempty"` + Duration json.Number `json:"duration,omitempty"` + Size json.Number `json:"size,omitempty"` + + BridgedSticker *BridgedSticker `json:"fi.mau.bridged_sticker,omitempty"` +} + +func (fileInfo *FileInfo) IsZero() bool { + return fileInfo.MimeType == "" && + fileInfo.ThumbnailInfo == nil && + fileInfo.ThumbnailURL == "" && + fileInfo.ThumbnailFile == nil && + fileInfo.Blurhash == "" && + fileInfo.AnoaBlurhash == "" && + !fileInfo.MauGIF && + !fileInfo.IsAnimated && + fileInfo.Width == 0 && + fileInfo.Height == 0 && + fileInfo.Duration == 0 && + fileInfo.Size == 0 && + fileInfo.BridgedSticker == nil && + len(fileInfo.Extra) == 0 +} + +func (sfi *serializableFileInfo) CopyFrom(fileInfo *FileInfo) *serializableFileInfo { + if fileInfo == nil { + return nil + } + *sfi = serializableFileInfo{ + MimeType: fileInfo.MimeType, + ThumbnailURL: fileInfo.ThumbnailURL, + ThumbnailInfo: (&serializableFileInfo{}).CopyFrom(fileInfo.ThumbnailInfo), + ThumbnailFile: fileInfo.ThumbnailFile, + + MauGIF: fileInfo.MauGIF, + IsAnimated: fileInfo.IsAnimated, + + Blurhash: fileInfo.Blurhash, + AnoaBlurhash: fileInfo.AnoaBlurhash, + + BridgedSticker: fileInfo.BridgedSticker, + } + if fileInfo.Width > 0 { + sfi.Width = json.Number(strconv.Itoa(fileInfo.Width)) + } + if fileInfo.Height > 0 { + sfi.Height = json.Number(strconv.Itoa(fileInfo.Height)) + } + if fileInfo.Size > 0 { + sfi.Size = json.Number(strconv.Itoa(fileInfo.Size)) + + } + if fileInfo.Duration > 0 { + sfi.Duration = json.Number(strconv.Itoa(int(fileInfo.Duration))) + } + return sfi +} + +func (sfi *serializableFileInfo) CopyTo(fileInfo *FileInfo) { + *fileInfo = FileInfo{ + Width: numberToInt(sfi.Width), + Height: numberToInt(sfi.Height), + Size: numberToInt(sfi.Size), + Duration: numberToInt(sfi.Duration), + MimeType: sfi.MimeType, + ThumbnailURL: sfi.ThumbnailURL, + ThumbnailFile: sfi.ThumbnailFile, + MauGIF: sfi.MauGIF, + IsAnimated: sfi.IsAnimated, + Blurhash: sfi.Blurhash, + AnoaBlurhash: sfi.AnoaBlurhash, + BridgedSticker: sfi.BridgedSticker, + } + if sfi.ThumbnailInfo != nil { + fileInfo.ThumbnailInfo = &FileInfo{} + sfi.ThumbnailInfo.CopyTo(fileInfo.ThumbnailInfo) + } +} + +func (fileInfo *FileInfo) deleteStandardExtraFields() { + if len(fileInfo.Extra) == 0 { + return + } + delete(fileInfo.Extra, "mimetype") + delete(fileInfo.Extra, "thumbnail_info") + delete(fileInfo.Extra, "thumbnail_url") + delete(fileInfo.Extra, "thumbnail_file") + delete(fileInfo.Extra, "blurhash") + delete(fileInfo.Extra, "xyz.amorgan.blurhash") + delete(fileInfo.Extra, "fi.mau.gif") + delete(fileInfo.Extra, "is_animated") + delete(fileInfo.Extra, "w") + delete(fileInfo.Extra, "h") + delete(fileInfo.Extra, "duration") + delete(fileInfo.Extra, "size") + delete(fileInfo.Extra, "fi.mau.bridged_sticker") +} + +func (fileInfo *FileInfo) UnmarshalJSON(data []byte) error { + sfi := &serializableFileInfo{} + if err := json.Unmarshal(data, sfi); err != nil { + return err + } + sfi.CopyTo(fileInfo) + if err := json.Unmarshal(data, &fileInfo.Extra); err != nil { + return err + } + fileInfo.deleteStandardExtraFields() + return nil +} + +func MarshalMerge(base any, extraMap map[string]any) (json.RawMessage, error) { + // TODO replace all uses of this method with jsonv2's extra field once jsonv2 is available + baseSerialized, err := json.Marshal(base) + if len(extraMap) == 0 || err != nil { + return baseSerialized, err + } + extraSerialized, err := json.Marshal(extraMap) + if err != nil { + return nil, err + } + if len(baseSerialized) <= 4 || baseSerialized[0] != '{' { + return extraSerialized, nil + } else if len(extraSerialized) <= 4 || extraSerialized[0] != '{' { + return baseSerialized, nil + } + output := make([]byte, 0, len(baseSerialized)+len(extraSerialized)-1) + output = append(output, baseSerialized[:len(baseSerialized)-1]...) + output = append(output, ',') + output = append(output, extraSerialized[1:]...) + if !json.Valid(output) { + return nil, fmt.Errorf("failed to merge extra: %s", output) + } + return output, nil +} + +func (fileInfo *FileInfo) MarshalJSON() ([]byte, error) { + fileInfo.deleteStandardExtraFields() + return MarshalMerge((&serializableFileInfo{}).CopyFrom(fileInfo), fileInfo.Extra) +} + +func numberToInt(val json.Number) int { + f64, _ := val.Float64() + if f64 > 0 { + return int(f64) + } + return 0 +} + +func (fileInfo *FileInfo) GetThumbnailInfo() *FileInfo { + if fileInfo.ThumbnailInfo == nil { + fileInfo.ThumbnailInfo = &FileInfo{} + } + return fileInfo.ThumbnailInfo +} diff --git a/mautrix-patched/event/message_test.go b/mautrix-patched/event/message_test.go new file mode 100644 index 00000000..7ef116c9 --- /dev/null +++ b/mautrix-patched/event/message_test.go @@ -0,0 +1,187 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const invalidMessageEvent = `{ + "sender": "@tulir:maunium.net", + "type": "m.room.message", + "origin_server_ts": 1587252684192, + "event_id": "$foo", + "room_id": "!bar", + "content": { + "body": { + "hmm": false + } + } +}` + +func TestMessageEventContent__ParseInvalid(t *testing.T) { + var evt *event.Event + err := json.Unmarshal([]byte(invalidMessageEvent), &evt) + assert.NoError(t, err) + + assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) + assert.Equal(t, event.EventMessage, evt.Type) + assert.Equal(t, int64(1587252684192), evt.Timestamp) + assert.Equal(t, id.EventID("$foo"), evt.ID) + assert.Equal(t, id.RoomID("!bar"), evt.RoomID) + + err = evt.Content.ParseRaw(evt.Type) + assert.Error(t, err) +} + +const messageEvent = `{ + "sender": "@tulir:maunium.net", + "type": "m.room.message", + "origin_server_ts": 1587252684192, + "event_id": "$foo", + "room_id": "!bar", + "content": { + "msgtype": "m.text", + "body": "* **Hello**, World!", + "format": "org.matrix.custom.html", + "formatted_body": "* Hello, World!", + "m.new_content": { + "msgtype": "m.text", + "body": "**Hello**, World!", + "format": "org.matrix.custom.html", + "formatted_body": "Hello, World!" + } + } +}` + +func TestMessageEventContent__ParseEdit(t *testing.T) { + var evt *event.Event + err := json.Unmarshal([]byte(messageEvent), &evt) + assert.NoError(t, err) + + assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) + assert.Equal(t, event.EventMessage, evt.Type) + assert.Equal(t, int64(1587252684192), evt.Timestamp) + assert.Equal(t, id.EventID("$foo"), evt.ID) + assert.Equal(t, id.RoomID("!bar"), evt.RoomID) + + err = evt.Content.ParseRaw(evt.Type) + require.NoError(t, err) + + assert.IsType(t, &event.MessageEventContent{}, evt.Content.Parsed) + content := evt.Content.Parsed.(*event.MessageEventContent) + assert.Equal(t, event.MsgText, content.MsgType) + assert.Equal(t, event.MsgText, content.NewContent.MsgType) + assert.Equal(t, "**Hello**, World!", content.NewContent.Body) + assert.Equal(t, "Hello, World!", content.NewContent.FormattedBody) +} + +const imageMessageEvent = `{ + "sender": "@tulir:maunium.net", + "type": "m.room.message", + "origin_server_ts": 1587252684192, + "event_id": "$foo", + "room_id": "!bar", + "content": { + "msgtype": "m.image", + "body": "image.png", + "url": "mxc://example.com/image", + "info": { + "mimetype": "image/png", + "w": 64, + "h": 64, + "size": 12345, + "thumbnail_url": "mxc://example.com/image_thumb", + "custom_field": "meow" + } + } +}` + +func TestMessageEventContent__ParseMedia(t *testing.T) { + var evt *event.Event + err := json.Unmarshal([]byte(imageMessageEvent), &evt) + assert.NoError(t, err) + + assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) + assert.Equal(t, event.EventMessage, evt.Type) + assert.Equal(t, int64(1587252684192), evt.Timestamp) + assert.Equal(t, id.EventID("$foo"), evt.ID) + assert.Equal(t, id.RoomID("!bar"), evt.RoomID) + + err = evt.Content.ParseRaw(evt.Type) + require.NoError(t, err) + + assert.IsType(t, &event.MessageEventContent{}, evt.Content.Parsed) + content := evt.Content.Parsed.(*event.MessageEventContent) + assert.Equal(t, event.MsgImage, content.MsgType) + parsedURL, err := content.URL.Parse() + assert.NoError(t, err) + assert.Equal(t, id.ContentURI{Homeserver: "example.com", FileID: "image"}, parsedURL) + assert.Nil(t, content.NewContent) + assert.Equal(t, "image/png", content.GetInfo().MimeType) + assert.Equal(t, 64, content.GetInfo().Width) + assert.Equal(t, 64, content.GetInfo().Height) + assert.Equal(t, 12345, content.GetInfo().Size) + assert.Equal(t, map[string]any{"custom_field": "meow"}, content.GetInfo().Extra) + + content.GetInfo().Extra["cat"] = 5 + content.GetInfo().Extra["w"] = 1234 + marshaledExtra, err := json.Marshal(content.GetInfo()) + assert.NoError(t, err) + var unmarshaledMap map[string]any + err = json.Unmarshal(marshaledExtra, &unmarshaledMap) + assert.NoError(t, err) + assert.Equal(t, map[string]any{ + "mimetype": "image/png", + "w": 64.0, + "h": 64.0, + "size": 12345.0, + "thumbnail_url": "mxc://example.com/image_thumb", + "custom_field": "meow", + "cat": 5.0, + }, unmarshaledMap) +} + +var parsedMessage = &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "test", + }, +} + +const expectedMarshalResult = `{"msgtype":"m.text","body":"test"}` + +func TestMessageEventContent__Marshal(t *testing.T) { + data, err := json.Marshal(parsedMessage) + assert.NoError(t, err) + assert.Equal(t, expectedMarshalResult, string(data)) +} + +var customParsedMessage = &event.Content{ + Raw: map[string]interface{}{ + "net.maunium.custom": "hello world", + }, + Parsed: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "test", + }, +} + +const expectedCustomMarshalResult = `{"body":"test","msgtype":"m.text","net.maunium.custom":"hello world"}` + +func TestMessageEventContent__Marshal_Custom(t *testing.T) { + data, err := json.Marshal(customParsedMessage) + assert.NoError(t, err) + assert.Equal(t, expectedCustomMarshalResult, string(data)) +} diff --git a/mautrix-patched/event/poll.go b/mautrix-patched/event/poll.go new file mode 100644 index 00000000..9082f65e --- /dev/null +++ b/mautrix-patched/event/poll.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +type PollResponseEventContent struct { + RelatesTo RelatesTo `json:"m.relates_to"` + Response struct { + Answers []string `json:"answers"` + } `json:"org.matrix.msc3381.poll.response"` +} + +func (content *PollResponseEventContent) GetRelatesTo() *RelatesTo { + return &content.RelatesTo +} + +func (content *PollResponseEventContent) OptionalGetRelatesTo() *RelatesTo { + if content.RelatesTo.Type == "" { + return nil + } + return &content.RelatesTo +} + +func (content *PollResponseEventContent) SetRelatesTo(rel *RelatesTo) { + content.RelatesTo = *rel +} + +type MSC1767Message struct { + Text string `json:"org.matrix.msc1767.text,omitempty"` + HTML string `json:"org.matrix.msc1767.html,omitempty"` + Message []ExtensibleText `json:"org.matrix.msc1767.message,omitempty"` +} + +type PollStartEventContent struct { + RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` + Mentions *Mentions `json:"m.mentions,omitempty"` + PollStart struct { + Kind string `json:"kind"` + MaxSelections int `json:"max_selections"` + Question MSC1767Message `json:"question"` + Answers []struct { + ID string `json:"id"` + MSC1767Message + } `json:"answers"` + } `json:"org.matrix.msc3381.poll.start"` +} + +func (content *PollStartEventContent) GetRelatesTo() *RelatesTo { + if content.RelatesTo == nil { + content.RelatesTo = &RelatesTo{} + } + return content.RelatesTo +} + +func (content *PollStartEventContent) OptionalGetRelatesTo() *RelatesTo { + return content.RelatesTo +} + +func (content *PollStartEventContent) SetRelatesTo(rel *RelatesTo) { + content.RelatesTo = rel +} diff --git a/mautrix-patched/event/powerlevels.go b/mautrix-patched/event/powerlevels.go new file mode 100644 index 00000000..708721f9 --- /dev/null +++ b/mautrix-patched/event/powerlevels.go @@ -0,0 +1,235 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "math" + "slices" + "sync" + + "go.mau.fi/util/ptr" + "golang.org/x/exp/maps" + + "maunium.net/go/mautrix/id" +) + +// PowerLevelsEventContent represents the content of a m.room.power_levels state event content. +// https://spec.matrix.org/v1.5/client-server-api/#mroompower_levels +type PowerLevelsEventContent struct { + usersLock sync.RWMutex + Users map[id.UserID]int `json:"users,omitempty"` + UsersDefault int `json:"users_default,omitempty"` + + eventsLock sync.RWMutex + Events map[string]int `json:"events,omitempty"` + EventsDefault int `json:"events_default,omitempty"` + + Notifications *NotificationPowerLevels `json:"notifications,omitempty"` + + StateDefaultPtr *int `json:"state_default,omitempty"` + + InvitePtr *int `json:"invite,omitempty"` + KickPtr *int `json:"kick,omitempty"` + BanPtr *int `json:"ban,omitempty"` + RedactPtr *int `json:"redact,omitempty"` + + // This is not a part of power levels, it's added by mautrix-go internally in certain places + // in order to detect creator power accurately. + CreateEvent *Event `json:"-"` +} + +func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { + if pl == nil { + return nil + } + return &PowerLevelsEventContent{ + Users: maps.Clone(pl.Users), + UsersDefault: pl.UsersDefault, + Events: maps.Clone(pl.Events), + EventsDefault: pl.EventsDefault, + StateDefaultPtr: ptr.Clone(pl.StateDefaultPtr), + + Notifications: pl.Notifications.Clone(), + + InvitePtr: ptr.Clone(pl.InvitePtr), + KickPtr: ptr.Clone(pl.KickPtr), + BanPtr: ptr.Clone(pl.BanPtr), + RedactPtr: ptr.Clone(pl.RedactPtr), + + CreateEvent: pl.CreateEvent, + } +} + +type NotificationPowerLevels struct { + RoomPtr *int `json:"room,omitempty"` +} + +func (npl *NotificationPowerLevels) Clone() *NotificationPowerLevels { + if npl == nil { + return nil + } + return &NotificationPowerLevels{ + RoomPtr: ptr.Clone(npl.RoomPtr), + } +} + +func (npl *NotificationPowerLevels) Room() int { + if npl != nil && npl.RoomPtr != nil { + return *npl.RoomPtr + } + return 50 +} + +func (pl *PowerLevelsEventContent) Invite() int { + if pl.InvitePtr != nil { + return *pl.InvitePtr + } + return 0 +} + +func (pl *PowerLevelsEventContent) Kick() int { + if pl.KickPtr != nil { + return *pl.KickPtr + } + return 50 +} + +func (pl *PowerLevelsEventContent) Ban() int { + if pl.BanPtr != nil { + return *pl.BanPtr + } + return 50 +} + +func (pl *PowerLevelsEventContent) Redact() int { + if pl.RedactPtr != nil { + return *pl.RedactPtr + } + return 50 +} + +func (pl *PowerLevelsEventContent) StateDefault() int { + if pl.StateDefaultPtr != nil { + return *pl.StateDefaultPtr + } + return 50 +} + +func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int { + if pl.isCreator(userID) { + return math.MaxInt + } + pl.usersLock.RLock() + defer pl.usersLock.RUnlock() + level, ok := pl.Users[userID] + if !ok { + return pl.UsersDefault + } + return level +} + +const maxPL = 1<<53 - 1 + +func (pl *PowerLevelsEventContent) SetUserLevel(userID id.UserID, level int) { + pl.usersLock.Lock() + defer pl.usersLock.Unlock() + if pl.isCreator(userID) { + return + } + if level == math.MaxInt && maxPL < math.MaxInt { + // Hack to avoid breaking on 32-bit systems (they're only slightly supported) + x := int64(maxPL) + level = int(x) + } + if level == pl.UsersDefault { + delete(pl.Users, userID) + } else { + if pl.Users == nil { + pl.Users = make(map[id.UserID]int) + } + pl.Users[userID] = level + } +} + +func (pl *PowerLevelsEventContent) EnsureUserLevel(target id.UserID, level int) bool { + return pl.EnsureUserLevelAs("", target, level) +} + +func (pl *PowerLevelsEventContent) createContent() *CreateEventContent { + if pl.CreateEvent == nil { + return &CreateEventContent{} + } + return pl.CreateEvent.Content.AsCreate() +} + +func (pl *PowerLevelsEventContent) isCreator(userID id.UserID) bool { + cc := pl.createContent() + return cc.SupportsCreatorPower() && (userID == pl.CreateEvent.Sender || slices.Contains(cc.AdditionalCreators, userID)) +} + +func (pl *PowerLevelsEventContent) EnsureUserLevelAs(actor, target id.UserID, level int) bool { + if pl.isCreator(target) { + return false + } + existingLevel := pl.GetUserLevel(target) + if actor != "" && !pl.isCreator(actor) { + actorLevel := pl.GetUserLevel(actor) + if actorLevel <= existingLevel || actorLevel < level { + return false + } + } + if existingLevel != level { + pl.SetUserLevel(target, level) + return true + } + return false +} + +func (pl *PowerLevelsEventContent) GetEventLevel(eventType Type) int { + pl.eventsLock.RLock() + defer pl.eventsLock.RUnlock() + level, ok := pl.Events[eventType.String()] + if !ok { + if eventType.IsState() { + return pl.StateDefault() + } + return pl.EventsDefault + } + return level +} + +func (pl *PowerLevelsEventContent) SetEventLevel(eventType Type, level int) { + pl.eventsLock.Lock() + defer pl.eventsLock.Unlock() + if (eventType.IsState() && level == pl.StateDefault()) || (!eventType.IsState() && level == pl.EventsDefault) { + delete(pl.Events, eventType.String()) + } else { + if pl.Events == nil { + pl.Events = make(map[string]int) + } + pl.Events[eventType.String()] = level + } +} + +func (pl *PowerLevelsEventContent) EnsureEventLevel(eventType Type, level int) bool { + return pl.EnsureEventLevelAs("", eventType, level) +} + +func (pl *PowerLevelsEventContent) EnsureEventLevelAs(actor id.UserID, eventType Type, level int) bool { + existingLevel := pl.GetEventLevel(eventType) + if actor != "" && !pl.isCreator(actor) { + actorLevel := pl.GetUserLevel(actor) + if existingLevel > actorLevel || level > actorLevel { + return false + } + } + if existingLevel != level { + pl.SetEventLevel(eventType, level) + return true + } + return false +} diff --git a/mautrix-patched/event/relations.go b/mautrix-patched/event/relations.go new file mode 100644 index 00000000..38232b43 --- /dev/null +++ b/mautrix-patched/event/relations.go @@ -0,0 +1,240 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + + "maunium.net/go/mautrix/id" +) + +type RelationType string + +const ( + RelReplace RelationType = "m.replace" + RelReference RelationType = "m.reference" + RelAnnotation RelationType = "m.annotation" + RelThread RelationType = "m.thread" + RelBeeperTranscription RelationType = "com.beeper.transcription" +) + +type RelatesTo struct { + Type RelationType `json:"rel_type,omitempty"` + EventID id.EventID `json:"event_id,omitempty"` + Key string `json:"key,omitempty"` + + InReplyTo *InReplyTo `json:"m.in_reply_to,omitempty"` + IsFallingBack bool `json:"is_falling_back,omitempty"` +} + +type InReplyTo struct { + EventID id.EventID `json:"event_id,omitempty"` + + UnstableRoomID id.RoomID `json:"com.beeper.cross_room_id,omitempty"` + BeeperQuote json.RawMessage `json:"com.beeper.quote,omitempty"` +} + +func (rel *RelatesTo) Copy() *RelatesTo { + if rel == nil { + return nil + } + cp := *rel + return &cp +} + +func (rel *RelatesTo) GetReplaceID() id.EventID { + if rel != nil && rel.Type == RelReplace { + return rel.EventID + } + return "" +} + +func (rel *RelatesTo) GetReferenceID() id.EventID { + if rel != nil && rel.Type == RelReference { + return rel.EventID + } + return "" +} + +func (rel *RelatesTo) GetThreadParent() id.EventID { + if rel != nil && rel.Type == RelThread { + return rel.EventID + } + return "" +} + +func (rel *RelatesTo) GetReplyTo() id.EventID { + if rel != nil && rel.InReplyTo != nil { + return rel.InReplyTo.EventID + } + return "" +} + +func (rel *RelatesTo) GetNonFallbackReplyTo() id.EventID { + if rel != nil && rel.InReplyTo != nil && (rel.Type != RelThread || !rel.IsFallingBack) { + return rel.InReplyTo.EventID + } + return "" +} + +func (rel *RelatesTo) GetAnnotationID() id.EventID { + if rel != nil && rel.Type == RelAnnotation { + return rel.EventID + } + return "" +} + +func (rel *RelatesTo) GetAnnotationKey() string { + if rel != nil && rel.Type == RelAnnotation { + return rel.Key + } + return "" +} + +func (rel *RelatesTo) SetReplace(mxid id.EventID) *RelatesTo { + rel.Type = RelReplace + rel.EventID = mxid + return rel +} + +func (rel *RelatesTo) SetReplyTo(mxid id.EventID) *RelatesTo { + if rel.Type != RelThread { + rel.Type = "" + rel.EventID = "" + } + rel.InReplyTo = &InReplyTo{EventID: mxid} + rel.IsFallingBack = false + return rel +} + +func (rel *RelatesTo) SetThread(mxid, fallback id.EventID) *RelatesTo { + rel.Type = RelThread + rel.EventID = mxid + if fallback != "" && rel.GetReplyTo() == "" { + rel.SetReplyTo(fallback) + rel.IsFallingBack = true + } + return rel +} + +func (rel *RelatesTo) SetAnnotation(mxid id.EventID, key string) *RelatesTo { + rel.Type = RelAnnotation + rel.EventID = mxid + rel.Key = key + return rel +} + +type RelationChunkItem struct { + Type RelationType `json:"type"` + EventID string `json:"event_id,omitempty"` + Key string `json:"key,omitempty"` + Count int `json:"count,omitempty"` +} + +type RelationChunk struct { + Chunk []RelationChunkItem `json:"chunk"` + + Limited bool `json:"limited"` + Count int `json:"count"` +} + +type AnnotationChunk struct { + RelationChunk + Map map[string]int `json:"-"` +} + +type serializableAnnotationChunk AnnotationChunk + +func (ac *AnnotationChunk) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, (*serializableAnnotationChunk)(ac)); err != nil { + return err + } + ac.Map = make(map[string]int) + for _, item := range ac.Chunk { + if item.Key != "" { + ac.Map[item.Key] += item.Count + } + } + return nil +} + +func (ac *AnnotationChunk) Serialize() RelationChunk { + ac.Chunk = make([]RelationChunkItem, len(ac.Map)) + i := 0 + for key, count := range ac.Map { + ac.Chunk[i] = RelationChunkItem{ + Type: RelAnnotation, + Key: key, + Count: count, + } + i++ + } + return ac.RelationChunk +} + +type EventIDChunk struct { + RelationChunk + List []string `json:"-"` +} + +type serializableEventIDChunk EventIDChunk + +func (ec *EventIDChunk) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, (*serializableEventIDChunk)(ec)); err != nil { + return err + } + for _, item := range ec.Chunk { + ec.List = append(ec.List, item.EventID) + } + return nil +} + +func (ec *EventIDChunk) Serialize(typ RelationType) RelationChunk { + ec.Chunk = make([]RelationChunkItem, len(ec.List)) + for i, eventID := range ec.List { + ec.Chunk[i] = RelationChunkItem{ + Type: typ, + EventID: eventID, + } + } + return ec.RelationChunk +} + +type Relations struct { + Raw map[RelationType]RelationChunk `json:"-"` + + Annotations AnnotationChunk `json:"m.annotation,omitempty"` + References EventIDChunk `json:"m.reference,omitempty"` + Replaces EventIDChunk `json:"m.replace,omitempty"` +} + +type serializableRelations Relations + +func (relations *Relations) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &relations.Raw); err != nil { + return err + } + return json.Unmarshal(data, (*serializableRelations)(relations)) +} + +func (relations *Relations) MarshalJSON() ([]byte, error) { + if relations.Raw == nil { + relations.Raw = make(map[RelationType]RelationChunk) + } + relations.Raw[RelAnnotation] = relations.Annotations.Serialize() + relations.Raw[RelReference] = relations.References.Serialize(RelReference) + relations.Raw[RelReplace] = relations.Replaces.Serialize(RelReplace) + for key, item := range relations.Raw { + if !item.Limited { + item.Count = len(item.Chunk) + } + if item.Count == 0 { + delete(relations.Raw, key) + } + } + return json.Marshal(relations.Raw) +} diff --git a/mautrix-patched/event/reply.go b/mautrix-patched/event/reply.go new file mode 100644 index 00000000..5f55bb80 --- /dev/null +++ b/mautrix-patched/event/reply.go @@ -0,0 +1,74 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "regexp" + "strings" + + "maunium.net/go/mautrix/id" +) + +var HTMLReplyFallbackRegex = regexp.MustCompile(`^[\s\S]+?`) + +func TrimReplyFallbackHTML(html string) string { + return HTMLReplyFallbackRegex.ReplaceAllString(html, "") +} + +func TrimReplyFallbackText(text string) string { + if (!strings.HasPrefix(text, "> <") && !strings.HasPrefix(text, "> * <")) || !strings.Contains(text, "\n") { + return text + } + + lines := strings.Split(text, "\n") + for len(lines) > 0 && strings.HasPrefix(lines[0], "> ") { + lines = lines[1:] + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func (content *MessageEventContent) RemoveReplyFallback() { + if len(content.RelatesTo.GetReplyTo()) > 0 && !content.replyFallbackRemoved && content.Format == FormatHTML { + origHTML := content.FormattedBody + content.FormattedBody = TrimReplyFallbackHTML(content.FormattedBody) + if content.FormattedBody != origHTML { + content.Body = TrimReplyFallbackText(content.Body) + content.replyFallbackRemoved = true + } + } +} + +// Deprecated: RelatesTo methods are nil-safe, so RelatesTo.GetReplyTo can be used directly +func (content *MessageEventContent) GetReplyTo() id.EventID { + return content.RelatesTo.GetReplyTo() +} + +func (content *MessageEventContent) SetReply(inReplyTo *Event) { + if content.RelatesTo == nil { + content.RelatesTo = &RelatesTo{} + } + content.RelatesTo.SetReplyTo(inReplyTo.ID) + if content.Mentions == nil { + content.Mentions = &Mentions{} + } + content.Mentions.Add(inReplyTo.Sender) +} + +func (content *MessageEventContent) SetThread(inReplyTo *Event) { + root := inReplyTo.ID + relatable, ok := inReplyTo.Content.Parsed.(Relatable) + if ok { + targetRoot := relatable.OptionalGetRelatesTo().GetThreadParent() + if targetRoot != "" { + root = targetRoot + } + } + if content.RelatesTo == nil { + content.RelatesTo = &RelatesTo{} + } + content.RelatesTo.SetThread(root, inReplyTo.ID) +} diff --git a/mautrix-patched/event/state.go b/mautrix-patched/event/state.go new file mode 100644 index 00000000..ace170a5 --- /dev/null +++ b/mautrix-patched/event/state.go @@ -0,0 +1,357 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/base64" + "encoding/json" + "slices" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/id" +) + +// CanonicalAliasEventContent represents the content of a m.room.canonical_alias state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomcanonical_alias +type CanonicalAliasEventContent struct { + Alias id.RoomAlias `json:"alias"` + AltAliases []id.RoomAlias `json:"alt_aliases,omitempty"` +} + +// RoomNameEventContent represents the content of a m.room.name state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomname +type RoomNameEventContent struct { + Name string `json:"name"` +} + +// RoomAvatarEventContent represents the content of a m.room.avatar state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomavatar +type RoomAvatarEventContent struct { + URL id.ContentURIString `json:"url,omitempty"` + Info *FileInfo `json:"info,omitempty"` + MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` +} + +// ServerACLEventContent represents the content of a m.room.server_acl state event. +// https://spec.matrix.org/v1.2/client-server-api/#server-access-control-lists-acls-for-rooms +type ServerACLEventContent struct { + Allow []string `json:"allow,omitempty"` + AllowIPLiterals bool `json:"allow_ip_literals"` + Deny []string `json:"deny,omitempty"` +} + +// TopicEventContent represents the content of a m.room.topic state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomtopic +type TopicEventContent struct { + Topic string `json:"topic"` + ExtensibleTopic *ExtensibleTopic `json:"m.topic,omitempty"` +} + +// ExtensibleTopic represents the contents of the m.topic field within the +// m.room.topic state event as described in [MSC3765]. +// +// [MSC3765]: https://github.com/matrix-org/matrix-spec-proposals/pull/3765 +type ExtensibleTopic = ExtensibleTextContainer + +type ExtensibleTextContainer struct { + Text []ExtensibleText `json:"m.text"` +} + +func (c *ExtensibleTextContainer) Equals(description *ExtensibleTextContainer) bool { + if c == nil || description == nil { + return c == description + } + return slices.Equal(c.Text, description.Text) +} + +func MakeExtensibleText(text string) *ExtensibleTextContainer { + return &ExtensibleTextContainer{ + Text: []ExtensibleText{{ + Body: text, + MimeType: "text/plain", + }}, + } +} + +func MakeExtensibleFormattedText(plaintext, html string) *ExtensibleTextContainer { + return &ExtensibleTextContainer{ + Text: []ExtensibleText{{ + Body: plaintext, + MimeType: "text/plain", + }, { + Body: html, + MimeType: "text/html", + }}, + } +} + +// ExtensibleText represents the contents of an m.text field. +type ExtensibleText struct { + MimeType string `json:"mimetype,omitempty"` + Body string `json:"body"` +} + +// TombstoneEventContent represents the content of a m.room.tombstone state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomtombstone +type TombstoneEventContent struct { + Body string `json:"body"` + ReplacementRoom id.RoomID `json:"replacement_room"` +} + +func (tec *TombstoneEventContent) GetReplacementRoom() id.RoomID { + if tec == nil { + return "" + } + return tec.ReplacementRoom +} + +type Predecessor struct { + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` +} + +// Deprecated: use id.RoomVersion instead +type RoomVersion = id.RoomVersion + +// Deprecated: use id.RoomVX constants instead +const ( + RoomV1 = id.RoomV1 + RoomV2 = id.RoomV2 + RoomV3 = id.RoomV3 + RoomV4 = id.RoomV4 + RoomV5 = id.RoomV5 + RoomV6 = id.RoomV6 + RoomV7 = id.RoomV7 + RoomV8 = id.RoomV8 + RoomV9 = id.RoomV9 + RoomV10 = id.RoomV10 + RoomV11 = id.RoomV11 + RoomV12 = id.RoomV12 +) + +// CreateEventContent represents the content of a m.room.create state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomcreate +type CreateEventContent struct { + Type RoomType `json:"type,omitempty"` + Federate *bool `json:"m.federate,omitempty"` + RoomVersion id.RoomVersion `json:"room_version,omitempty"` + Predecessor *Predecessor `json:"predecessor,omitempty"` + + // Room v12+ only + AdditionalCreators []id.UserID `json:"additional_creators,omitempty"` + + // Deprecated: use the event sender instead + Creator id.UserID `json:"creator,omitempty"` +} + +func (cec *CreateEventContent) GetPredecessor() (p Predecessor) { + if cec != nil && cec.Predecessor != nil { + p = *cec.Predecessor + } + return +} + +func (cec *CreateEventContent) SupportsCreatorPower() bool { + if cec == nil { + return false + } + return cec.RoomVersion.PrivilegedRoomCreators() +} + +// JoinRule specifies how open a room is to new members. +// https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules +type JoinRule string + +const ( + JoinRulePublic JoinRule = "public" + JoinRuleKnock JoinRule = "knock" + JoinRuleInvite JoinRule = "invite" + JoinRuleRestricted JoinRule = "restricted" + JoinRuleKnockRestricted JoinRule = "knock_restricted" + JoinRulePrivate JoinRule = "private" +) + +// JoinRulesEventContent represents the content of a m.room.join_rules state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules +type JoinRulesEventContent struct { + JoinRule JoinRule `json:"join_rule"` + Allow []JoinRuleAllow `json:"allow,omitempty"` +} + +type JoinRuleAllowType string + +const ( + JoinRuleAllowRoomMembership JoinRuleAllowType = "m.room_membership" +) + +type JoinRuleAllow struct { + RoomID id.RoomID `json:"room_id"` + Type JoinRuleAllowType `json:"type"` +} + +// PinnedEventsEventContent represents the content of a m.room.pinned_events state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroompinned_events +type PinnedEventsEventContent struct { + Pinned []id.EventID `json:"pinned"` +} + +// HistoryVisibility specifies who can see new messages. +// https://spec.matrix.org/v1.2/client-server-api/#mroomhistory_visibility +type HistoryVisibility string + +const ( + HistoryVisibilityInvited HistoryVisibility = "invited" + HistoryVisibilityJoined HistoryVisibility = "joined" + HistoryVisibilityShared HistoryVisibility = "shared" + HistoryVisibilityWorldReadable HistoryVisibility = "world_readable" +) + +// HistoryVisibilityEventContent represents the content of a m.room.history_visibility state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomhistory_visibility +type HistoryVisibilityEventContent struct { + HistoryVisibility HistoryVisibility `json:"history_visibility"` +} + +// GuestAccess specifies whether or not guest accounts can join. +// https://spec.matrix.org/v1.2/client-server-api/#mroomguest_access +type GuestAccess string + +const ( + GuestAccessCanJoin GuestAccess = "can_join" + GuestAccessForbidden GuestAccess = "forbidden" +) + +// GuestAccessEventContent represents the content of a m.room.guest_access state event. +// https://spec.matrix.org/v1.2/client-server-api/#mroomguest_access +type GuestAccessEventContent struct { + GuestAccess GuestAccess `json:"guest_access"` +} + +type BridgeInfoSection struct { + ID string `json:"id"` + DisplayName string `json:"displayname,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + ExternalURL string `json:"external_url,omitempty"` + + Receiver string `json:"fi.mau.receiver,omitempty"` + MessageRequest bool `json:"com.beeper.message_request,omitempty"` +} + +// BridgeEventContent represents the content of a m.bridge state event. +// https://github.com/matrix-org/matrix-doc/pull/2346 +type BridgeEventContent struct { + BridgeBot id.UserID `json:"bridgebot"` + Creator id.UserID `json:"creator,omitempty"` + Protocol BridgeInfoSection `json:"protocol"` + Network *BridgeInfoSection `json:"network,omitempty"` + Channel BridgeInfoSection `json:"channel"` + + BeeperRoomType string `json:"com.beeper.room_type,omitempty"` + BeeperRoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"` + + TempSlackRemoteIDMigratedFlag bool `json:"com.beeper.slack_remote_id_migrated,omitempty"` + TempSlackRemoteIDMigratedFlag2 bool `json:"com.beeper.slack_remote_id_really_migrated,omitempty"` +} + +// DisappearingType represents the type of a disappearing message timer. +type DisappearingType string + +const ( + DisappearingTypeNone DisappearingType = "" + DisappearingTypeAfterRead DisappearingType = "after_read" + DisappearingTypeAfterSend DisappearingType = "after_send" +) + +type BeeperDisappearingTimer struct { + Type DisappearingType `json:"type"` + Timer jsontime.Milliseconds `json:"timer"` +} + +type marshalableBeeperDisappearingTimer BeeperDisappearingTimer + +func (bdt *BeeperDisappearingTimer) MarshalJSON() ([]byte, error) { + if bdt == nil || bdt.Type == DisappearingTypeNone { + return []byte("{}"), nil + } + return json.Marshal((*marshalableBeeperDisappearingTimer)(bdt)) +} + +type SpaceChildEventContent struct { + Via []string `json:"via,omitempty"` + Order string `json:"order,omitempty"` + Suggested bool `json:"suggested,omitempty"` +} + +type SpaceParentEventContent struct { + Via []string `json:"via,omitempty"` + Canonical bool `json:"canonical,omitempty"` +} + +type PolicyRecommendation string + +const ( + PolicyRecommendationBan PolicyRecommendation = "m.ban" + PolicyRecommendationUnstableTakedown PolicyRecommendation = "org.matrix.msc4204.takedown" + PolicyRecommendationUnstableBan PolicyRecommendation = "org.matrix.mjolnir.ban" + PolicyRecommendationUnban PolicyRecommendation = "fi.mau.meowlnir.unban" +) + +type PolicyHashes struct { + SHA256 string `json:"sha256"` +} + +func (ph *PolicyHashes) DecodeSHA256() *[32]byte { + if ph == nil || ph.SHA256 == "" { + return nil + } + decoded, _ := base64.StdEncoding.DecodeString(ph.SHA256) + if len(decoded) == 32 { + return (*[32]byte)(decoded) + } + return nil +} + +// ModPolicyContent represents the content of a m.room.rule.user, m.room.rule.room, and m.room.rule.server state event. +// https://spec.matrix.org/v1.2/client-server-api/#moderation-policy-lists +type ModPolicyContent struct { + Entity string `json:"entity,omitempty"` + Reason string `json:"reason"` + Recommendation PolicyRecommendation `json:"recommendation"` + UnstableHashes *PolicyHashes `json:"org.matrix.msc4205.hashes,omitempty"` +} + +func (mpc *ModPolicyContent) EntityOrHash() string { + if mpc.UnstableHashes != nil && mpc.UnstableHashes.SHA256 != "" { + return mpc.UnstableHashes.SHA256 + } + return mpc.Entity +} + +type ElementFunctionalMembersContent struct { + ServiceMembers []id.UserID `json:"service_members"` +} + +func (efmc *ElementFunctionalMembersContent) Add(mxid id.UserID) bool { + if slices.Contains(efmc.ServiceMembers, mxid) { + return false + } + efmc.ServiceMembers = append(efmc.ServiceMembers, mxid) + return true +} + +type PolicyServerPublicKeys struct { + Ed25519 id.Ed25519 `json:"ed25519,omitempty"` +} + +type RoomPolicyEventContent struct { + Via string `json:"via,omitempty"` + PublicKeys *PolicyServerPublicKeys `json:"public_keys,omitempty"` + + // Deprecated, only for legacy use + PublicKey id.Ed25519 `json:"public_key,omitempty"` +} diff --git a/mautrix-patched/event/type.go b/mautrix-patched/event/type.go new file mode 100644 index 00000000..0cffbcc6 --- /dev/null +++ b/mautrix-patched/event/type.go @@ -0,0 +1,312 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "fmt" + "strings" + + "maunium.net/go/mautrix/id" +) + +type RoomType string + +const ( + RoomTypeDefault RoomType = "" + RoomTypeSpace RoomType = "m.space" +) + +type TypeClass int + +func (tc TypeClass) Name() string { + switch tc { + case MessageEventType: + return "message" + case StateEventType: + return "state" + case EphemeralEventType: + return "ephemeral" + case AccountDataEventType: + return "account data" + case ToDeviceEventType: + return "to-device" + default: + return "unknown" + } +} + +const ( + // Unknown events + UnknownEventType TypeClass = iota + // Normal message events + MessageEventType + // State events + StateEventType + // Ephemeral events + EphemeralEventType + // Account data events + AccountDataEventType + // Device-to-device events + ToDeviceEventType +) + +type Type struct { + Type string + Class TypeClass +} + +func NewEventType(name string) Type { + evtType := Type{Type: name} + evtType.Class = evtType.GuessClass() + return evtType +} + +func (et *Type) IsState() bool { + return et.Class == StateEventType +} + +func (et *Type) IsEphemeral() bool { + return et.Class == EphemeralEventType +} + +func (et *Type) IsAccountData() bool { + return et.Class == AccountDataEventType +} + +func (et *Type) IsToDevice() bool { + return et.Class == ToDeviceEventType +} + +func (et *Type) IsInRoomVerification() bool { + switch et.Type { + case InRoomVerificationStart.Type, InRoomVerificationReady.Type, InRoomVerificationAccept.Type, + InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type: + return true + default: + return false + } +} + +func (et *Type) IsCall() bool { + switch et.Type { + case CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, + CallNegotiate.Type, CallHangup.Type: + return true + default: + return false + } +} + +func (et *Type) IsCustom() bool { + return !strings.HasPrefix(et.Type, "m.") +} + +func (et *Type) GuessClass() TypeClass { + switch et.Type { + case StateAliases.Type, StateCanonicalAlias.Type, StateCreate.Type, StateJoinRules.Type, StateMember.Type, StateThirdPartyInvite.Type, + StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, + StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, + StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, + StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, + StateMSC4391BotCommand.Type, StateRoomPolicy.Type, StateUnstableRoomPolicy.Type, StateImagePack.Type, + StateUnstableImagePack.Type: + return StateEventType + case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: + return EphemeralEventType + case AccountDataDirectChats.Type, AccountDataPushRules.Type, AccountDataRoomTags.Type, + AccountDataFullyRead.Type, AccountDataIgnoredUserList.Type, AccountDataMarkedUnread.Type, + AccountDataSecretStorageKey.Type, AccountDataSecretStorageDefaultKey.Type, + AccountDataCrossSigningMaster.Type, AccountDataCrossSigningSelf.Type, AccountDataCrossSigningUser.Type, + AccountDataFullyRead.Type, AccountDataMegolmBackupKey.Type, AccountDataImagePackRooms.Type, + AccountDataUnstableImagePackRooms.Type: + return AccountDataEventType + case EventRedaction.Type, EventMessage.Type, EventEncrypted.Type, EventReaction.Type, EventSticker.Type, + InRoomVerificationStart.Type, InRoomVerificationReady.Type, InRoomVerificationAccept.Type, + InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type, + CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, + CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type, + EventUnstablePollEnd.Type, BeeperTranscription.Type, BeeperDeleteChat.Type, BeeperAcceptMessageRequest.Type: + return MessageEventType + case ToDeviceRoomKey.Type, ToDeviceRoomKeyRequest.Type, ToDeviceForwardedRoomKey.Type, ToDeviceRoomKeyWithheld.Type, + ToDeviceBeeperRoomKeyAck.Type, ToDeviceBeeperStreamSubscribe.Type, ToDeviceBeeperStreamUpdate.Type: + return ToDeviceEventType + default: + return UnknownEventType + } +} + +func (et *Type) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, &et.Type) + if err != nil { + return err + } + et.Class = et.GuessClass() + return nil +} + +func (et *Type) MarshalJSON() ([]byte, error) { + return json.Marshal(&et.Type) +} + +func (et *Type) UnmarshalText(data []byte) error { + et.Type = string(data) + et.Class = et.GuessClass() + return nil +} + +func (et Type) MarshalText() ([]byte, error) { + return []byte(et.Type), nil +} + +func (et Type) String() string { + return et.Type +} + +func (et Type) Repr() string { + return fmt.Sprintf("%s (%s)", et.Type, et.Class.Name()) +} + +// State events +var ( + StateAliases = Type{"m.room.aliases", StateEventType} + StateCanonicalAlias = Type{"m.room.canonical_alias", StateEventType} + StateCreate = Type{"m.room.create", StateEventType} + StateJoinRules = Type{"m.room.join_rules", StateEventType} + StateHistoryVisibility = Type{"m.room.history_visibility", StateEventType} + StateGuestAccess = Type{"m.room.guest_access", StateEventType} + StateMember = Type{"m.room.member", StateEventType} + StateThirdPartyInvite = Type{"m.room.third_party_invite", StateEventType} + StatePowerLevels = Type{"m.room.power_levels", StateEventType} + StateRoomName = Type{"m.room.name", StateEventType} + StateTopic = Type{"m.room.topic", StateEventType} + StateRoomAvatar = Type{"m.room.avatar", StateEventType} + StatePinnedEvents = Type{"m.room.pinned_events", StateEventType} + StateServerACL = Type{"m.room.server_acl", StateEventType} + StateTombstone = Type{"m.room.tombstone", StateEventType} + StatePolicyRoom = Type{"m.policy.rule.room", StateEventType} + StatePolicyServer = Type{"m.policy.rule.server", StateEventType} + StatePolicyUser = Type{"m.policy.rule.user", StateEventType} + StateEncryption = Type{"m.room.encryption", StateEventType} + StateBridge = Type{"m.bridge", StateEventType} + StateHalfShotBridge = Type{"uk.half-shot.bridge", StateEventType} + StateSpaceChild = Type{"m.space.child", StateEventType} + StateSpaceParent = Type{"m.space.parent", StateEventType} + + StateRoomPolicy = Type{"m.room.policy", StateEventType} + StateUnstableRoomPolicy = Type{"org.matrix.msc4284.policy", StateEventType} + + StateImagePack = Type{"m.room.image_pack", StateEventType} + StateUnstableImagePack = Type{"im.ponies.room_emotes", StateEventType} + + StateLegacyPolicyRoom = Type{"m.room.rule.room", StateEventType} + StateLegacyPolicyServer = Type{"m.room.rule.server", StateEventType} + StateLegacyPolicyUser = Type{"m.room.rule.user", StateEventType} + StateUnstablePolicyRoom = Type{"org.matrix.mjolnir.rule.room", StateEventType} + StateUnstablePolicyServer = Type{"org.matrix.mjolnir.rule.server", StateEventType} + StateUnstablePolicyUser = Type{"org.matrix.mjolnir.rule.user", StateEventType} + + StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} + StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} + StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType} + StateMSC4391BotCommand = Type{"org.matrix.msc4391.command_description", StateEventType} +) + +// Message events +var ( + EventRedaction = Type{"m.room.redaction", MessageEventType} + EventMessage = Type{"m.room.message", MessageEventType} + EventEncrypted = Type{"m.room.encrypted", MessageEventType} + EventReaction = Type{"m.reaction", MessageEventType} + EventSticker = Type{"m.sticker", MessageEventType} + + InRoomVerificationReady = Type{"m.key.verification.ready", MessageEventType} + InRoomVerificationStart = Type{"m.key.verification.start", MessageEventType} + InRoomVerificationDone = Type{"m.key.verification.done", MessageEventType} + InRoomVerificationCancel = Type{"m.key.verification.cancel", MessageEventType} + + // SAS Verification Events + InRoomVerificationAccept = Type{"m.key.verification.accept", MessageEventType} + InRoomVerificationKey = Type{"m.key.verification.key", MessageEventType} + InRoomVerificationMAC = Type{"m.key.verification.mac", MessageEventType} + + CallInvite = Type{"m.call.invite", MessageEventType} + CallCandidates = Type{"m.call.candidates", MessageEventType} + CallAnswer = Type{"m.call.answer", MessageEventType} + CallReject = Type{"m.call.reject", MessageEventType} + CallSelectAnswer = Type{"m.call.select_answer", MessageEventType} + CallNegotiate = Type{"m.call.negotiate", MessageEventType} + CallHangup = Type{"m.call.hangup", MessageEventType} + + BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} + BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} + BeeperDeleteChat = Type{"com.beeper.delete_chat", MessageEventType} + BeeperAcceptMessageRequest = Type{"com.beeper.accept_message_request", MessageEventType} + BeeperSendState = Type{"com.beeper.send_state", MessageEventType} + + EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} + EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} + EventUnstablePollEnd = Type{Type: "org.matrix.msc3381.poll.end", Class: MessageEventType} +) + +// Ephemeral events +var ( + EphemeralEventReceipt = Type{"m.receipt", EphemeralEventType} + EphemeralEventTyping = Type{"m.typing", EphemeralEventType} + EphemeralEventPresence = Type{"m.presence", EphemeralEventType} +) + +// Account data events +var ( + AccountDataDirectChats = Type{"m.direct", AccountDataEventType} + AccountDataPushRules = Type{"m.push_rules", AccountDataEventType} + AccountDataRoomTags = Type{"m.tag", AccountDataEventType} + AccountDataFullyRead = Type{"m.fully_read", AccountDataEventType} + AccountDataIgnoredUserList = Type{"m.ignored_user_list", AccountDataEventType} + AccountDataMarkedUnread = Type{"m.marked_unread", AccountDataEventType} + AccountDataBeeperMute = Type{"com.beeper.mute", AccountDataEventType} + AccountDataSpaceOrder = Type{"org.matrix.msc3230.space_order", AccountDataEventType} + + AccountDataImagePackRooms = Type{"m.image_pack.rooms", AccountDataEventType} + AccountDataUnstableImagePackRooms = Type{"im.ponies.emote_rooms", AccountDataEventType} + + AccountDataSecretStorageDefaultKey = Type{"m.secret_storage.default_key", AccountDataEventType} + AccountDataSecretStorageKey = Type{"m.secret_storage.key", AccountDataEventType} + AccountDataCrossSigningMaster = Type{string(id.SecretXSMaster), AccountDataEventType} + AccountDataCrossSigningUser = Type{string(id.SecretXSUserSigning), AccountDataEventType} + AccountDataCrossSigningSelf = Type{string(id.SecretXSSelfSigning), AccountDataEventType} + AccountDataMegolmBackupKey = Type{string(id.SecretMegolmBackupV1), AccountDataEventType} +) + +// Device-to-device events +var ( + ToDeviceRoomKey = Type{"m.room_key", ToDeviceEventType} + ToDeviceRoomKeyRequest = Type{"m.room_key_request", ToDeviceEventType} + ToDeviceForwardedRoomKey = Type{"m.forwarded_room_key", ToDeviceEventType} + ToDeviceEncrypted = Type{"m.room.encrypted", ToDeviceEventType} + ToDeviceRoomKeyWithheld = Type{"m.room_key.withheld", ToDeviceEventType} + ToDeviceSecretRequest = Type{"m.secret.request", ToDeviceEventType} + ToDeviceSecretSend = Type{"m.secret.send", ToDeviceEventType} + ToDeviceDummy = Type{"m.dummy", ToDeviceEventType} + + ToDeviceVerificationRequest = Type{"m.key.verification.request", ToDeviceEventType} + ToDeviceVerificationReady = Type{"m.key.verification.ready", ToDeviceEventType} + ToDeviceVerificationStart = Type{"m.key.verification.start", ToDeviceEventType} + ToDeviceVerificationDone = Type{"m.key.verification.done", ToDeviceEventType} + ToDeviceVerificationCancel = Type{"m.key.verification.cancel", ToDeviceEventType} + + // SAS Verification Events + ToDeviceVerificationAccept = Type{"m.key.verification.accept", ToDeviceEventType} + ToDeviceVerificationKey = Type{"m.key.verification.key", ToDeviceEventType} + ToDeviceVerificationMAC = Type{"m.key.verification.mac", ToDeviceEventType} + + ToDeviceOrgMatrixRoomKeyWithheld = Type{"org.matrix.room_key.withheld", ToDeviceEventType} + + ToDeviceBeeperRoomKeyAck = Type{"com.beeper.room_key.ack", ToDeviceEventType} + ToDeviceBeeperStreamSubscribe = Type{"com.beeper.stream.subscribe", ToDeviceEventType} + ToDeviceBeeperStreamUpdate = Type{"com.beeper.stream.update", ToDeviceEventType} +) diff --git a/mautrix-patched/event/verification.go b/mautrix-patched/event/verification.go new file mode 100644 index 00000000..6101896f --- /dev/null +++ b/mautrix-patched/event/verification.go @@ -0,0 +1,308 @@ +// Copyright (c) 2020 Nikos Filippakis +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "go.mau.fi/util/jsonbytes" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/id" +) + +type VerificationMethod string + +const ( + VerificationMethodSAS VerificationMethod = "m.sas.v1" + + VerificationMethodReciprocate VerificationMethod = "m.reciprocate.v1" + VerificationMethodQRCodeShow VerificationMethod = "m.qr_code.show.v1" + VerificationMethodQRCodeScan VerificationMethod = "m.qr_code.scan.v1" +) + +type VerificationTransactionable interface { + GetTransactionID() id.VerificationTransactionID + SetTransactionID(id.VerificationTransactionID) +} + +// ToDeviceVerificationEvent contains the fields common to all to-device +// verification events. +type ToDeviceVerificationEvent struct { + // TransactionID is an opaque identifier for the verification request. Must + // be unique with respect to the devices involved. + TransactionID id.VerificationTransactionID `json:"transaction_id,omitempty"` +} + +var _ VerificationTransactionable = (*ToDeviceVerificationEvent)(nil) + +func (ve *ToDeviceVerificationEvent) GetTransactionID() id.VerificationTransactionID { + return ve.TransactionID +} + +func (ve *ToDeviceVerificationEvent) SetTransactionID(id id.VerificationTransactionID) { + ve.TransactionID = id +} + +// InRoomVerificationEvent contains the fields common to all in-room +// verification events. +type InRoomVerificationEvent struct { + // RelatesTo indicates the m.key.verification.request that this message is + // related to. Note that for encrypted messages, this property should be in + // the unencrypted portion of the event. + RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` +} + +var _ Relatable = (*InRoomVerificationEvent)(nil) + +func (ve *InRoomVerificationEvent) GetRelatesTo() *RelatesTo { + if ve.RelatesTo == nil { + ve.RelatesTo = &RelatesTo{} + } + return ve.RelatesTo +} + +func (ve *InRoomVerificationEvent) OptionalGetRelatesTo() *RelatesTo { + return ve.RelatesTo +} + +func (ve *InRoomVerificationEvent) SetRelatesTo(rel *RelatesTo) { + ve.RelatesTo = rel +} + +// VerificationRequestEventContent represents the content of an +// [m.key.verification.request] to-device event as described in [Section +// 11.12.2.1] of the Spec. +// +// For the in-room version, use a standard [MessageEventContent] struct. +// +// [m.key.verification.request]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationrequest +// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework +type VerificationRequestEventContent struct { + ToDeviceVerificationEvent + // FromDevice is the device ID which is initiating the request. + FromDevice id.DeviceID `json:"from_device"` + // Methods is a list of the verification methods supported by the sender. + Methods []VerificationMethod `json:"methods"` + // Timestamp is the time at which the request was made. + Timestamp jsontime.UnixMilli `json:"timestamp,omitempty"` +} + +// VerificationRequestEventContentFromMessage converts an in-room verification +// request message event to a [VerificationRequestEventContent]. +func VerificationRequestEventContentFromMessage(evt *Event) *VerificationRequestEventContent { + content := evt.Content.AsMessage() + return &VerificationRequestEventContent{ + ToDeviceVerificationEvent: ToDeviceVerificationEvent{ + TransactionID: id.VerificationTransactionID(evt.ID), + }, + Timestamp: jsontime.UMInt(evt.Timestamp), + FromDevice: content.FromDevice, + Methods: content.Methods, + } +} + +// VerificationReadyEventContent represents the content of an +// [m.key.verification.ready] event (both the to-device and the in-room +// version) as described in [Section 11.12.2.1] of the Spec. +// +// [m.key.verification.ready]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationready +// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework +type VerificationReadyEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent + + // FromDevice is the device ID which is initiating the request. + FromDevice id.DeviceID `json:"from_device"` + // Methods is a list of the verification methods supported by the sender. + Methods []VerificationMethod `json:"methods"` +} + +type KeyAgreementProtocol string + +const ( + KeyAgreementProtocolCurve25519 KeyAgreementProtocol = "curve25519" + KeyAgreementProtocolCurve25519HKDFSHA256 KeyAgreementProtocol = "curve25519-hkdf-sha256" +) + +type VerificationHashMethod string + +const VerificationHashMethodSHA256 VerificationHashMethod = "sha256" + +type MACMethod string + +const ( + MACMethodHKDFHMACSHA256 MACMethod = "hkdf-hmac-sha256" + MACMethodHKDFHMACSHA256V2 MACMethod = "hkdf-hmac-sha256.v2" +) + +type SASMethod string + +const ( + SASMethodDecimal SASMethod = "decimal" + SASMethodEmoji SASMethod = "emoji" +) + +// VerificationStartEventContent represents the content of an +// [m.key.verification.start] event (both the to-device and the in-room +// version) as described in [Section 11.12.2.1] of the Spec. +// +// This struct also contains the fields for an [m.key.verification.start] event +// using the [VerificationMethodSAS] method as described in [Section +// 11.12.2.2.2] and an [m.key.verification.start] using +// [VerificationMethodReciprocate] as described in [Section 11.12.2.4.2]. +// +// [m.key.verification.start]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationstart +// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework +// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas +// [Section 11.12.2.4.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-qr-codes +type VerificationStartEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent + + // FromDevice is the device ID which is initiating the request. + FromDevice id.DeviceID `json:"from_device"` + // Method is the verification method to use. + Method VerificationMethod `json:"method"` + // NextMethod is an optional method to use to verify the other user's key. + // Applicable when the method chosen only verifies one user’s key. This + // field will never be present if the method verifies keys both ways. + NextMethod VerificationMethod `json:"next_method,omitempty"` + + // Hashes are the hash methods the sending device understands. This field + // is only applicable when the method is m.sas.v1. + Hashes []VerificationHashMethod `json:"hashes,omitempty"` + // KeyAgreementProtocols is the list of key agreement protocols the sending + // device understands. This field is only applicable when the method is + // m.sas.v1. + KeyAgreementProtocols []KeyAgreementProtocol `json:"key_agreement_protocols,omitempty"` + // MessageAuthenticationCodes is a list of the MAC methods that the sending + // device understands. This field is only applicable when the method is + // m.sas.v1. + MessageAuthenticationCodes []MACMethod `json:"message_authentication_codes"` + // ShortAuthenticationString is a list of SAS methods the sending device + // (and the sending device's user) understands. This field is only + // applicable when the method is m.sas.v1. + ShortAuthenticationString []SASMethod `json:"short_authentication_string"` + + // Secret is the shared secret from the QR code. This field is only + // applicable when the method is m.reciprocate.v1. + Secret jsonbytes.UnpaddedBytes `json:"secret,omitempty"` +} + +// VerificationDoneEventContent represents the content of an +// [m.key.verification.done] event (both the to-device and the in-room version) +// as described in [Section 11.12.2.1] of the Spec. +// +// This type is an alias for [VerificationRelatable] since there are no +// additional fields defined by the spec. +// +// [m.key.verification.done]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationdone +// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationdone +type VerificationDoneEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent +} + +type VerificationCancelCode string + +const ( + VerificationCancelCodeUser VerificationCancelCode = "m.user" + VerificationCancelCodeTimeout VerificationCancelCode = "m.timeout" + VerificationCancelCodeUnknownTransaction VerificationCancelCode = "m.unknown_transaction" + VerificationCancelCodeUnknownMethod VerificationCancelCode = "m.unknown_method" + VerificationCancelCodeUnexpectedMessage VerificationCancelCode = "m.unexpected_message" + VerificationCancelCodeKeyMismatch VerificationCancelCode = "m.key_mismatch" + VerificationCancelCodeUserMismatch VerificationCancelCode = "m.user_mismatch" + VerificationCancelCodeInvalidMessage VerificationCancelCode = "m.invalid_message" + VerificationCancelCodeAccepted VerificationCancelCode = "m.accepted" + VerificationCancelCodeSASMismatch VerificationCancelCode = "m.mismatched_sas" + VerificationCancelCodeCommitmentMismatch VerificationCancelCode = "m.mismatched_commitment" + + // Non-spec codes + VerificationCancelCodeInternalError VerificationCancelCode = "com.beeper.internal_error" + VerificationCancelCodeMasterKeyNotTrusted VerificationCancelCode = "com.beeper.master_key_not_trusted" // the master key is not trusted by this device, but the QR code that was scanned was from a device that doesn't trust the master key +) + +// VerificationCancelEventContent represents the content of an +// [m.key.verification.cancel] event (both the to-device and the in-room +// version) as described in [Section 11.12.2.1] of the Spec. +// +// [m.key.verification.cancel]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationcancel +// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationdone +type VerificationCancelEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent + + // Code is the error code for why the process/request was cancelled by the + // user. + Code VerificationCancelCode `json:"code"` + // Reason is a human readable description of the code. The client should + // only rely on this string if it does not understand the code. + Reason string `json:"reason"` +} + +// VerificationAcceptEventContent represents the content of an +// [m.key.verification.accept] event (both the to-device and the in-room +// version) as described in [Section 11.12.2.2.2] of the Spec. +// +// [m.key.verification.accept]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationaccept +// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas +type VerificationAcceptEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent + + // Commitment is the hash of the concatenation of the device's ephemeral + // public key (encoded as unpadded base64) and the canonical JSON + // representation of the m.key.verification.start message. + Commitment jsonbytes.UnpaddedBytes `json:"commitment"` + // Hash is the hash method the device is choosing to use, out of the + // options in the m.key.verification.start message. + Hash VerificationHashMethod `json:"hash"` + // KeyAgreementProtocol is the key agreement protocol the device is + // choosing to use, out of the options in the m.key.verification.start + // message. + KeyAgreementProtocol KeyAgreementProtocol `json:"key_agreement_protocol"` + // MessageAuthenticationCode is the message authentication code the device + // is choosing to use, out of the options in the m.key.verification.start + // message. + MessageAuthenticationCode MACMethod `json:"message_authentication_code"` + // ShortAuthenticationString is a list of SAS methods both devices involved + // in the verification process understand. Must be a subset of the options + // in the m.key.verification.start message. + ShortAuthenticationString []SASMethod `json:"short_authentication_string"` +} + +// VerificationKeyEventContent represents the content of an +// [m.key.verification.key] event (both the to-device and the in-room version) +// as described in [Section 11.12.2.2.2] of the Spec. +// +// [m.key.verification.key]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationkey +// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas +type VerificationKeyEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent + + // Key is the device’s ephemeral public key. + Key jsonbytes.UnpaddedBytes `json:"key"` +} + +// VerificationMACEventContent represents the content of an +// [m.key.verification.mac] event (both the to-device and the in-room version) +// as described in [Section 11.12.2.2.2] of the Spec. +// +// [m.key.verification.mac]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationmac +// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas +type VerificationMACEventContent struct { + ToDeviceVerificationEvent + InRoomVerificationEvent + + // Keys is the MAC of the comma-separated, sorted, list of key IDs given in + // the MAC property. + Keys jsonbytes.UnpaddedBytes `json:"keys"` + // MAC is a map of the key ID to the MAC of the key, using the algorithm in + // the verification process. + MAC map[id.KeyID]jsonbytes.UnpaddedBytes `json:"mac"` +} diff --git a/mautrix-patched/event/voip.go b/mautrix-patched/event/voip.go new file mode 100644 index 00000000..cd8364a1 --- /dev/null +++ b/mautrix-patched/event/voip.go @@ -0,0 +1,116 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event + +import ( + "encoding/json" + "fmt" + "strconv" +) + +type CallHangupReason string + +const ( + CallHangupICEFailed CallHangupReason = "ice_failed" + CallHangupInviteTimeout CallHangupReason = "invite_timeout" + CallHangupUserHangup CallHangupReason = "user_hangup" + CallHangupUserMediaFailed CallHangupReason = "user_media_failed" + CallHangupUnknownError CallHangupReason = "unknown_error" +) + +type CallDataType string + +const ( + CallDataTypeOffer CallDataType = "offer" + CallDataTypeAnswer CallDataType = "answer" +) + +type CallData struct { + SDP string `json:"sdp"` + Type CallDataType `json:"type"` +} + +type CallCandidate struct { + Candidate string `json:"candidate"` + SDPMLineIndex int `json:"sdpMLineIndex"` + SDPMID string `json:"sdpMid"` +} + +type CallVersion string + +func (cv *CallVersion) UnmarshalJSON(raw []byte) error { + var numberVersion int + err := json.Unmarshal(raw, &numberVersion) + if err != nil { + var stringVersion string + err = json.Unmarshal(raw, &stringVersion) + if err != nil { + return fmt.Errorf("failed to parse CallVersion: %w", err) + } + *cv = CallVersion(stringVersion) + } else { + *cv = CallVersion(strconv.Itoa(numberVersion)) + } + return nil +} + +func (cv *CallVersion) MarshalJSON() ([]byte, error) { + for _, char := range *cv { + if char < '0' || char > '9' { + // The version contains weird characters, return as string. + return json.Marshal(string(*cv)) + } + } + // The version consists of only ASCII digits, return as an integer. + return []byte(*cv), nil +} + +func (cv *CallVersion) Int() (int, error) { + return strconv.Atoi(string(*cv)) +} + +type BaseCallEventContent struct { + CallID string `json:"call_id"` + PartyID string `json:"party_id"` + Version CallVersion `json:"version,omitempty"` +} + +type CallInviteEventContent struct { + BaseCallEventContent + Lifetime int `json:"lifetime"` + Offer CallData `json:"offer"` +} + +type CallCandidatesEventContent struct { + BaseCallEventContent + Candidates []CallCandidate `json:"candidates"` +} + +type CallRejectEventContent struct { + BaseCallEventContent +} + +type CallAnswerEventContent struct { + BaseCallEventContent + Answer CallData `json:"answer"` +} + +type CallSelectAnswerEventContent struct { + BaseCallEventContent + SelectedPartyID string `json:"selected_party_id"` +} + +type CallNegotiateEventContent struct { + BaseCallEventContent + Lifetime int `json:"lifetime"` + Description CallData `json:"description"` +} + +type CallHangupEventContent struct { + BaseCallEventContent + Reason CallHangupReason `json:"reason"` +} diff --git a/mautrix-patched/event/voip_test.go b/mautrix-patched/event/voip_test.go new file mode 100644 index 00000000..81014444 --- /dev/null +++ b/mautrix-patched/event/voip_test.go @@ -0,0 +1,175 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package event_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/event" +) + +const callCandidates = `{ + "type": "m.call.candidates", + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 1432735824653, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "content": { + "call_id": "12345", + "candidates": [ + { + "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0", + "sdpMLineIndex": 0, + "sdpMid": "audio" + } + ], + "version": 0 + }, + "unsigned": { + "age": 1234 + } +}` + +const callSelectAnswer = `{ + "type": "m.call.select_answer", + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 1432735824653, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "content": { + "version": 1, + "call_id": "12345", + "party_id": "67890", + "selected_party_id": "111213" + }, + "unsigned": { + "age": 1234 + } +}` + +const callAnswerStringVersion = `{ + "type": "m.call.answer", + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 1432735824653, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "content": { + "answer": { + "sdp": "v=0\r\no=- 6584580628695956864 2 IN IP4 127.0.0.1[...]", + "type": "answer" + }, + "call_id": "12345", + "lifetime": 60000, + "version": "com.example.call.version" + }, + "unsigned": { + "age": 1234 + } +}` + +func TestCallCandidatesEventContent_Parse(t *testing.T) { + var evt *event.Event + err := json.Unmarshal([]byte(callCandidates), &evt) + require.NoError(t, err) + require.Equal(t, evt.Type, event.CallCandidates) + err = evt.Content.ParseRaw(evt.Type) + require.NoError(t, err) + content := evt.Content.AsCallCandidates() + require.NotNil(t, content) + assert.Equal(t, event.CallVersion("0"), content.Version) +} + +func TestCallSelectAnswerEventContent_Parse(t *testing.T) { + var evt *event.Event + err := json.Unmarshal([]byte(callSelectAnswer), &evt) + require.NoError(t, err) + require.Equal(t, evt.Type, event.CallSelectAnswer) + err = evt.Content.ParseRaw(evt.Type) + require.NoError(t, err) + content := evt.Content.AsCallSelectAnswer() + require.NotNil(t, content) + assert.Equal(t, event.CallVersion("1"), content.Version) +} + +func TestCallAnswerContent_Parse(t *testing.T) { + var evt *event.Event + err := json.Unmarshal([]byte(callAnswerStringVersion), &evt) + require.NoError(t, err) + require.Equal(t, evt.Type, event.CallAnswer) + err = evt.Content.ParseRaw(evt.Type) + require.NoError(t, err) + content := evt.Content.AsCallAnswer() + require.NotNil(t, content) + assert.Equal(t, event.CallVersion("com.example.call.version"), content.Version) +} + +func TestCallVersion_MarshalJSON(t *testing.T) { + var version event.CallVersion + var data []byte + var err error + + version = "1" + data, err = json.Marshal(&version) + assert.NoError(t, err) + assert.Equal(t, []byte("1"), data) + + version = "0" + data, err = json.Marshal(&version) + assert.NoError(t, err) + assert.Equal(t, []byte("0"), data) + + version = "1234" + data, err = json.Marshal(&version) + assert.NoError(t, err) + assert.Equal(t, []byte("1234"), data) + + version = "com.example.call.version" + data, err = json.Marshal(&version) + assert.NoError(t, err) + assert.Equal(t, []byte(`"com.example.call.version"`), data) +} + +func TestCallVersion_UnmarshalJSON(t *testing.T) { + var version event.CallVersion + var err error + + err = json.Unmarshal([]byte(`1`), &version) + assert.NoError(t, err) + assert.Equal(t, event.CallVersion("1"), version) + + err = json.Unmarshal([]byte(`0`), &version) + assert.NoError(t, err) + assert.Equal(t, event.CallVersion("0"), version) + + err = json.Unmarshal([]byte(`1234`), &version) + assert.NoError(t, err) + assert.Equal(t, event.CallVersion("1234"), version) + + err = json.Unmarshal([]byte(`"1234"`), &version) + assert.NoError(t, err) + assert.Equal(t, event.CallVersion("1234"), version) + + err = json.Unmarshal([]byte(`"com.example.call.version"`), &version) + assert.NoError(t, err) + assert.Equal(t, event.CallVersion("com.example.call.version"), version) + + err = json.Unmarshal([]byte(`1.234`), &version) + assert.Error(t, err) + + err = json.Unmarshal([]byte(`false`), &version) + assert.Error(t, err) + + err = json.Unmarshal([]byte(`["hmm"]`), &version) + assert.Error(t, err) + + err = json.Unmarshal([]byte(`{"hmm": true}`), &version) + assert.Error(t, err) +} diff --git a/mautrix-patched/example/main.go b/mautrix-patched/example/main.go new file mode 100644 index 00000000..2bf4bef3 --- /dev/null +++ b/mautrix-patched/example/main.go @@ -0,0 +1,155 @@ +// Copyright (C) 2017 Tulir Asokan +// Copyright (C) 2018-2020 Luca Weiss +// Copyright (C) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "sync" + "time" + + "github.com/chzyer/readline" + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "go.mau.fi/util/exzerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var homeserver = flag.String("homeserver", "", "Matrix homeserver") +var username = flag.String("username", "", "Matrix username localpart") +var password = flag.String("password", "", "Matrix password") +var database = flag.String("database", "mautrix-example.db", "SQLite database path") +var debug = flag.Bool("debug", false, "Enable debug logs") + +func main() { + flag.Parse() + if *username == "" || *password == "" || *homeserver == "" { + _, _ = fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + os.Exit(1) + } + + client, err := mautrix.NewClient(*homeserver, "", "") + if err != nil { + panic(err) + } + rl, err := readline.New("[no room]> ") + if err != nil { + panic(err) + } + defer rl.Close() + log := zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.Out = rl.Stdout() + w.TimeFormat = time.Stamp + })).With().Timestamp().Logger() + if !*debug { + log = log.Level(zerolog.InfoLevel) + } + exzerolog.SetupDefaults(&log) + client.Log = log + + var lastRoomID id.RoomID + + syncer := client.Syncer.(*mautrix.DefaultSyncer) + syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + lastRoomID = evt.RoomID + rl.SetPrompt(fmt.Sprintf("%s> ", lastRoomID)) + log.Info(). + Str("sender", evt.Sender.String()). + Str("type", evt.Type.String()). + Str("id", evt.ID.String()). + Str("body", evt.Content.AsMessage().Body). + Msg("Received message") + }) + syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { + if evt.GetStateKey() == client.UserID.String() && evt.Content.AsMember().Membership == event.MembershipInvite { + _, err := client.JoinRoomByID(ctx, evt.RoomID) + if err == nil { + lastRoomID = evt.RoomID + rl.SetPrompt(fmt.Sprintf("%s> ", lastRoomID)) + log.Info(). + Str("room_id", evt.RoomID.String()). + Str("inviter", evt.Sender.String()). + Msg("Joined room after invite") + } else { + log.Error().Err(err). + Str("room_id", evt.RoomID.String()). + Str("inviter", evt.Sender.String()). + Msg("Failed to join room after invite") + } + } + }) + + cryptoHelper, err := cryptohelper.NewCryptoHelper(client, []byte("meow"), *database) + if err != nil { + panic(err) + } + + // You can also store the user/device IDs and access token and put them in the client beforehand instead of using LoginAs. + //client.UserID = "..." + //client.DeviceID = "..." + //client.AccessToken = "..." + // You don't need to set a device ID in LoginAs because the crypto helper will set it for you if necessary. + cryptoHelper.LoginAs = &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: *username}, + Password: *password, + } + // If you want to use multiple clients with the same DB, you should set a distinct database account ID for each one. + //cryptoHelper.DBAccountID = "" + err = cryptoHelper.Init(context.TODO()) + if err != nil { + panic(err) + } + // Set the client crypto helper in order to automatically encrypt outgoing messages + client.Crypto = cryptoHelper + + log.Info().Msg("Now running") + syncCtx, cancelSync := context.WithCancel(context.Background()) + var syncStopWait sync.WaitGroup + syncStopWait.Add(1) + + go func() { + err = client.SyncWithContext(syncCtx) + defer syncStopWait.Done() + if err != nil && !errors.Is(err, context.Canceled) { + panic(err) + } + }() + + for { + line, err := rl.Readline() + if err != nil { // io.EOF + break + } + if lastRoomID == "" { + log.Error().Msg("Wait for an incoming message before sending messages") + continue + } + resp, err := client.SendText(context.TODO(), lastRoomID, line) + if err != nil { + log.Error().Err(err).Msg("Failed to send event") + } else { + log.Info().Stringer("event_id", resp.EventID).Msg("Event sent") + } + } + cancelSync() + syncStopWait.Wait() + err = cryptoHelper.Close() + if err != nil { + log.Error().Err(err).Msg("Error closing database") + } +} diff --git a/mautrix-patched/federation/cache.go b/mautrix-patched/federation/cache.go new file mode 100644 index 00000000..24154974 --- /dev/null +++ b/mautrix-patched/federation/cache.go @@ -0,0 +1,153 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "errors" + "fmt" + "math" + "sync" + "time" +) + +// ResolutionCache is an interface for caching resolved server names. +type ResolutionCache interface { + StoreResolution(*ResolvedServerName) + // LoadResolution loads a resolved server name from the cache. + // Expired entries MUST NOT be returned. + LoadResolution(serverName string) (*ResolvedServerName, error) +} + +type KeyCache interface { + StoreKeys(*ServerKeyResponse) + StoreFetchError(serverName string, err error) + ShouldReQuery(serverName string) bool + LoadKeys(serverName string) (*ServerKeyResponse, error) +} + +type InMemoryCache struct { + MinKeyRefetchDelay time.Duration + + resolutions map[string]*ResolvedServerName + resolutionsLock sync.RWMutex + keys map[string]*ServerKeyResponse + lastReQueryAt map[string]time.Time + lastError map[string]*resolutionErrorCache + keysLock sync.RWMutex +} + +var ( + _ ResolutionCache = (*InMemoryCache)(nil) + _ KeyCache = (*InMemoryCache)(nil) +) + +func NewInMemoryCache() *InMemoryCache { + return &InMemoryCache{ + resolutions: make(map[string]*ResolvedServerName), + keys: make(map[string]*ServerKeyResponse), + lastReQueryAt: make(map[string]time.Time), + lastError: make(map[string]*resolutionErrorCache), + MinKeyRefetchDelay: 1 * time.Hour, + } +} + +func (c *InMemoryCache) StoreResolution(resolution *ResolvedServerName) { + c.resolutionsLock.Lock() + defer c.resolutionsLock.Unlock() + c.resolutions[resolution.ServerName] = resolution +} + +func (c *InMemoryCache) LoadResolution(serverName string) (*ResolvedServerName, error) { + c.resolutionsLock.RLock() + defer c.resolutionsLock.RUnlock() + resolution, ok := c.resolutions[serverName] + if !ok || time.Until(resolution.Expires) < 0 { + return nil, nil + } + return resolution, nil +} + +func (c *InMemoryCache) StoreKeys(keys *ServerKeyResponse) { + c.keysLock.Lock() + defer c.keysLock.Unlock() + c.keys[keys.ServerName] = keys + delete(c.lastError, keys.ServerName) +} + +type resolutionErrorCache struct { + Error error + Time time.Time + Count int +} + +const MaxBackoff = 7 * 24 * time.Hour + +func (rec *resolutionErrorCache) ShouldRetry() bool { + backoff := time.Duration(math.Exp(float64(rec.Count))) * time.Second + return time.Since(rec.Time) > backoff +} + +var ErrRecentKeyQueryFailed = errors.New("last retry was too recent") + +func (c *InMemoryCache) LoadKeys(serverName string) (*ServerKeyResponse, error) { + c.keysLock.RLock() + defer c.keysLock.RUnlock() + keys, ok := c.keys[serverName] + if !ok || time.Until(keys.ValidUntilTS.Time) < 0 { + err, ok := c.lastError[serverName] + if ok && !err.ShouldRetry() { + return nil, fmt.Errorf( + "%w (%s ago) and failed with %w", + ErrRecentKeyQueryFailed, + time.Since(err.Time).String(), + err.Error, + ) + } + return nil, nil + } + return keys, nil +} + +func (c *InMemoryCache) StoreFetchError(serverName string, err error) { + c.keysLock.Lock() + defer c.keysLock.Unlock() + errorCache, ok := c.lastError[serverName] + if ok { + errorCache.Time = time.Now() + errorCache.Error = err + errorCache.Count++ + } else { + c.lastError[serverName] = &resolutionErrorCache{Error: err, Time: time.Now(), Count: 1} + } +} + +func (c *InMemoryCache) ShouldReQuery(serverName string) bool { + c.keysLock.Lock() + defer c.keysLock.Unlock() + lastQuery, ok := c.lastReQueryAt[serverName] + if ok && time.Since(lastQuery) < c.MinKeyRefetchDelay { + return false + } + c.lastReQueryAt[serverName] = time.Now() + return true +} + +type noopCache struct{} + +func (*noopCache) StoreKeys(_ *ServerKeyResponse) {} +func (*noopCache) LoadKeys(_ string) (*ServerKeyResponse, error) { return nil, nil } +func (*noopCache) StoreFetchError(_ string, _ error) {} +func (*noopCache) ShouldReQuery(_ string) bool { return true } +func (*noopCache) StoreResolution(_ *ResolvedServerName) {} +func (*noopCache) LoadResolution(_ string) (*ResolvedServerName, error) { return nil, nil } + +var ( + _ ResolutionCache = (*noopCache)(nil) + _ KeyCache = (*noopCache)(nil) +) + +var NoopCache *noopCache diff --git a/mautrix-patched/federation/client.go b/mautrix-patched/federation/client.go new file mode 100644 index 00000000..4f3ca083 --- /dev/null +++ b/mautrix-patched/federation/client.go @@ -0,0 +1,629 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "time" + + "go.mau.fi/util/exhttp" + "go.mau.fi/util/exslices" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +type Client struct { + HTTP *http.Client + ExtHTTP *http.Client + Dialer *net.Dialer + AllowIP func(net.IP) bool + ServerName string + UserAgent string + Key *SigningKey + + ResponseSizeLimit int64 +} + +func NewClient(serverName string, key *SigningKey, cache ResolutionCache, httpSettings exhttp.ClientSettings) *Client { + dialer := &net.Dialer{Timeout: httpSettings.DialTimeout} + c := &Client{ + HTTP: &http.Client{ + Transport: NewServerResolvingTransport(cache, dialer.DialContext, httpSettings), + Timeout: httpSettings.GlobalTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Federation requests do not allow redirects. + return http.ErrUseLastResponse + }, + }, + ExtHTTP: httpSettings.WithDial(dialer.DialContext).Compile(), + Dialer: dialer, + UserAgent: mautrix.DefaultUserAgent, + ServerName: serverName, + Key: key, + AllowIP: DefaultAllowIP, + + ResponseSizeLimit: mautrix.DefaultResponseSizeLimit, + } + c.ExtHTTP.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // External requests (like media download redirects) can redirect further themselves, + // but only allow secure URLs. + if req.URL.Scheme != "https" { + return fmt.Errorf("attempted to redirect to non-https URL") + } + return nil + } + dialer.ControlContext = c.controlConn + return c +} + +func DefaultAllowIP(ip net.IP) bool { + return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLinkLocalMulticast() +} + +func (c *Client) Version(ctx context.Context, serverName string) (resp *RespServerVersion, err error) { + err = c.MakeRequest(ctx, serverName, false, http.MethodGet, URLPath{"v1", "version"}, nil, &resp) + return +} + +func (c *Client) ServerKeys(ctx context.Context, serverName string) (resp *ServerKeyResponse, err error) { + err = c.MakeRequest(ctx, serverName, false, http.MethodGet, KeyURLPath{"v2", "server"}, nil, &resp) + return +} + +func (c *Client) QueryKeys(ctx context.Context, serverName string, req *ReqQueryKeys) (resp *QueryKeysResponse, err error) { + err = c.MakeRequest(ctx, serverName, false, http.MethodPost, KeyURLPath{"v2", "query"}, req, &resp) + return +} + +type PDU = json.RawMessage +type EDU = json.RawMessage + +type ReqSendTransaction struct { + Destination string `json:"destination"` + TxnID string `json:"-"` + + Origin string `json:"origin"` + OriginServerTS jsontime.UnixMilli `json:"origin_server_ts"` + PDUs []PDU `json:"pdus"` + EDUs []EDU `json:"edus,omitempty"` +} + +type PDUProcessingResult struct { + Error string `json:"error,omitempty"` +} + +type RespSendTransaction struct { + PDUs map[id.EventID]PDUProcessingResult `json:"pdus"` +} + +func (c *Client) SendTransaction(ctx context.Context, req *ReqSendTransaction) (resp *RespSendTransaction, err error) { + err = c.MakeRequest(ctx, req.Destination, true, http.MethodPut, URLPath{"v1", "send", req.TxnID}, req, &resp) + return +} + +type RespGetEventAuthChain struct { + AuthChain []PDU `json:"auth_chain"` +} + +func (c *Client) GetEventAuthChain(ctx context.Context, serverName string, roomID id.RoomID, eventID id.EventID) (resp *RespGetEventAuthChain, err error) { + err = c.MakeRequest(ctx, serverName, true, http.MethodGet, URLPath{"v1", "event_auth", roomID, eventID}, nil, &resp) + return +} + +type ReqBackfill struct { + ServerName string + RoomID id.RoomID + Limit int + BackfillFrom []id.EventID +} + +type RespBackfill struct { + Origin string `json:"origin"` + OriginServerTS jsontime.UnixMilli `json:"origin_server_ts"` + PDUs []PDU `json:"pdus"` +} + +func (c *Client) Backfill(ctx context.Context, req *ReqBackfill) (resp *RespBackfill, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.ServerName, + Method: http.MethodGet, + Path: URLPath{"v1", "backfill", req.RoomID}, + Query: url.Values{ + "limit": {strconv.Itoa(req.Limit)}, + "v": exslices.CastToString[string](req.BackfillFrom), + }, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +type ReqGetMissingEvents struct { + ServerName string `json:"-"` + RoomID id.RoomID `json:"-"` + EarliestEvents []id.EventID `json:"earliest_events"` + LatestEvents []id.EventID `json:"latest_events"` + Limit int `json:"limit,omitempty"` + MinDepth int `json:"min_depth,omitempty"` +} + +type RespGetMissingEvents struct { + Events []PDU `json:"events"` +} + +func (c *Client) GetMissingEvents(ctx context.Context, req *ReqGetMissingEvents) (resp *RespGetMissingEvents, err error) { + err = c.MakeRequest(ctx, req.ServerName, true, http.MethodPost, URLPath{"v1", "get_missing_events", req.RoomID}, req, &resp) + return +} + +func (c *Client) GetEvent(ctx context.Context, serverName string, eventID id.EventID) (resp *RespBackfill, err error) { + err = c.MakeRequest(ctx, serverName, true, http.MethodGet, URLPath{"v1", "event", eventID}, nil, &resp) + return +} + +type RespGetState struct { + AuthChain []PDU `json:"auth_chain"` + PDUs []PDU `json:"pdus"` +} + +func (c *Client) GetState(ctx context.Context, serverName string, roomID id.RoomID, eventID id.EventID) (resp *RespGetState, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "state", roomID}, + Query: url.Values{ + "event_id": {string(eventID)}, + }, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +type RespGetStateIDs struct { + AuthChain []id.EventID `json:"auth_chain_ids"` + PDUs []id.EventID `json:"pdu_ids"` +} + +func (c *Client) GetStateIDs(ctx context.Context, serverName string, roomID id.RoomID, eventID id.EventID) (resp *RespGetStateIDs, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "state_ids", roomID}, + Query: url.Values{ + "event_id": {string(eventID)}, + }, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) TimestampToEvent(ctx context.Context, serverName string, roomID id.RoomID, timestamp time.Time, dir mautrix.Direction) (resp *mautrix.RespTimestampToEvent, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "timestamp_to_event", roomID}, + Query: url.Values{ + "dir": {string(dir)}, + "ts": {strconv.FormatInt(timestamp.UnixMilli(), 10)}, + }, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) QueryProfile(ctx context.Context, serverName string, userID id.UserID) (resp *mautrix.RespUserProfile, err error) { + err = c.Query(ctx, serverName, "profile", url.Values{"user_id": {userID.String()}}, &resp) + return +} + +func (c *Client) QueryDirectory(ctx context.Context, serverName string, roomAlias id.RoomAlias) (resp *mautrix.RespAliasResolve, err error) { + err = c.Query(ctx, serverName, "directory", url.Values{"room_alias": {roomAlias.String()}}, &resp) + return +} + +func (c *Client) Query(ctx context.Context, serverName, queryType string, queryParams url.Values, respStruct any) (err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "query", queryType}, + Query: queryParams, + Authenticate: true, + ResponseJSON: respStruct, + }) + return +} + +func queryToValues(query map[string]string) url.Values { + values := make(url.Values, len(query)) + for k, v := range query { + values[k] = []string{v} + } + return values +} + +func (c *Client) PublicRooms(ctx context.Context, serverName string, req *mautrix.ReqPublicRooms) (resp *mautrix.RespPublicRooms, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "publicRooms"}, + Query: queryToValues(req.Query()), + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +type RespOpenIDUserInfo struct { + Sub id.UserID `json:"sub"` +} + +func (c *Client) GetOpenIDUserInfo(ctx context.Context, serverName, accessToken string) (resp *RespOpenIDUserInfo, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "openid", "userinfo"}, + Query: url.Values{"access_token": {accessToken}}, + ResponseJSON: &resp, + }) + return +} + +type ReqMakeJoin struct { + RoomID id.RoomID + UserID id.UserID + Via string + SupportedVersions []id.RoomVersion +} + +type RespMakeJoin struct { + RoomVersion id.RoomVersion `json:"room_version"` + Event PDU `json:"event"` +} + +type ReqSendJoin struct { + RoomID id.RoomID + EventID id.EventID + OmitMembers bool + Event PDU + Via string +} + +type ReqSendKnock struct { + RoomID id.RoomID + EventID id.EventID + Event PDU + Via string +} + +type RespSendJoin struct { + AuthChain []PDU `json:"auth_chain"` + Event PDU `json:"event"` + MembersOmitted bool `json:"members_omitted"` + ServersInRoom []string `json:"servers_in_room"` + State []PDU `json:"state"` +} + +type RespSendKnock struct { + KnockRoomState []PDU `json:"knock_room_state"` +} + +type ReqSendInvite struct { + RoomID id.RoomID `json:"-"` + UserID id.UserID `json:"-"` + Event PDU `json:"event"` + InviteRoomState []PDU `json:"invite_room_state"` + RoomVersion id.RoomVersion `json:"room_version"` +} + +type RespSendInvite struct { + Event PDU `json:"event"` +} + +type ReqMakeLeave struct { + RoomID id.RoomID + UserID id.UserID + Via string +} + +type ReqSendLeave struct { + RoomID id.RoomID + EventID id.EventID + Event PDU + Via string +} + +type ( + ReqMakeKnock = ReqMakeJoin + RespMakeKnock = RespMakeJoin + RespMakeLeave = RespMakeJoin +) + +func (c *Client) MakeJoin(ctx context.Context, req *ReqMakeJoin) (resp *RespMakeJoin, err error) { + versions := make([]string, len(req.SupportedVersions)) + for i, v := range req.SupportedVersions { + versions[i] = string(v) + } + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodGet, + Path: URLPath{"v1", "make_join", req.RoomID, req.UserID}, + Query: url.Values{"ver": versions}, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) MakeKnock(ctx context.Context, req *ReqMakeKnock) (resp *RespMakeKnock, err error) { + versions := make([]string, len(req.SupportedVersions)) + for i, v := range req.SupportedVersions { + versions[i] = string(v) + } + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodGet, + Path: URLPath{"v1", "make_knock", req.RoomID, req.UserID}, + Query: url.Values{"ver": versions}, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendJoin(ctx context.Context, req *ReqSendJoin) (resp *RespSendJoin, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodPut, + Path: URLPath{"v2", "send_join", req.RoomID, req.EventID}, + Query: url.Values{ + "omit_members": {strconv.FormatBool(req.OmitMembers)}, + }, + Authenticate: true, + RequestJSON: req.Event, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendKnock(ctx context.Context, req *ReqSendKnock) (resp *RespSendKnock, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodPut, + Path: URLPath{"v1", "send_knock", req.RoomID, req.EventID}, + Authenticate: true, + RequestJSON: req.Event, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendInvite(ctx context.Context, req *ReqSendInvite) (resp *RespSendInvite, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.UserID.Homeserver(), + Method: http.MethodPut, + Path: URLPath{"v2", "invite", req.RoomID, req.UserID}, + Authenticate: true, + RequestJSON: req, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) MakeLeave(ctx context.Context, req *ReqMakeLeave) (resp *RespMakeLeave, err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodGet, + Path: URLPath{"v1", "make_leave", req.RoomID, req.UserID}, + Authenticate: true, + ResponseJSON: &resp, + }) + return +} + +func (c *Client) SendLeave(ctx context.Context, req *ReqSendLeave) (err error) { + _, _, err = c.MakeFullRequest(ctx, RequestParams{ + ServerName: req.Via, + Method: http.MethodPut, + Path: URLPath{"v2", "send_leave", req.RoomID, req.EventID}, + Authenticate: true, + RequestJSON: req.Event, + }) + return +} + +type URLPath []any + +func (fup URLPath) FullPath() []any { + return append([]any{"_matrix", "federation"}, []any(fup)...) +} + +type KeyURLPath []any + +func (fkup KeyURLPath) FullPath() []any { + return append([]any{"_matrix", "key"}, []any(fkup)...) +} + +type RequestParams struct { + ServerName string + Method string + Path mautrix.PrefixableURLPath + Query url.Values + Authenticate bool + RequestJSON any + + ResponseJSON any + DontReadBody bool +} + +func (c *Client) MakeRequest(ctx context.Context, serverName string, authenticate bool, method string, path mautrix.PrefixableURLPath, reqJSON, respJSON any) error { + _, _, err := c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: method, + Path: path, + Authenticate: authenticate, + RequestJSON: reqJSON, + ResponseJSON: respJSON, + }) + return err +} + +func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]byte, *http.Response, error) { + req, err := c.compileRequest(ctx, params) + if err != nil { + return nil, nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "request error", + WrappedError: err, + } + } + if !params.DontReadBody { + defer resp.Body.Close() + } + var body []byte + if resp.StatusCode >= 300 { + body, err = mautrix.ParseErrorResponse(req, resp) + return body, resp, err + } else if params.ResponseJSON != nil || !params.DontReadBody { + if resp.ContentLength > c.ResponseSizeLimit { + return body, resp, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "not reading response", + WrappedError: fmt.Errorf("%w (%.2f MiB)", mautrix.ErrResponseTooLong, float64(resp.ContentLength)/1024/1024), + } + } + body, err = io.ReadAll(io.LimitReader(resp.Body, c.ResponseSizeLimit+1)) + if err == nil && len(body) > int(c.ResponseSizeLimit) { + err = mautrix.ErrBodyReadReachedLimit + } + if err != nil { + return body, resp, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "failed to read response body", + WrappedError: err, + } + } + if params.ResponseJSON != nil { + err = json.Unmarshal(body, params.ResponseJSON) + if err != nil { + return body, resp, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "failed to unmarshal response JSON", + ResponseBody: string(body), + WrappedError: err, + } + } + } + } + return body, resp, nil +} + +func (c *Client) compileRequest(ctx context.Context, params RequestParams) (*http.Request, error) { + reqURL := mautrix.BuildURL(&url.URL{ + Scheme: "matrix-federation", + Host: params.ServerName, + }, params.Path.FullPath()...) + reqURL.RawQuery = params.Query.Encode() + var reqJSON json.RawMessage + var reqBody io.Reader + if params.RequestJSON != nil { + var err error + reqJSON, err = json.Marshal(params.RequestJSON) + if err != nil { + return nil, mautrix.HTTPError{ + Message: "failed to marshal JSON", + WrappedError: err, + } + } + reqBody = bytes.NewReader(reqJSON) + } + req, err := http.NewRequestWithContext(ctx, params.Method, reqURL.String(), reqBody) + if err != nil { + return nil, mautrix.HTTPError{ + Message: "failed to create request", + WrappedError: err, + } + } + req.Header.Set("User-Agent", c.UserAgent) + if params.Authenticate { + if c.ServerName == "" || c.Key == nil { + return nil, mautrix.HTTPError{ + Message: "client not configured for authentication", + } + } + auth, err := (&signableRequest{ + Method: req.Method, + URI: reqURL.RequestURI(), + Origin: c.ServerName, + Destination: params.ServerName, + Content: reqJSON, + }).Sign(c.Key) + if err != nil { + return nil, mautrix.HTTPError{ + Message: "failed to sign request", + WrappedError: err, + } + } + req.Header.Set("Authorization", auth) + } + return req, nil +} + +type signableRequest struct { + Method string `json:"method"` + URI string `json:"uri"` + Origin string `json:"origin"` + Destination string `json:"destination"` + Content json.RawMessage `json:"content,omitempty"` +} + +func (r *signableRequest) Verify(key id.SigningKey, sig string) error { + message, err := canonicaljson.Marshal(r) + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + return signutil.VerifyJSONCanonical(key, sig, message) +} + +func (r *signableRequest) Sign(key *SigningKey) (string, error) { + sig, err := key.SignJSON(r) + if err != nil { + return "", err + } + return XMatrixAuth{ + Origin: r.Origin, + Destination: r.Destination, + KeyID: key.ID, + Signature: sig, + }.String(), nil +} diff --git a/mautrix-patched/federation/client_test.go b/mautrix-patched/federation/client_test.go new file mode 100644 index 00000000..785bae2e --- /dev/null +++ b/mautrix-patched/federation/client_test.go @@ -0,0 +1,25 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.mau.fi/util/exhttp" + + "maunium.net/go/mautrix/federation" +) + +func TestClient_Version(t *testing.T) { + cli := federation.NewClient("", nil, nil, exhttp.SensibleClientSettings) + cli.AllowIP = nil + resp, err := cli.Version(context.TODO(), "maunium.net") + require.NoError(t, err) + require.Equal(t, "Synapse", resp.Server.Name) +} diff --git a/mautrix-patched/federation/context.go b/mautrix-patched/federation/context.go new file mode 100644 index 00000000..eedb2dc1 --- /dev/null +++ b/mautrix-patched/federation/context.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "context" + "net/http" +) + +type contextKey int + +const ( + contextKeyIPPort contextKey = iota + contextKeyDestinationServer + contextKeyOriginServer +) + +func DestinationServerNameFromRequest(r *http.Request) string { + return DestinationServerName(r.Context()) +} + +func DestinationServerName(ctx context.Context) string { + if dest, ok := ctx.Value(contextKeyDestinationServer).(string); ok { + return dest + } + return "" +} + +func OriginServerNameFromRequest(r *http.Request) string { + return OriginServerName(r.Context()) +} + +func OriginServerName(ctx context.Context) string { + if origin, ok := ctx.Value(contextKeyOriginServer).(string); ok { + return origin + } + return "" +} diff --git a/mautrix-patched/federation/eventauth/eventauth.go b/mautrix-patched/federation/eventauth/eventauth.go new file mode 100644 index 00000000..27d1e166 --- /dev/null +++ b/mautrix-patched/federation/eventauth/eventauth.go @@ -0,0 +1,851 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package eventauth + +import ( + "encoding/json" + "encoding/json/jsontext" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "go.mau.fi/util/exgjson" + "go.mau.fi/util/exstrings" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +type AuthFailError struct { + Index string + Message string + Wrapped error +} + +func (afe AuthFailError) Error() string { + if afe.Message != "" { + return fmt.Sprintf("fail %s: %s", afe.Index, afe.Message) + } else if afe.Wrapped != nil { + return fmt.Sprintf("fail %s: %s", afe.Index, afe.Wrapped.Error()) + } + return fmt.Sprintf("fail %s", afe.Index) +} + +func (afe AuthFailError) Unwrap() error { + return afe.Wrapped +} + +var mFederatePath = exgjson.Path("m.federate") + +var ( + ErrCreateHasPrevEvents = AuthFailError{Index: "1.1", Message: "m.room.create event has prev_events"} + ErrCreateHasRoomID = AuthFailError{Index: "1.2", Message: "m.room.create event has room_id set"} + ErrRoomIDDoesntMatchSender = AuthFailError{Index: "1.2", Message: "room ID server doesn't match sender server"} + ErrUnknownRoomVersion = AuthFailError{Index: "1.3", Wrapped: id.ErrUnknownRoomVersion} + ErrInvalidAdditionalCreators = AuthFailError{Index: "1.4", Message: "m.room.create event has invalid additional_creators"} + ErrMissingCreator = AuthFailError{Index: "1.4", Message: "m.room.create event is missing creator field"} + + ErrInvalidRoomIDLength = AuthFailError{Index: "2", Message: "room ID length is invalid"} + ErrFailedToGetCreateEvent = AuthFailError{Index: "2", Message: "failed to get m.room.create event"} + ErrCreateEventNotFound = AuthFailError{Index: "2", Message: "m.room.create event not found using room ID as event ID"} + ErrRejectedCreateEvent = AuthFailError{Index: "2", Message: "m.room.create event was rejected"} + + ErrFailedToGetAuthEvents = AuthFailError{Index: "3", Message: "failed to get auth events"} + ErrFailedToParsePowerLevels = AuthFailError{Index: "?", Message: "failed to parse power levels"} + ErrDuplicateAuthEvent = AuthFailError{Index: "3.1", Message: "duplicate type/state key pair in auth events"} + ErrNonStateAuthEvent = AuthFailError{Index: "3.2", Message: "non-state event in auth events"} + ErrMissingAuthEvent = AuthFailError{Index: "3.2", Message: "missing auth event"} + ErrUnexpectedAuthEvent = AuthFailError{Index: "3.2", Message: "unexpected type/state key pair in auth events"} + ErrNoCreateEvent = AuthFailError{Index: "3.2", Message: "no m.room.create event found in auth events"} + ErrRejectedAuthEvent = AuthFailError{Index: "3.3", Message: "auth event was rejected"} + ErrMismatchingRoomIDInAuthEvent = AuthFailError{Index: "3.4", Message: "auth event room ID does not match event room ID"} + + ErrFederationDisabled = AuthFailError{Index: "4", Message: "federation is disabled for this room"} + + ErrMemberNotState = AuthFailError{Index: "5.1", Message: "m.room.member event is not a state event"} + ErrNotSignedByAuthoriser = AuthFailError{Index: "5.2", Message: "m.room.member event is not signed by server of join_authorised_via_users_server"} + ErrCantJoinOtherUser = AuthFailError{Index: "5.3.2", Message: "can't send join event with different state key"} + ErrCantJoinBanned = AuthFailError{Index: "5.3.3", Message: "user is banned from the room"} + ErrAuthoriserCantInvite = AuthFailError{Index: "5.3.5.2", Message: "authoriser doesn't have sufficient power level to invite"} + ErrAuthoriserNotInRoom = AuthFailError{Index: "5.3.5.2", Message: "authoriser isn't a member of the room"} + ErrCantJoinWithoutInvite = AuthFailError{Index: "5.3.7", Message: "can't join invite-only room without invite"} + ErrInvalidJoinRule = AuthFailError{Index: "5.3.7", Message: "invalid join rule in room"} + ErrThirdPartyInviteBanned = AuthFailError{Index: "5.4.1.1", Message: "third party invite target user is banned"} + ErrThirdPartyInviteMissingFields = AuthFailError{Index: "5.4.1.3", Message: "third party invite is missing mxid or token fields"} + ErrThirdPartyInviteMXIDMismatch = AuthFailError{Index: "5.4.1.4", Message: "mxid in signed third party invite doesn't match event state key"} + ErrThirdPartyInviteNotFound = AuthFailError{Index: "5.4.1.5", Message: "matching m.room.third_party_invite event not found in auth events"} + ErrThirdPartyInviteSenderMismatch = AuthFailError{Index: "5.4.1.6", Message: "sender of third party invite doesn't match sender of member event"} + ErrThirdPartyInviteNotSigned = AuthFailError{Index: "5.4.1.8", Message: "no valid signatures found for third party invite"} + ErrInviterNotInRoom = AuthFailError{Index: "5.4.2", Message: "inviter's membership is not join"} + ErrInviteTargetAlreadyInRoom = AuthFailError{Index: "5.4.3", Message: "invite target user is already in the room"} + ErrInviteTargetBanned = AuthFailError{Index: "5.4.3", Message: "invite target user is banned"} + ErrInsufficientPermissionForInvite = AuthFailError{Index: "5.4.5", Message: "inviter does not have sufficient permission to send invites"} + ErrCantLeaveWithoutBeingInRoom = AuthFailError{Index: "5.5.1", Message: "can't leave room without being in it"} + ErrCantKickWithoutBeingInRoom = AuthFailError{Index: "5.5.2", Message: "can't kick another user without being in the room"} + ErrInsufficientPermissionForUnban = AuthFailError{Index: "5.5.3", Message: "sender does not have sufficient permission to unban users"} + ErrInsufficientPermissionForKick = AuthFailError{Index: "5.5.5", Message: "sender does not have sufficient permission to kick the user"} + ErrCantBanWithoutBeingInRoom = AuthFailError{Index: "5.6.1", Message: "can't ban another user without being in the room"} + ErrInsufficientPermissionForBan = AuthFailError{Index: "5.6.3", Message: "sender does not have sufficient permission to ban the user"} + ErrNotKnockableRoom = AuthFailError{Index: "5.7.1", Message: "join rule doesn't allow knocking"} + ErrCantKnockOtherUser = AuthFailError{Index: "5.7.1", Message: "can't send knock event with different state key"} + ErrCantKnockWhileInRoom = AuthFailError{Index: "5.7.2", Message: "can't knock while joined, invited or banned"} + ErrUnknownMembership = AuthFailError{Index: "5.8", Message: "unknown membership in m.room.member event"} + + ErrNotInRoom = AuthFailError{Index: "6", Message: "sender is not a member of the room"} + + ErrInsufficientPowerForThirdPartyInvite = AuthFailError{Index: "7.1", Message: "sender does not have sufficient power level to send third party invite"} + + ErrInsufficientPowerLevel = AuthFailError{Index: "8", Message: "sender does not have sufficient power level to send event"} + + ErrMismatchingPrivateStateKey = AuthFailError{Index: "9", Message: "state keys starting with @ must match sender user ID"} + + ErrTopLevelPLNotInteger = AuthFailError{Index: "10.1", Message: "invalid type for top-level power level field"} + ErrPLNotInteger = AuthFailError{Index: "10.2", Message: "invalid type for power level"} + ErrInvalidUserIDInPL = AuthFailError{Index: "10.3", Message: "invalid user ID in power levels"} + ErrUserPLNotInteger = AuthFailError{Index: "10.3", Message: "invalid type for user power level"} + ErrCreatorInPowerLevels = AuthFailError{Index: "10.4", Message: "room creators must not be specified in power levels"} + ErrInvalidPowerChange = AuthFailError{Index: "10.x", Message: "illegal power level change"} + ErrInvalidUserPowerChange = AuthFailError{Index: "10.9", Message: "illegal power level change"} +) + +func isRejected(evt *pdu.PDU) bool { + return evt.InternalMeta.Rejected +} + +type GetEventsFunc = func(ids []id.EventID) ([]*pdu.PDU, error) + +func Authorize(roomVersion id.RoomVersion, evt *pdu.PDU, getEvents GetEventsFunc, getKey pdu.GetKeyFunc) error { + if evt.Type == event.StateCreate.Type { + // 1. If type is m.room.create: + return authorizeCreate(roomVersion, evt) + } + var createEvt *pdu.PDU + if roomVersion.RoomIDIsCreateEventID() { + // 2. If the event’s room_id is not an event ID for an accepted (not rejected) m.room.create event, + // with the sigil ! instead of $, reject. + if len(evt.RoomID) != 44 { + return fmt.Errorf("%w (%d)", ErrInvalidRoomIDLength, len(evt.RoomID)) + } else if createEvts, err := getEvents([]id.EventID{id.EventID("$" + evt.RoomID[1:])}); err != nil { + return fmt.Errorf("%w: %w", ErrFailedToGetCreateEvent, err) + } else if len(createEvts) != 1 { + return fmt.Errorf("%w (%s)", ErrCreateEventNotFound, evt.RoomID) + } else if isRejected(createEvts[0]) { + return ErrRejectedCreateEvent + } else { + createEvt = createEvts[0] + } + } + authEvents, err := getEvents(evt.AuthEvents) + if err != nil { + return fmt.Errorf("%w: %w", ErrFailedToGetAuthEvents, err) + } + expectedAuthEvents := evt.AuthEventSelection(roomVersion) + deduplicator := make(map[pdu.StateKey]id.EventID, len(expectedAuthEvents)) + // 3. Considering the event’s auth_events: + for i, ae := range authEvents { + authEvtID := evt.AuthEvents[i] + if ae == nil { + return fmt.Errorf("%w (%s)", ErrMissingAuthEvent, authEvtID) + } else if ae.StateKey == nil { + // This approximately falls under rule 3.2. + return fmt.Errorf("%w (%s)", ErrNonStateAuthEvent, authEvtID) + } + key := pdu.StateKey{Type: ae.Type, StateKey: *ae.StateKey} + if prevEvtID, alreadyFound := deduplicator[key]; alreadyFound { + // 3.1. If there are duplicate entries for a given type and state_key pair, reject. + return fmt.Errorf("%w for %s/%s: found %s and %s", ErrDuplicateAuthEvent, ae.Type, *ae.StateKey, prevEvtID, authEvtID) + } else if !expectedAuthEvents.Has(key) { + // 3.2. If there are entries whose type and state_key don’t match those specified by + // the auth events selection algorithm described in the server specification, reject. + return fmt.Errorf("%w: found %s with key %s/%s", ErrUnexpectedAuthEvent, authEvtID, ae.Type, *ae.StateKey) + } else if isRejected(ae) { + // 3.3. If there are entries which were themselves rejected under the checks performed on receipt of a PDU, reject. + return fmt.Errorf("%w (%s)", ErrRejectedAuthEvent, authEvtID) + } else if ae.RoomID != evt.RoomID { + // 3.4. If any event in auth_events has a room_id which does not match that of the event being authorised, reject. + return fmt.Errorf("%w (%s)", ErrMismatchingRoomIDInAuthEvent, authEvtID) + } else { + deduplicator[key] = authEvtID + } + if ae.Type == event.StateCreate.Type { + if createEvt == nil { + createEvt = ae + } else { + // Duplicates are prevented by deduplicator, AuthEventSelection also won't allow a create event at all for v12+ + panic(fmt.Errorf("impossible case: multiple create events found in auth events")) + } + } + } + if createEvt == nil { + // This comes either from auth_events or room_id depending on the room version. + // The checks above make sure it's from the right source. + return ErrNoCreateEvent + } + if federateVal := gjson.GetBytes(createEvt.Content, mFederatePath); federateVal.Type == gjson.False && createEvt.Sender.Homeserver() != evt.Sender.Homeserver() { + // 4. If the content of the m.room.create event in the room state has the property m.federate set to false, + // and the sender domain of the event does not match the sender domain of the create event, reject. + return ErrFederationDisabled + } + if evt.Type == event.StateMember.Type { + // 5. If type is m.room.member: + return authorizeMember(roomVersion, evt, createEvt, authEvents, getKey) + } + senderMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, evt.Sender.String(), "membership", "leave")) + if senderMembership != event.MembershipJoin { + // 6. If the sender’s current membership state is not join, reject. + return ErrNotInRoom + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + senderPL := powerLevels.GetUserLevel(evt.Sender) + if evt.Type == event.StateThirdPartyInvite.Type { + // 7.1. Allow if and only if sender’s current power level is greater than or equal to the invite level. + if senderPL >= powerLevels.Invite() { + return nil + } + return ErrInsufficientPowerForThirdPartyInvite + } + typeClass := event.MessageEventType + if evt.StateKey != nil { + typeClass = event.StateEventType + } + evtLevel := powerLevels.GetEventLevel(event.Type{Type: evt.Type, Class: typeClass}) + if evtLevel > senderPL { + // 8. If the event type’s required power level is greater than the sender’s power level, reject. + return fmt.Errorf("%w (%d > %d)", ErrInsufficientPowerLevel, evtLevel, senderPL) + } + + if evt.StateKey != nil && strings.HasPrefix(*evt.StateKey, "@") && *evt.StateKey != evt.Sender.String() { + // 9. If the event has a state_key that starts with an @ and does not match the sender, reject. + return ErrMismatchingPrivateStateKey + } + + if evt.Type == event.StatePowerLevels.Type { + // 10. If type is m.room.power_levels: + return authorizePowerLevels(roomVersion, evt, createEvt, authEvents) + } + + // 11. Otherwise, allow. + return nil +} + +var ErrUserIDNotAString = errors.New("not a string") +var ErrUserIDNotValid = errors.New("not a valid user ID") + +func isValidUserID(roomVersion id.RoomVersion, userID gjson.Result) error { + if userID.Type != gjson.String { + return ErrUserIDNotAString + } + // In a future room version, user IDs will have stricter validation + _, _, err := id.UserID(userID.Str).Parse() + if err != nil { + return ErrUserIDNotValid + } + return nil +} + +func authorizeCreate(roomVersion id.RoomVersion, evt *pdu.PDU) error { + if len(evt.PrevEvents) > 0 { + // 1.1. If it has any prev_events, reject. + return ErrCreateHasPrevEvents + } + if roomVersion.RoomIDIsCreateEventID() { + if evt.RoomID != "" { + // 1.2. If the event has a room_id, reject. + return ErrCreateHasRoomID + } + } else { + _, _, server := id.ParseCommonIdentifier(evt.RoomID) + if server == "" || server != evt.Sender.Homeserver() { + // 1.2. (v11 and below) If the domain of the room_id does not match the domain of the sender, reject. + return ErrRoomIDDoesntMatchSender + } + } + if !roomVersion.IsKnown() { + // 1.3. If content.room_version is present and is not a recognised version, reject. + return fmt.Errorf("%w %s", ErrUnknownRoomVersion, roomVersion) + } + if roomVersion.PrivilegedRoomCreators() { + additionalCreators := gjson.GetBytes(evt.Content, "additional_creators") + if additionalCreators.Exists() { + if !additionalCreators.IsArray() { + return fmt.Errorf("%w: not an array", ErrInvalidAdditionalCreators) + } + for i, item := range additionalCreators.Array() { + // 1.4. If additional_creators is present in content and is not an array of strings + // where each string passes the same user ID validation applied to sender, reject. + if err := isValidUserID(roomVersion, item); err != nil { + return fmt.Errorf("%w: item #%d %w", ErrInvalidAdditionalCreators, i+1, err) + } + } + } + } + if roomVersion.CreatorInContent() { + // 1.4. (v10 and below) If content has no creator property, reject. + if !gjson.GetBytes(evt.Content, "creator").Exists() { + return ErrMissingCreator + } + } + // 1.5. Otherwise, allow. + return nil +} + +func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEvents []*pdu.PDU, getKey pdu.GetKeyFunc) error { + membership := event.Membership(gjson.GetBytes(evt.Content, "membership").Str) + if evt.StateKey == nil { + // 5.1. If there is no state_key property, or no membership property in content, reject. + return ErrMemberNotState + } + authorizedVia := id.UserID(gjson.GetBytes(evt.Content, "join_authorised_via_users_server").Str) + if authorizedVia != "" { + homeserver := authorizedVia.Homeserver() + err := evt.VerifySignature(roomVersion, homeserver, getKey) + if err != nil { + // 5.2. If content has a join_authorised_via_users_server key: + // 5.2.1. If the event is not validly signed by the homeserver of the user ID denoted by the key, reject. + return fmt.Errorf("%w: %w", ErrNotSignedByAuthoriser, err) + } + } + targetPrevMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, *evt.StateKey, "membership", "leave")) + senderMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, evt.Sender.String(), "membership", "leave")) + switch membership { + case event.MembershipJoin: + createEvtID, err := createEvt.GetEventID(roomVersion) + if err != nil { + return fmt.Errorf("failed to get create event ID: %w", err) + } + creator := createEvt.Sender.String() + if roomVersion.CreatorInContent() { + creator = gjson.GetBytes(createEvt.Content, "creator").Str + } + if len(evt.PrevEvents) == 1 && + len(evt.AuthEvents) <= 1 && + evt.PrevEvents[0] == createEvtID && + *evt.StateKey == creator { + // 5.3.1. If the only previous event is an m.room.create and the state_key is the sender of the m.room.create, allow. + return nil + } + // Spec wart: this would make more sense before the check above. + // Now you can set anyone as the sender of the first join. + if evt.Sender.String() != *evt.StateKey { + // 5.3.2. If the sender does not match state_key, reject. + return ErrCantJoinOtherUser + } + + if senderMembership == event.MembershipBan { + // 5.3.3. If the sender is banned, reject. + return ErrCantJoinBanned + } + + joinRule := event.JoinRule(findEventAndReadString(authEvents, event.StateJoinRules.Type, "", "join_rule", "invite")) + switch joinRule { + case event.JoinRuleKnock: + if !roomVersion.Knocks() { + return ErrInvalidJoinRule + } + fallthrough + case event.JoinRuleInvite: + // 5.3.4. If the join_rule is invite or knock then allow if membership state is invite or join. + if targetPrevMembership == event.MembershipJoin || targetPrevMembership == event.MembershipInvite { + return nil + } + return ErrCantJoinWithoutInvite + case event.JoinRuleKnockRestricted: + if !roomVersion.KnockRestricted() { + return ErrInvalidJoinRule + } + fallthrough + case event.JoinRuleRestricted: + if joinRule == event.JoinRuleRestricted && !roomVersion.RestrictedJoins() { + return ErrInvalidJoinRule + } + if targetPrevMembership == event.MembershipJoin || targetPrevMembership == event.MembershipInvite { + // 5.3.5.1. If membership state is join or invite, allow. + return nil + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + if powerLevels.GetUserLevel(authorizedVia) < powerLevels.Invite() { + // 5.3.5.2. If the join_authorised_via_users_server key in content is not a user with sufficient permission to invite other users, reject. + return ErrAuthoriserCantInvite + } + authorizerMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, authorizedVia.String(), "membership", string(event.MembershipLeave))) + if authorizerMembership != event.MembershipJoin { + return ErrAuthoriserNotInRoom + } + // 5.3.5.3. Otherwise, allow. + return nil + case event.JoinRulePublic: + // 5.3.6. If the join_rule is public, allow. + return nil + default: + // 5.3.7. Otherwise, reject. + return ErrInvalidJoinRule + } + case event.MembershipInvite: + tpiVal := gjson.GetBytes(evt.Content, "third_party_invite") + if tpiVal.Exists() { + if targetPrevMembership == event.MembershipBan { + return ErrThirdPartyInviteBanned + } + signed := tpiVal.Get("signed") + mxid := signed.Get("mxid").Str + token := signed.Get("token").Str + if mxid == "" || token == "" { + // 5.4.1.2. If content.third_party_invite does not have a signed property, reject. + // 5.4.1.3. If signed does not have mxid and token properties, reject. + return ErrThirdPartyInviteMissingFields + } + if mxid != *evt.StateKey { + // 5.4.1.4. If mxid does not match state_key, reject. + return ErrThirdPartyInviteMXIDMismatch + } + tpiEvt := findEvent(authEvents, event.StateThirdPartyInvite.Type, token) + if tpiEvt == nil { + // 5.4.1.5. If there is no m.room.third_party_invite event in the current room state with state_key matching token, reject. + return ErrThirdPartyInviteNotFound + } + if tpiEvt.Sender != evt.Sender { + // 5.4.1.6. If sender does not match sender of the m.room.third_party_invite, reject. + return ErrThirdPartyInviteSenderMismatch + } + var keys []id.Ed25519 + const ed25519Base64Len = 43 + oldPubKey := gjson.GetBytes(evt.Content, "public_key.token") + if oldPubKey.Type == gjson.String && len(oldPubKey.Str) == ed25519Base64Len { + keys = append(keys, id.Ed25519(oldPubKey.Str)) + } + gjson.GetBytes(evt.Content, "public_keys").ForEach(func(key, value gjson.Result) bool { + if key.Type != gjson.Number { + return false + } + if value.Type == gjson.String && len(value.Str) == ed25519Base64Len { + keys = append(keys, id.Ed25519(value.Str)) + } + return true + }) + rawSigned := jsontext.Value(exstrings.UnsafeBytes(signed.Str)) + var validated bool + for _, key := range keys { + if signutil.VerifyJSONAny(key, rawSigned) == nil { + validated = true + } + } + if validated { + // 4.4.1.7. If any signature in signed matches any public key in the m.room.third_party_invite event, allow. + return nil + } + // 4.4.1.8. Otherwise, reject. + return ErrThirdPartyInviteNotSigned + } + if senderMembership != event.MembershipJoin { + // 5.4.2. If the sender’s current membership state is not join, reject. + return ErrInviterNotInRoom + } + // 5.4.3. If target user’s current membership state is join or ban, reject. + if targetPrevMembership == event.MembershipJoin { + return ErrInviteTargetAlreadyInRoom + } else if targetPrevMembership == event.MembershipBan { + return ErrInviteTargetBanned + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + if powerLevels.GetUserLevel(evt.Sender) >= powerLevels.Invite() { + // 5.4.4. If the sender’s power level is greater than or equal to the invite level, allow. + return nil + } + // 5.4.5. Otherwise, reject. + return ErrInsufficientPermissionForInvite + case event.MembershipLeave: + if evt.Sender.String() == *evt.StateKey { + // 5.5.1. If the sender matches state_key, allow if and only if that user’s current membership state is invite, join, or knock. + if senderMembership == event.MembershipInvite || + senderMembership == event.MembershipJoin || + (senderMembership == event.MembershipKnock && roomVersion.Knocks()) { + return nil + } + return ErrCantLeaveWithoutBeingInRoom + } + if senderMembership != event.MembershipJoin { + // 5.5.2. If the sender’s current membership state is not join, reject. + return ErrCantKickWithoutBeingInRoom + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + senderLevel := powerLevels.GetUserLevel(evt.Sender) + if targetPrevMembership == event.MembershipBan && senderLevel < powerLevels.Ban() { + // 5.5.3. If the target user’s current membership state is ban, and the sender’s power level is less than the ban level, reject. + return ErrInsufficientPermissionForUnban + } + if senderLevel >= powerLevels.Kick() && powerLevels.GetUserLevel(id.UserID(*evt.StateKey)) < senderLevel { + // 5.5.4. If the sender’s power level is greater than or equal to the kick level, and the target user’s power level is less than the sender’s power level, allow. + return nil + } + // TODO separate errors for < kick and < target user level? + // 5.5.5. Otherwise, reject. + return ErrInsufficientPermissionForKick + case event.MembershipBan: + if senderMembership != event.MembershipJoin { + // 5.6.1. If the sender’s current membership state is not join, reject. + return ErrCantBanWithoutBeingInRoom + } + powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) + if err != nil { + return err + } + senderLevel := powerLevels.GetUserLevel(evt.Sender) + if senderLevel >= powerLevels.Ban() && powerLevels.GetUserLevel(id.UserID(*evt.StateKey)) < senderLevel { + // 5.6.2. If the sender’s power level is greater than or equal to the ban level, and the target user’s power level is less than the sender’s power level, allow. + return nil + } + // 5.6.3. Otherwise, reject. + return ErrInsufficientPermissionForBan + case event.MembershipKnock: + joinRule := event.JoinRule(findEventAndReadString(authEvents, event.StateJoinRules.Type, "", "join_rule", "invite")) + validKnockRule := roomVersion.Knocks() && joinRule == event.JoinRuleKnock + validKnockRestrictedRule := roomVersion.KnockRestricted() && joinRule == event.JoinRuleKnockRestricted + if !validKnockRule && !validKnockRestrictedRule { + // 5.7.1. If the join_rule is anything other than knock or knock_restricted, reject. + return ErrNotKnockableRoom + } + if evt.Sender.String() != *evt.StateKey { + // 5.7.2. If the sender does not match state_key, reject. + return ErrCantKnockOtherUser + } + if senderMembership != event.MembershipBan && senderMembership != event.MembershipInvite && senderMembership != event.MembershipJoin { + // 5.7.3. If the sender’s current membership is not ban, invite, or join, allow. + return nil + } + // 5.7.4. Otherwise, reject. + return ErrCantKnockWhileInRoom + default: + // 5.8. Otherwise, the membership is unknown. Reject. + return ErrUnknownMembership + } +} + +func authorizePowerLevels(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEvents []*pdu.PDU) error { + if roomVersion.ValidatePowerLevelInts() { + for _, key := range []string{"users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"} { + res := gjson.GetBytes(evt.Content, key) + if !res.Exists() { + continue + } + if parseIntWithVersion(roomVersion, res) == nil { + // 10.1. If any of the properties users_default, events_default, state_default, ban, redact, kick, or invite in content are present and not an integer, reject. + return fmt.Errorf("%w %s", ErrTopLevelPLNotInteger, key) + } + } + for _, key := range []string{"events", "notifications"} { + obj := gjson.GetBytes(evt.Content, key) + if !obj.Exists() { + continue + } + // 10.2. If either of the properties events or notifications in content are present and not an object [...], reject. + if !obj.IsObject() { + return fmt.Errorf("%w %s", ErrTopLevelPLNotInteger, key) + } + var err error + // 10.2. [...] are not an object with values that are integers, reject. + obj.ForEach(func(innerKey, value gjson.Result) bool { + if parseIntWithVersion(roomVersion, value) == nil { + err = fmt.Errorf("%w %s.%s", ErrPLNotInteger, key, innerKey.Str) + return false + } + return true + }) + if err != nil { + return err + } + } + } + var creators []id.UserID + if roomVersion.PrivilegedRoomCreators() { + creators = append(creators, createEvt.Sender) + gjson.GetBytes(createEvt.Content, "additional_creators").ForEach(func(key, value gjson.Result) bool { + creators = append(creators, id.UserID(value.Str)) + return true + }) + } + users := gjson.GetBytes(evt.Content, "users") + if users.Exists() { + if !users.IsObject() { + // 10.3. If the users property in content is not an object [...], reject. + return fmt.Errorf("%w users", ErrTopLevelPLNotInteger) + } + var err error + users.ForEach(func(key, value gjson.Result) bool { + if validatorErr := isValidUserID(roomVersion, key); validatorErr != nil { + // 10.3. [...] is not an object with keys that are valid user IDs [...], reject. + err = fmt.Errorf("%w: %q %w", ErrInvalidUserIDInPL, key.Str, validatorErr) + return false + } + if parseIntWithVersion(roomVersion, value) == nil { + // 10.3. [...] is not an object [...] with values that are integers, reject. + err = fmt.Errorf("%w %q", ErrUserPLNotInteger, key.Str) + return false + } + // creators is only filled if the room version has privileged room creators + if slices.Contains(creators, id.UserID(key.Str)) { + // 10.4. If the users property in content contains the sender of the m.room.create event or any of + // the additional_creators array (if present) from the content of the m.room.create event, reject. + err = fmt.Errorf("%w: %q", ErrCreatorInPowerLevels, key.Str) + return false + } + return true + }) + if err != nil { + return err + } + } + oldPL := findEvent(authEvents, event.StatePowerLevels.Type, "") + if oldPL == nil { + // 10.5. If there is no previous m.room.power_levels event in the room, allow. + return nil + } + if slices.Contains(creators, evt.Sender) { + // Skip remaining checks for creators + return nil + } + senderPLPtr := parsePythonInt(gjson.GetBytes(oldPL.Content, exgjson.Path("users", evt.Sender.String()))) + if senderPLPtr == nil { + senderPLPtr = parsePythonInt(gjson.GetBytes(oldPL.Content, "users_default")) + if senderPLPtr == nil { + senderPLPtr = ptr.Ptr(0) + } + } + for _, key := range []string{"users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"} { + oldVal := gjson.GetBytes(oldPL.Content, key) + newVal := gjson.GetBytes(evt.Content, key) + if err := allowPowerChange(roomVersion, *senderPLPtr, key, oldVal, newVal); err != nil { + return err + } + } + if err := allowPowerChangeMap( + roomVersion, *senderPLPtr, "events", "", + gjson.GetBytes(oldPL.Content, "events"), + gjson.GetBytes(evt.Content, "events"), + ); err != nil { + return err + } + if err := allowPowerChangeMap( + roomVersion, *senderPLPtr, "notifications", "", + gjson.GetBytes(oldPL.Content, "notifications"), + gjson.GetBytes(evt.Content, "notifications"), + ); err != nil { + return err + } + if err := allowPowerChangeMap( + roomVersion, *senderPLPtr, "users", evt.Sender.String(), + gjson.GetBytes(oldPL.Content, "users"), + gjson.GetBytes(evt.Content, "users"), + ); err != nil { + return err + } + return nil +} + +func allowPowerChangeMap(roomVersion id.RoomVersion, maxVal int, path, ownID string, old, new gjson.Result) (err error) { + old.ForEach(func(key, value gjson.Result) bool { + newVal := new.Get(exgjson.Path(key.Str)) + err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, value, newVal) + if err == nil && ownID != "" && key.Str != ownID { + parsedOldVal := parseIntWithVersion(roomVersion, value) + parsedNewVal := parseIntWithVersion(roomVersion, newVal) + if *parsedOldVal >= maxVal && *parsedOldVal != *parsedNewVal { + err = fmt.Errorf("%w: can't change users.%s from %s to %s with sender level %d", ErrInvalidUserPowerChange, key.Str, stringifyForError(value), stringifyForError(newVal), maxVal) + } + } + return err == nil + }) + if err != nil { + return + } + new.ForEach(func(key, value gjson.Result) bool { + err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, old.Get(exgjson.Path(key.Str)), value) + return err == nil + }) + return +} + +func allowPowerChange(roomVersion id.RoomVersion, maxVal int, path string, old, new gjson.Result) error { + oldVal := parseIntWithVersion(roomVersion, old) + newVal := parseIntWithVersion(roomVersion, new) + if oldVal == nil { + if newVal == nil || *newVal <= maxVal { + return nil + } + } else if newVal == nil { + if *oldVal <= maxVal { + return nil + } + } else if *oldVal == *newVal || (*oldVal <= maxVal && *newVal <= maxVal) { + return nil + } + return fmt.Errorf("%w can't change %s from %s to %s with sender level %d", ErrInvalidPowerChange, path, stringifyForError(old), stringifyForError(new), maxVal) +} + +func stringifyForError(val gjson.Result) string { + if !val.Exists() { + return "null" + } + return val.Raw +} + +func findEvent(events []*pdu.PDU, evtType, stateKey string) *pdu.PDU { + for _, evt := range events { + if evt.Type == evtType && *evt.StateKey == stateKey { + return evt + } + } + return nil +} + +func findEventAndReadData[T any](events []*pdu.PDU, evtType, stateKey string, reader func(evt *pdu.PDU) T) T { + return reader(findEvent(events, evtType, stateKey)) +} + +func findEventAndReadString(events []*pdu.PDU, evtType, stateKey, fieldPath, defVal string) string { + return findEventAndReadData(events, evtType, stateKey, func(evt *pdu.PDU) string { + if evt == nil { + return defVal + } + res := gjson.GetBytes(evt.Content, fieldPath) + if res.Type != gjson.String { + return defVal + } + return res.Str + }) +} + +func getPowerLevels(roomVersion id.RoomVersion, authEvents []*pdu.PDU, createEvt *pdu.PDU) (*event.PowerLevelsEventContent, error) { + var err error + powerLevels := findEventAndReadData(authEvents, event.StatePowerLevels.Type, "", func(evt *pdu.PDU) *event.PowerLevelsEventContent { + if evt == nil { + return nil + } + content := evt.Content + out := &event.PowerLevelsEventContent{} + if !roomVersion.ValidatePowerLevelInts() { + safeParsePowerLevels(content, out) + } else { + err = json.Unmarshal(content, out) + } + return out + }) + if err != nil { + // This should never happen thanks to safeParsePowerLevels for v1-9 and strict validation in v10+ + return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) + } + if roomVersion.PrivilegedRoomCreators() { + if powerLevels == nil { + powerLevels = &event.PowerLevelsEventContent{} + } + powerLevels.CreateEvent, err = createEvt.ToClientEvent(roomVersion) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) + } + err = powerLevels.CreateEvent.Content.ParseRaw(powerLevels.CreateEvent.Type) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) + } + } else if powerLevels == nil { + powerLevels = &event.PowerLevelsEventContent{ + Users: map[id.UserID]int{ + createEvt.Sender: 100, + }, + } + } + return powerLevels, nil +} + +func parseIntWithVersion(roomVersion id.RoomVersion, val gjson.Result) *int { + if roomVersion.ValidatePowerLevelInts() { + if val.Type != gjson.Number { + return nil + } + return ptr.Ptr(int(val.Int())) + } + return parsePythonInt(val) +} + +func parsePythonInt(val gjson.Result) *int { + switch val.Type { + case gjson.True: + return ptr.Ptr(1) + case gjson.False: + return ptr.Ptr(0) + case gjson.Number: + return ptr.Ptr(int(val.Int())) + case gjson.String: + // strconv.Atoi accepts signs as well as leading zeroes, so we just need to trim spaces beforehand + num, err := strconv.Atoi(strings.TrimSpace(val.Str)) + if err != nil { + return nil + } + return &num + default: + // Python int() doesn't accept nulls, arrays or dicts + return nil + } +} + +func safeParsePowerLevels(content jsontext.Value, into *event.PowerLevelsEventContent) { + *into = event.PowerLevelsEventContent{ + Users: make(map[id.UserID]int), + UsersDefault: ptr.Val(parsePythonInt(gjson.GetBytes(content, "users_default"))), + Events: make(map[string]int), + EventsDefault: ptr.Val(parsePythonInt(gjson.GetBytes(content, "events_default"))), + Notifications: nil, // irrelevant for event auth + StateDefaultPtr: parsePythonInt(gjson.GetBytes(content, "state_default")), + InvitePtr: parsePythonInt(gjson.GetBytes(content, "invite")), + KickPtr: parsePythonInt(gjson.GetBytes(content, "kick")), + BanPtr: parsePythonInt(gjson.GetBytes(content, "ban")), + RedactPtr: parsePythonInt(gjson.GetBytes(content, "redact")), + } + gjson.GetBytes(content, "events").ForEach(func(key, value gjson.Result) bool { + if key.Type != gjson.String { + return false + } + val := parsePythonInt(value) + if val != nil { + into.Events[key.Str] = *val + } + return true + }) + gjson.GetBytes(content, "users").ForEach(func(key, value gjson.Result) bool { + if key.Type != gjson.String { + return false + } + val := parsePythonInt(value) + if val == nil { + return false + } + userID := id.UserID(key.Str) + if _, _, err := userID.Parse(); err != nil { + return false + } + into.Users[userID] = *val + return true + }) +} diff --git a/mautrix-patched/federation/eventauth/eventauth_internal_test.go b/mautrix-patched/federation/eventauth/eventauth_internal_test.go new file mode 100644 index 00000000..d316f3c8 --- /dev/null +++ b/mautrix-patched/federation/eventauth/eventauth_internal_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package eventauth + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type pythonIntTest struct { + Name string + Input string + Expected int64 +} + +var pythonIntTests = []pythonIntTest{ + {"True", `true`, 1}, + {"False", `false`, 0}, + {"SmallFloat", `3.1415`, 3}, + {"SmallFloatRoundDown", `10.999999999999999`, 10}, + {"SmallFloatRoundUp", `10.9999999999999999`, 11}, + {"BigFloatRoundDown", `1000000.9999999999`, 1000000}, + {"BigFloatRoundUp", `1000000.99999999999`, 1000001}, + {"BigFloatPrecisionError", `9007199254740993.0`, 9007199254740992}, + {"BigFloatPrecisionError2", `9007199254740993.123`, 9007199254740994}, + {"Int64", `9223372036854775807`, 9223372036854775807}, + {"Int64String", `"9223372036854775807"`, 9223372036854775807}, + {"String", `"123"`, 123}, + {"InvalidFloatInString", `"123.456"`, 0}, + {"StringWithPlusSign", `"+123"`, 123}, + {"StringWithMinusSign", `"-123"`, -123}, + {"StringWithSpaces", `" 123 "`, 123}, + {"StringWithSpacesAndSign", `" -123 "`, -123}, + //{"StringWithUnderscores", `"123_456"`, 123456}, + //{"StringWithUnderscores", `"123_456"`, 123456}, + {"InvalidStringWithTrailingUnderscore", `"123_456_"`, 0}, + {"InvalidStringWithMultipleUnderscores", `"123__456"`, 0}, + {"InvalidStringWithLeadingUnderscore", `"_123_456"`, 0}, + {"InvalidStringWithUnderscoreAfterSign", `"+_123_456"`, 0}, + {"InvalidStringWithUnderscoreAfterSpace", `" _123_456"`, 0}, + //{"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456}, +} + +func TestParsePythonInt(t *testing.T) { + for _, test := range pythonIntTests { + t.Run(test.Name, func(t *testing.T) { + output := parsePythonInt(gjson.Parse(test.Input)) + if strings.HasPrefix(test.Name, "Invalid") { + assert.Nil(t, output) + } else { + require.NotNil(t, output) + assert.Equal(t, int(test.Expected), *output) + } + }) + } +} diff --git a/mautrix-patched/federation/eventauth/eventauth_test.go b/mautrix-patched/federation/eventauth/eventauth_test.go new file mode 100644 index 00000000..e3c5cd76 --- /dev/null +++ b/mautrix-patched/federation/eventauth/eventauth_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package eventauth_test + +import ( + "embed" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/federation/eventauth" + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +//go:embed *.jsonl +var data embed.FS + +type eventMap map[id.EventID]*pdu.PDU + +func (em eventMap) Get(ids []id.EventID) ([]*pdu.PDU, error) { + output := make([]*pdu.PDU, len(ids)) + for i, evtID := range ids { + output[i] = em[evtID] + } + return output, nil +} + +func GetKey(serverName string, keyID id.KeyID, validUntilTS time.Time) (id.SigningKey, time.Time, error) { + return "", time.Time{}, nil +} + +func TestAuthorize(t *testing.T) { + files := exerrors.Must(data.ReadDir(".")) + for _, file := range files { + t.Run(file.Name(), func(t *testing.T) { + decoder := jsontext.NewDecoder(exerrors.Must(data.Open(file.Name()))) + events := make(eventMap) + var roomVersion *id.RoomVersion + for i := 1; ; i++ { + var evt *pdu.PDU + err := json.UnmarshalDecode(decoder, &evt) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if roomVersion == nil { + require.Equal(t, evt.Type, "m.room.create") + roomVersion = ptr.Ptr(id.RoomVersion(gjson.GetBytes(evt.Content, "room_version").Str)) + } + expectedEventID := gjson.GetBytes(evt.Unsigned, "event_id").Str + evtID, err := evt.GetEventID(*roomVersion) + require.NoError(t, err) + require.Equalf(t, id.EventID(expectedEventID), evtID, "Event ID mismatch for event #%d", i) + + // TODO allow redacted events + assert.True(t, evt.VerifyContentHash(), i) + + events[evtID] = evt + err = eventauth.Authorize(*roomVersion, evt, events.Get, GetKey) + if err != nil { + evt.InternalMeta.Rejected = true + } + // TODO allow testing intentionally rejected events + assert.NoErrorf(t, err, "Failed to authorize event #%d / %s of type %s", i, evtID, evt.Type) + } + }) + } + +} diff --git a/mautrix-patched/federation/eventauth/testroom-v12-success.jsonl b/mautrix-patched/federation/eventauth/testroom-v12-success.jsonl new file mode 100644 index 00000000..2b751de3 --- /dev/null +++ b/mautrix-patched/federation/eventauth/testroom-v12-success.jsonl @@ -0,0 +1,21 @@ +{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age_ts":1756071567186,"event_id":"$lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54"}} +{"auth_events":[],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":2,"hashes":{"sha256":"MXmgq0e4J9CdIP0IVKVvueFhOb+ndlsXpeyI+6l/2FI"},"origin_server_ts":1756071567259,"prev_events":["$lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"xMgRzyRg9VM9XCKpfFJA+MrYoI68b8PIddKpMTcxz/fDzmGSHEy6Ta2b59VxiX3NoJe2CigkDZ3+jVsQoZYIBA"}},"state_key":"@tulir:maunium.net","type":"m.room.member","unsigned":{"age_ts":1756071567259,"event_id":"$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"}} +{"auth_events":["$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":150},"events_default":0,"historical":100,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001},"users_default":0},"depth":3,"hashes":{"sha256":"/JzQNBNqJ/i8vwj6xESDaD5EDdOqB4l/LmKlvAVl5jY"},"origin_server_ts":1756071567319,"prev_events":["$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"W3N3X/enja+lumXw3uz66/wT9oczoxrmHbAD5/RF069cX4wkCtqtDd61VWPkSGmKxdV1jurgbCqSX6+Q9/t3AA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"age_ts":1756071567319,"event_id":"$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"join_rule":"invite"},"depth":4,"hashes":{"sha256":"GBu5AySj75ZXlOLd65mB03KueFKOHNgvtg2o/LUnLyI"},"origin_server_ts":1756071567320,"prev_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"XqWEnFREo2PhRnaebGjNzdHdtD691BtCQKkLnpKd8P3lVDewDt8OkCbDSk/Uzh9rDtzwWEsbsIoKSYuOm+G6CA"}},"state_key":"","type":"m.room.join_rules","unsigned":{"age_ts":1756071567320,"event_id":"$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"history_visibility":"shared"},"depth":5,"hashes":{"sha256":"niDi5vG2akQm0f5pm0aoCYXqmWjXRfmP1ulr/ZEPm/k"},"origin_server_ts":1756071567320,"prev_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"PTIrNke/fc9+ObKAl/K0PGZfmpe8dwREyoA5rXffOXWdRHSaBifn9UIiJUqd68Bzvrv4RcADTR/ci7lUquFBBw"}},"state_key":"","type":"m.room.history_visibility","unsigned":{"age_ts":1756071567320,"event_id":"$Wmy3G9yxl9ArVg5ZsdeIDPxBsNAdgseuvHoqHTZ2vug"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"guest_access":"can_join"},"depth":6,"hashes":{"sha256":"sZ9QqsId4oarFF724esTohXuRxDNnaXPl+QmTDG60dw"},"origin_server_ts":1756071567321,"prev_events":["$Wmy3G9yxl9ArVg5ZsdeIDPxBsNAdgseuvHoqHTZ2vug"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"Eh2P9/hl38wfZx2AQbeS5VCD4wldXPfeP2sQsJsLtfmdwFV74jrlGVBaKIkaYcXY4eA08iDp8HW5jqttZqKKDg"}},"state_key":"","type":"m.room.guest_access","unsigned":{"age_ts":1756071567321,"event_id":"$hYVRH7F4P5mB5IqvBDDU5aXY7pYGG0ApstrryiVPKmQ"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"name":"event auth test v12"},"depth":7,"hashes":{"sha256":"tjwPo38yR+23Was6SbxLvPMhNx44DaXLhF3rKgngepU"},"origin_server_ts":1756071567321,"prev_events":["$hYVRH7F4P5mB5IqvBDDU5aXY7pYGG0ApstrryiVPKmQ"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"q1rk0c5m8TJYE9tePsMaLeaigatNNbvaLRom0X8KiZY0EH+itujfA+/UnksvmPmMmThfAXWlFLx5u8tcuSVyCQ"}},"state_key":"","type":"m.room.name","unsigned":{"age_ts":1756071567321,"event_id":"$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"invite"},"depth":8,"hashes":{"sha256":"r5EBUZN/4LbVcMYwuffDcVV9G4OMHzAQuNbnjigL+OE"},"origin_server_ts":1756071567548,"prev_events":["$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"envs.net":{"ed25519:wuJyKT":"svB+uW4Tsj8/I+SYbLl+LPPjBlqxGNXE4wGyAxlP7vfyJtFf7Kn/19jx65wT9ebeCq5sTGlEDV4Fabwma9LhDA"},"maunium.net":{"ed25519:a_xxeS":"LBYMcdJVSNsLd6SmOgx5oOU/0xOeCl03o4g83VwJfHWlRuTT5l9+qlpNED28wY07uxoU9MgLgXXICJ0EezMBCg"}},"state_key":"@tulir:envs.net","type":"m.room.member","unsigned":{"age_ts":1756071567548,"event_id":"$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok","invite_room_state":[{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"},{"content":{"name":"event auth test v12"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.name"},{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age_ts":1756071567186}},{"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member"}]}} +{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":9,"hashes":{"sha256":"23rgMf7EGJcYt3Aj0qAFnmBWCxuU9Uk+ReidqtIJDKQ"},"origin_server_ts":1756071575986,"prev_events":["$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"p+Fm/uWO8VXJdCYvN/dVb8HF8W3t1sssNCBiOWbzAeuS3QqYjoMKHyixLuN1mOdnCyATv7SsHHmA4+cELRGdAA"}},"type":"m.room.message","unsigned":{"age_ts":1756071576002,"event_id":"$eZDCydRWSRnR5od0c7ahz2qSZQDHbl5g5PITT0OMC3E"}} +{"auth_events":["$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"join"},"depth":10,"hashes":{"sha256":"2kJPx2UsysNzTH8QGYHUKTO/05yetxKRlI0nKFeGbts"},"origin_server_ts":1756071578631,"prev_events":["$eZDCydRWSRnR5od0c7ahz2qSZQDHbl5g5PITT0OMC3E"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"Wuzxkh8nEEX6mdJzph6Bt5ku+odFkEg2RIpFAAirOqxgcrwRaz42PsJni3YbfzH1qneF+iWQ/neA+up6jLXFBw"}},"state_key":"@tulir:envs.net","type":"m.room.member","unsigned":{"age":6,"event_id":"$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","replaces_state":"$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"invite"},"depth":11,"hashes":{"sha256":"dRE11R2hBfFalQ5tIJdyaElUIiSE5aCKMddjek4wR3c"},"origin_server_ts":1756071591449,"prev_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"/Mi4kX40fbR+V3DCJJGI/9L3Uuf8y5Un8LHlCQv1T0O5gnFZGQ3qN6rRNaZ1Kdh3QJBU6H4NTfnd+SVj3wt3CQ"},"matrix.org":{"ed25519:a_RXGa":"ZeLm/oxP3/Cds/uCL2FaZpgjUp0vTDBlGG6YVFNl76yIVlyIKKQKR6BSVw2u5KC5Mu9M1f+0lDmLGQujR5NkBg"}},"state_key":"@tulir:matrix.org","type":"m.room.member","unsigned":{"event_id":"$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4","invite_room_state":[{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"},{"content":{"name":"event auth test v12"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.name"},{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age":11553}},{"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"join"},"sender":"@tulir:envs.net","state_key":"@tulir:envs.net","type":"m.room.member"}]}} +{"auth_events":["$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"join"},"depth":12,"hashes":{"sha256":"hR/fRIyFkxKnA1XNxIB+NKC0VR0vHs82EDgydhmmZXU"},"origin_server_ts":1756071609205,"prev_events":["$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"keWbZHm+LPW22XWxb14Att4Ae4GVc6XAKAnxFRr3hxhrgEhsnMcxUx7fjqlA1dk3As6kjLKdekcyCef+AQCXCA"}},"state_key":"@tulir:matrix.org","type":"m.room.member","unsigned":{"age":19,"event_id":"$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","replaces_state":"$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":150},"events_default":0,"historical":100,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":13,"hashes":{"sha256":"30Wuw3xIbA8+eXQBa4nFDKcyHtMbKPBYhLW1zft9/fE"},"origin_server_ts":1756071643928,"prev_events":["$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"x6Y4uViq4nK8LVPqtMLdCuvNET2bnjxYTgiKuEe1JYfwB4jPBnPuqvrt1O9oaanMpcRWbnuiZjckq4bUlRZ7Cw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg","replaces_state":"$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"}} +{"auth_events":["$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg","$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw"],"content":{"name":"event auth test v12!"},"depth":14,"hashes":{"sha256":"WT0gz7KYXvbdNruRavqIi9Hhul3rxCdZ+YY9yMGN+Fw"},"origin_server_ts":1756071656988,"prev_events":["$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"bSplmqtXVhO2Z3hJ8JMQ/u7G2Wmg6yt7SwhYXObRQJfthekddJN152ME4YJIwy7YD8WFq7EkyB/NMyQoliYyCg"}},"state_key":"","type":"m.room.name","unsigned":{"event_id":"$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI","replaces_state":"$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":9001},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":15,"hashes":{"sha256":"FnGzbcXc8YOiB1TY33QunGA17Axoyuu3sdVOj5Z408o"},"origin_server_ts":1756071804931,"prev_events":["$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"uyTUsPR+CzCtlevzB5+sNXvmfbPSp6u7RZC4E4TLVsj45+pjmMRswAvuHP9PT2+Tkl6Hu8ZPigsXgbKZtR35Aw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw","replaces_state":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":16,"hashes":{"sha256":"KcivsiLesdnUnKX23Akk3OJEJFGRSY0g4H+p7XIThnw"},"origin_server_ts":1756071812688,"prev_events":["$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"cAK8dO2AVZklY9te5aVKbF1jR/eB5rzeNOXfYPjBLf+aSAS4Z6R2aMKW6hJB9PqRS4S+UZc24DTrjUjnvMzeBA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU","replaces_state":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"}} +{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"body":"meow #2","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":17,"hashes":{"sha256":"SgH9fOXGdbdqpRfYmoz1t29+gX8Ze4ThSoj6klZs3Og"},"origin_server_ts":1756247476706,"prev_events":["$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"SMYK7zP3SaQOKhzZUKUBVCKwffYqi3PFAlPM34kRJtmfGU3KZXNBT0zi+veXDMmxkMunqhF2RTHBD6joa0kBAQ"}},"type":"m.room.message","unsigned":{"event_id":"$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"}} +{"auth_events":["$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:beeper.com":8999,"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":18,"hashes":{"sha256":"l8Mw3VKn/Bvntg7bZ8uh5J8M2IBZM93Xg7hsdaSci8s"},"origin_server_ts":1758918656341,"prev_events":["$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"cg5LP0WuTnVB5jFhNERLLU5b+EhmyACiOq6cp3gKJnZsTAb1yajcgJybLWKrc8QQqxPa7hPnskRBgt4OBTFNAA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0","replaces_state":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"}} +{"auth_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"content":{"avatar_url":"mxc://beeper.com/eBdwbHbllONoAySQkXLjbfFM","displayname":"tulir[b]","membership":"invite"},"depth":19,"hashes":{"sha256":"KpmaRUQnJju8TIDMPzakitUIKOWJxTvULpFB3a1CGgc"},"origin_server_ts":1758918665952,"prev_events":["$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"beeper.com":{"ed25519:a_zgvp":"mzI9rPkQ1xHl2/G5Yrn0qmIRt5OyjPNqRwilPfH4jmr1tP+vv3vC0m4mph/MCOq8S1c/DQaCWSpdOX1uWfchBQ"},"matrix.org":{"ed25519:a_RXGa":"kEdfr8DjxC/bdvGYxnniFI/pxDWeyG73OjG/Gu1uoHLhjdtAT/vEQ6lotJJs214/KX5eAaQWobE9qtMvtPwMDw"}},"state_key":"@tulir:beeper.com","type":"m.room.member","unsigned":{"event_id":"$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro","invite_room_state":[{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age":11553}},{"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"join"},"sender":"@tulir:matrix.org","state_key":"@tulir:matrix.org","type":"m.room.member"},{"content":{"name":"event auth test v12!"},"sender":"@tulir:matrix.org","state_key":"","type":"m.room.name"},{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"}]}} +{"auth_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro","$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"content":{"avatar_url":"mxc://beeper.com/eBdwbHbllONoAySQkXLjbfFM","displayname":"tulir[b]","membership":"join"},"depth":20,"hashes":{"sha256":"bmaHSm4mYPNBNlUfFsauSTxLrUH4CUSAKYvr1v76qkk"},"origin_server_ts":1758918670276,"prev_events":["$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:beeper.com","signatures":{"beeper.com":{"ed25519:a_zgvp":"D3cz3m15m89a3G4c5yWOBCjhtSeI5IxBfQKt5XOr9a44QHyc3nwjjvIJaRrKNcS5tLUJwZ2IpVzjlrpbPHpxDA"}},"state_key":"@tulir:beeper.com","type":"m.room.member","unsigned":{"age":6,"event_id":"$_hayW1Y0HRWp3VEGZZbsMf0Ncg9x6n0ikveD0lbCwMw","replaces_state":"$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro"}} +{"auth_events":["$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0","$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:beeper.com":9000,"@tulir:envs.net":9001,"@tulir:matrix.org":8999},"users_default":0},"depth":21,"hashes":{"sha256":"xCj9vszChHiXba9DaPzhtF79Tphek3pRViMp36DOurU"},"origin_server_ts":1758918689485,"prev_events":["$_hayW1Y0HRWp3VEGZZbsMf0Ncg9x6n0ikveD0lbCwMw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"odkrWD30+ObeYtagULtECB/QmGae7qNy66nmJMWYXiQMYUJw/GMzSmgAiLAWfVYlfD3aEvMb/CBdrhL07tfSBw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$di6cI89-GxX8-Wbx-0T69l4wg6TUWITRkjWXzG7EBqo","replaces_state":"$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"}} diff --git a/mautrix-patched/federation/httpclient.go b/mautrix-patched/federation/httpclient.go new file mode 100644 index 00000000..8375aab9 --- /dev/null +++ b/mautrix-patched/federation/httpclient.go @@ -0,0 +1,116 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "syscall" + + "go.mau.fi/util/exhttp" +) + +// ServerResolvingTransport is an http.RoundTripper that resolves Matrix server names before sending requests. +// It only allows requests using the "matrix-federation" scheme. +type ServerResolvingTransport struct { + ResolveOpts *ResolveServerNameOpts + Transport *http.Transport + DialFunc exhttp.DialerFunc + + cache ResolutionCache + + resolveLocks map[string]*sync.Mutex + resolveLocksLock sync.Mutex +} + +func NewServerResolvingTransport(cache ResolutionCache, dialer exhttp.DialerFunc, settings exhttp.ClientSettings) *ServerResolvingTransport { + if cache == nil { + cache = NewInMemoryCache() + } + srt := &ServerResolvingTransport{ + resolveLocks: make(map[string]*sync.Mutex), + cache: cache, + DialFunc: dialer, + } + srt.Transport = settings.WithDial(srt.DialContext).Configure(&http.Transport{}) + return srt +} + +var _ http.RoundTripper = (*ServerResolvingTransport)(nil) + +func (srt *ServerResolvingTransport) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + addrs, ok := ctx.Value(contextKeyIPPort).([]string) + if !ok { + return nil, fmt.Errorf("no IP:port in context") + } + return srt.DialFunc(ctx, network, addrs[0]) +} + +func (srt *ServerResolvingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.Scheme != "matrix-federation" { + return nil, fmt.Errorf("unsupported scheme: %s", request.URL.Scheme) + } + resolved, err := srt.resolve(request.Context(), request.URL.Host) + if err != nil { + return nil, fmt.Errorf("failed to resolve server name: %w", err) + } + request = request.WithContext(context.WithValue(request.Context(), contextKeyIPPort, resolved.IPPort)) + request.URL.Scheme = "https" + request.URL.Host = resolved.HostHeader + request.Host = resolved.HostHeader + return srt.Transport.RoundTrip(request) +} + +func (srt *ServerResolvingTransport) resolve(ctx context.Context, serverName string) (*ResolvedServerName, error) { + srt.resolveLocksLock.Lock() + lock, ok := srt.resolveLocks[serverName] + if !ok { + lock = &sync.Mutex{} + srt.resolveLocks[serverName] = lock + } + srt.resolveLocksLock.Unlock() + + lock.Lock() + defer lock.Unlock() + res, err := srt.cache.LoadResolution(serverName) + if err != nil { + return nil, fmt.Errorf("failed to read cache: %w", err) + } else if res != nil { + return res, nil + } else if res, err = ResolveServerName(ctx, serverName, srt.ResolveOpts); err != nil { + return nil, err + } else { + srt.cache.StoreResolution(res) + return res, nil + } +} + +var ErrIPFiltered = errors.New("refusing to connect") + +func (c *Client) controlConn(_ context.Context, network, address string, _ syscall.RawConn) error { + switch network { + case "tcp4", "tcp6": + // ok + default: + return fmt.Errorf("unsupported network: %s", network) + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("invalid address %q: %w", address, err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("invalid IP address %q", host) + } else if c.AllowIP != nil && !c.AllowIP(ip) { + return fmt.Errorf("%w to %s", ErrIPFiltered, host) + } + return nil +} diff --git a/mautrix-patched/federation/keyserver.go b/mautrix-patched/federation/keyserver.go new file mode 100644 index 00000000..d32ba5cf --- /dev/null +++ b/mautrix-patched/federation/keyserver.go @@ -0,0 +1,205 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "go.mau.fi/util/requestlog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type ServerVersion struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ServerKeyProvider is an interface that returns private server keys for server key requests. +type ServerKeyProvider interface { + Get(r *http.Request) (serverName string, key *SigningKey) +} + +// StaticServerKey is an implementation of [ServerKeyProvider] that always returns the same server name and key. +type StaticServerKey struct { + ServerName string + Key *SigningKey +} + +func (ssk *StaticServerKey) Get(r *http.Request) (serverName string, key *SigningKey) { + return ssk.ServerName, ssk.Key +} + +// KeyServer implements a basic Matrix key server that can serve its own keys, plus the federation version endpoint. +// +// It does not implement querying keys of other servers, nor any other federation endpoints. +type KeyServer struct { + KeyProvider ServerKeyProvider + Version ServerVersion + WellKnownTarget string + OtherKeys KeyCache +} + +// Register registers the key server endpoints to the given router. +func (ks *KeyServer) Register(r *http.ServeMux, log zerolog.Logger) { + r.HandleFunc("GET /.well-known/matrix/server", ks.GetWellKnown) + r.HandleFunc("GET /_matrix/federation/v1/version", ks.GetServerVersion) + keyRouter := http.NewServeMux() + keyRouter.HandleFunc("GET /v2/server", ks.GetServerKey) + keyRouter.HandleFunc("GET /v2/query/{serverName}", ks.GetQueryKeys) + keyRouter.HandleFunc("POST /v2/query", ks.PostQueryKeys) + errorBodies := exhttp.ErrorBodies{ + NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), + MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), + } + r.Handle("/_matrix/key/", exhttp.ApplyMiddleware( + keyRouter, + exhttp.StripPrefix("/_matrix/key"), + hlog.NewHandler(log), + hlog.RequestIDHandler("request_id", "Request-Id"), + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + exhttp.HandleErrors(errorBodies), + )) +} + +// RespWellKnown is the response body for the `GET /.well-known/matrix/server` endpoint. +type RespWellKnown struct { + Server string `json:"m.server"` +} + +// GetWellKnown implements the `GET /.well-known/matrix/server` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#get_well-knownmatrixserver +func (ks *KeyServer) GetWellKnown(w http.ResponseWriter, r *http.Request) { + if ks.WellKnownTarget == "" { + mautrix.MNotFound.WithMessage("No well-known target set").Write(w) + } else { + exhttp.WriteJSONResponse(w, http.StatusOK, &RespWellKnown{Server: ks.WellKnownTarget}) + } +} + +// RespServerVersion is the response body for the `GET /_matrix/federation/v1/version` endpoint +type RespServerVersion struct { + Server ServerVersion `json:"server"` +} + +// GetServerVersion implements the `GET /_matrix/federation/v1/version` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#get_matrixfederationv1version +func (ks *KeyServer) GetServerVersion(w http.ResponseWriter, r *http.Request) { + exhttp.WriteJSONResponse(w, http.StatusOK, &RespServerVersion{Server: ks.Version}) +} + +// GetServerKey implements the `GET /_matrix/key/v2/server` endpoint. +// +// https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2server +func (ks *KeyServer) GetServerKey(w http.ResponseWriter, r *http.Request) { + domain, key := ks.KeyProvider.Get(r) + if key == nil { + mautrix.MNotFound.WithMessage("No signing key found for %q", r.Host).Write(w) + } else { + exhttp.WriteJSONResponse(w, http.StatusOK, key.GenerateKeyResponse(domain, nil)) + } +} + +// ReqQueryKeys is the request body for the `POST /_matrix/key/v2/query` endpoint +type ReqQueryKeys struct { + ServerKeys map[string]map[id.KeyID]QueryKeysCriteria `json:"server_keys"` +} + +type QueryKeysCriteria struct { + MinimumValidUntilTS jsontime.UnixMilli `json:"minimum_valid_until_ts"` +} + +// PostQueryKeysResponse is the response body for the `POST /_matrix/key/v2/query` endpoint +type PostQueryKeysResponse struct { + ServerKeys map[string]*ServerKeyResponse `json:"server_keys"` +} + +// PostQueryKeys implements the `POST /_matrix/key/v2/query` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#post_matrixkeyv2query +func (ks *KeyServer) PostQueryKeys(w http.ResponseWriter, r *http.Request) { + var req ReqQueryKeys + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + mautrix.MBadJSON.WithMessage("failed to parse request: %v", err).Write(w) + return + } + + resp := &PostQueryKeysResponse{ + ServerKeys: make(map[string]*ServerKeyResponse), + } + for serverName, keys := range req.ServerKeys { + domain, key := ks.KeyProvider.Get(r) + if domain != serverName { + continue + } + for keyID, criteria := range keys { + if key.ID == keyID && criteria.MinimumValidUntilTS.Before(time.Now().Add(24*time.Hour)) { + resp.ServerKeys[serverName] = key.GenerateKeyResponse(serverName, nil) + } + } + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} + +// GetQueryKeysResponse is the response body for the `GET /_matrix/key/v2/query/{serverName}` endpoint +type GetQueryKeysResponse struct { + ServerKeys []*ServerKeyResponse `json:"server_keys"` +} + +// GetQueryKeys implements the `GET /_matrix/key/v2/query/{serverName}` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2queryservername +func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { + serverName := r.PathValue("serverName") + minimumValidUntilTSString := r.URL.Query().Get("minimum_valid_until_ts") + minimumValidUntilTS, err := strconv.ParseInt(minimumValidUntilTSString, 10, 64) + if err != nil && minimumValidUntilTSString != "" { + mautrix.MInvalidParam.WithMessage("failed to parse ?minimum_valid_until_ts: %v", err).Write(w) + return + } else if time.UnixMilli(minimumValidUntilTS).After(time.Now().Add(24 * time.Hour)) { + mautrix.MInvalidParam.WithMessage("minimum_valid_until_ts may not be more than 24 hours in the future").Write(w) + return + } + resp := &GetQueryKeysResponse{ + ServerKeys: []*ServerKeyResponse{}, + } + domain, key := ks.KeyProvider.Get(r) + if domain == serverName { + if key != nil { + resp.ServerKeys = append(resp.ServerKeys, key.GenerateKeyResponse(serverName, nil)) + } + } else if ks.OtherKeys != nil { + otherKey, err := ks.OtherKeys.LoadKeys(serverName) + if err != nil { + mautrix.MUnknown.WithMessage("Failed to load keys from cache").Write(w) + return + } + if key != nil && domain != "" { + signature, err := key.SignJSON(otherKey) + if err == nil { + otherKey.Signatures[domain] = map[id.KeyID]string{ + key.ID: signature, + } + } + } + resp.ServerKeys = append(resp.ServerKeys, otherKey) + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) +} diff --git a/mautrix-patched/federation/media.go b/mautrix-patched/federation/media.go new file mode 100644 index 00000000..0f24dbb8 --- /dev/null +++ b/mautrix-patched/federation/media.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + + "maunium.net/go/mautrix" +) + +type FileMetadata struct { + // Header contains the multipart or HTTP response headers for the file. + // This is not parsed from the JSON metadata. + Header http.Header `json:"-"` + // JSON-parsed fields will be added once some are defined in the spec. +} + +type mediaPartReader struct { + respBody io.ReadCloser + part *multipart.Part +} + +func (mpr *mediaPartReader) Read(p []byte) (n int, err error) { + return mpr.part.Read(p) +} + +func (mpr *mediaPartReader) Close() error { + err1 := mpr.part.Close() + err2 := mpr.respBody.Close() + return cmp.Or(err1, err2) +} + +func (c *Client) DownloadMedia(ctx context.Context, serverName, mediaID string) (meta *FileMetadata, data io.ReadCloser, err error) { + _, resp, err := c.MakeFullRequest(ctx, RequestParams{ + ServerName: serverName, + Method: http.MethodGet, + Path: URLPath{"v1", "media", "download", mediaID}, + Authenticate: true, + DontReadBody: true, + }) + if err != nil { + return nil, nil, nil + } + defer func() { + if data == nil { + _ = resp.Body.Close() + } + }() + mimeType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse content type: %w", err) + } else if mimeType != "multipart/mixed" { + return nil, nil, fmt.Errorf("unexpected content type: %s", mimeType) + } else if params["boundary"] == "" { + return nil, nil, fmt.Errorf("missing boundary parameter in content type") + } + mr := multipart.NewReader(resp.Body, params["boundary"]) + part, err := mr.NextPart() + if err != nil { + return nil, nil, fmt.Errorf("failed to read metadata chunk: %w", err) + } else if !strings.HasPrefix(part.Header.Get("Content-Type"), "application/json") { + _ = part.Close() + return nil, nil, fmt.Errorf("unexpected content type for metadata chunk: %s", part.Header.Get("Content-Type")) + } + mbr := http.MaxBytesReader(nil, part, 64*1024) + err = json.NewDecoder(mbr).Decode(&meta) + _ = mbr.Close() + if err != nil { + return nil, nil, fmt.Errorf("failed to parse metadata: %w", err) + } + part, err = mr.NextPart() + if err != nil { + return nil, nil, fmt.Errorf("failed to read data chunk: %w", err) + } + redir := part.Header.Get("Location") + if redir != "" { + _ = resp.Body.Close() + _ = part.Close() + data, meta.Header, err = c.downloadMediaRedirect(ctx, redir) + return + } + meta.Header = http.Header(part.Header) + return meta, &mediaPartReader{ + respBody: resp.Body, + part: part, + }, nil +} + +func (c *Client) downloadMediaRedirect(ctx context.Context, url string) (io.ReadCloser, http.Header, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to prepare redirect request: %w", err) + } else if req.URL.Scheme != "https" { + return nil, nil, fmt.Errorf("non-https URL in redirect") + } + resp, err := c.ExtHTTP.Do(req) + if err != nil { + return nil, nil, mautrix.HTTPError{ + Request: req, + Response: resp, + + Message: "media redirect request error", + WrappedError: err, + } + } else if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, nil, fmt.Errorf("unexpected status code from redirect: %w", mautrix.HTTPError{ + Request: req, + Response: resp, + }) + } + return resp.Body, resp.Header, nil +} diff --git a/mautrix-patched/federation/pdu/auth.go b/mautrix-patched/federation/pdu/auth.go new file mode 100644 index 00000000..217dfadf --- /dev/null +++ b/mautrix-patched/federation/pdu/auth.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "slices" + + "github.com/tidwall/gjson" + "go.mau.fi/util/exgjson" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type StateKey struct { + Type string + StateKey string +} + +var thirdPartyInviteTokenPath = exgjson.Path("third_party_invite", "signed", "token") + +type AuthEventSelection []StateKey + +func (aes *AuthEventSelection) Add(evtType, stateKey string) { + key := StateKey{Type: evtType, StateKey: stateKey} + if !aes.Has(key) { + *aes = append(*aes, key) + } +} + +func (aes *AuthEventSelection) Has(key StateKey) bool { + return slices.Contains(*aes, key) +} + +func (pdu *PDU) AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSelection) { + if pdu.Type == event.StateCreate.Type && pdu.StateKey != nil { + return AuthEventSelection{} + } + keys = make(AuthEventSelection, 0, 3) + if !roomVersion.RoomIDIsCreateEventID() { + keys.Add(event.StateCreate.Type, "") + } + keys.Add(event.StatePowerLevels.Type, "") + keys.Add(event.StateMember.Type, pdu.Sender.String()) + if pdu.Type == event.StateMember.Type && pdu.StateKey != nil { + keys.Add(event.StateMember.Type, *pdu.StateKey) + membership := event.Membership(gjson.GetBytes(pdu.Content, "membership").Str) + if membership == event.MembershipJoin || membership == event.MembershipInvite || membership == event.MembershipKnock { + keys.Add(event.StateJoinRules.Type, "") + } + if membership == event.MembershipInvite { + thirdPartyInviteToken := gjson.GetBytes(pdu.Content, thirdPartyInviteTokenPath).Str + if thirdPartyInviteToken != "" { + keys.Add(event.StateThirdPartyInvite.Type, thirdPartyInviteToken) + } + } + if membership == event.MembershipJoin && roomVersion.RestrictedJoins() { + authorizedVia := gjson.GetBytes(pdu.Content, "join_authorised_via_users_server").Str + if authorizedVia != "" { + keys.Add(event.StateMember.Type, authorizedVia) + } + } + } + return +} diff --git a/mautrix-patched/federation/pdu/hash.go b/mautrix-patched/federation/pdu/hash.go new file mode 100644 index 00000000..67183825 --- /dev/null +++ b/mautrix-patched/federation/pdu/hash.go @@ -0,0 +1,119 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + + "github.com/tidwall/gjson" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/id" +) + +func (pdu *PDU) CalculateContentHash() ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + pduClone := pdu.Clone() + pduClone.Signatures = nil + pduClone.Unsigned = nil + pduClone.Hashes = nil + rawJSON, err := canonicaljson.Marshal(pduClone) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *PDU) FillContentHash() error { + if pdu == nil { + return ErrPDUIsNil + } else if pdu.Hashes != nil { + return nil + } else if hash, err := pdu.CalculateContentHash(); err != nil { + return err + } else { + pdu.Hashes = &Hashes{SHA256: hash[:]} + return nil + } +} + +func (pdu *PDU) VerifyContentHash() bool { + if pdu == nil || pdu.Hashes == nil { + return false + } + calculatedHash, err := pdu.CalculateContentHash() + if err != nil { + return false + } + return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) +} + +func (pdu *PDU) GetRoomID() (id.RoomID, error) { + if pdu == nil { + return "", ErrPDUIsNil + } else if pdu.Type != "m.room.create" { + return "", fmt.Errorf("room ID can only be calculated for m.room.create events") + } else if roomVersion := id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str); !roomVersion.RoomIDIsCreateEventID() { + return "", fmt.Errorf("room version %s does not use m.room.create event ID as room ID", roomVersion) + } else if evtID, err := pdu.calculateEventID(roomVersion, '!'); err != nil { + return "", fmt.Errorf("failed to calculate event ID: %w", err) + } else { + return id.RoomID(evtID), nil + } +} + +var UseInternalMetaForGetEventID = false + +func (pdu *PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { + if UseInternalMetaForGetEventID && pdu.InternalMeta.EventID != "" { + return pdu.InternalMeta.EventID, nil + } + return pdu.calculateEventID(roomVersion, '$') +} + +func (pdu *PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { + if err := pdu.FillContentHash(); err != nil { + return [32]byte{}, err + } + } + rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *PDU) calculateEventID(roomVersion id.RoomVersion, prefix byte) (id.EventID, error) { + referenceHash, err := pdu.GetReferenceHash(roomVersion) + if err != nil { + return "", err + } + eventID := make([]byte, 44) + eventID[0] = prefix + switch roomVersion.EventIDFormat() { + case id.EventIDFormatCustom: + return "", fmt.Errorf("*pdu.PDU can only be used for room v3+") + case id.EventIDFormatBase64: + base64.RawStdEncoding.Encode(eventID[1:], referenceHash[:]) + case id.EventIDFormatURLSafeBase64: + base64.RawURLEncoding.Encode(eventID[1:], referenceHash[:]) + default: + return "", fmt.Errorf("unknown event ID format %v", roomVersion.EventIDFormat()) + } + return id.EventID(eventID), nil +} diff --git a/mautrix-patched/federation/pdu/hash_test.go b/mautrix-patched/federation/pdu/hash_test.go new file mode 100644 index 00000000..17417e12 --- /dev/null +++ b/mautrix-patched/federation/pdu/hash_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" +) + +func TestPDU_CalculateContentHash(t *testing.T) { + for _, test := range testPDUs { + if test.redacted { + continue + } + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + contentHash := exerrors.Must(parsed.CalculateContentHash()) + assert.Equal( + t, + base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), + base64.RawStdEncoding.EncodeToString(contentHash[:]), + ) + }) + } +} + +func TestPDU_VerifyContentHash(t *testing.T) { + for _, test := range testPDUs { + if test.redacted { + continue + } + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + assert.True(t, parsed.VerifyContentHash()) + }) + } +} + +func TestPDU_GetEventID(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + gotEventID := exerrors.Must(parsePDU(test.pdu).GetEventID(test.roomVersion)) + assert.Equal(t, test.eventID, gotEventID) + }) + } +} diff --git a/mautrix-patched/federation/pdu/pdu.go b/mautrix-patched/federation/pdu/pdu.go new file mode 100644 index 00000000..c42b929a --- /dev/null +++ b/mautrix-patched/federation/pdu/pdu.go @@ -0,0 +1,141 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "crypto/ed25519" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "strings" + "time" + + "github.com/tidwall/gjson" + "go.mau.fi/util/jsonbytes" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// GetKeyFunc is a callback for retrieving the key corresponding to a given key ID when verifying the signature of a PDU. +// +// The input time is the timestamp of the event. The function should attempt to fetch a key that is +// valid at or after this time, but if that is not possible, the latest available key should be +// returned without an error. The verify function will do its own validity checking based on the +// returned valid until timestamp. +type GetKeyFunc = func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) + +type AnyPDU interface { + GetRoomID() (id.RoomID, error) + GetEventID(roomVersion id.RoomVersion) (id.EventID, error) + GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) + CalculateContentHash() ([32]byte, error) + FillContentHash() error + VerifyContentHash() bool + Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error + VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error + ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) + AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSelection) +} + +var ( + _ AnyPDU = (*PDU)(nil) + _ AnyPDU = (*RoomV1PDU)(nil) +) + +type InternalMeta struct { + EventID id.EventID `json:"event_id,omitempty"` + Rejected bool `json:"rejected,omitempty"` + Extra map[string]any `json:",unknown"` +} + +type PDU struct { + AuthEvents []id.EventID `json:"auth_events"` + Content jsontext.Value `json:"content"` + Depth int64 `json:"depth"` + Hashes *Hashes `json:"hashes,omitzero"` + OriginServerTS int64 `json:"origin_server_ts"` + PrevEvents []id.EventID `json:"prev_events"` + Redacts *id.EventID `json:"redacts,omitzero"` + RoomID id.RoomID `json:"room_id,omitzero"` // not present for room v12+ create events + Sender id.UserID `json:"sender"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitzero"` + StateKey *string `json:"state_key,omitzero"` + Type string `json:"type"` + Unsigned jsontext.Value `json:"unsigned,omitzero"` + InternalMeta InternalMeta `json:"-"` + + Unknown jsontext.Value `json:",unknown"` + + // Deprecated legacy fields + DeprecatedPrevState jsontext.Value `json:"prev_state,omitzero"` + DeprecatedOrigin jsontext.Value `json:"origin,omitzero"` + DeprecatedMembership jsontext.Value `json:"membership,omitzero"` +} + +var ErrPDUIsNil = errors.New("PDU is nil") + +type Hashes struct { + SHA256 jsonbytes.UnpaddedBytes `json:"sha256"` + + Unknown jsontext.Value `json:",unknown"` +} + +func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { + if pdu.Type == "m.room.create" && roomVersion == "" { + roomVersion = id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str) + } + evtType := event.Type{Type: pdu.Type, Class: event.MessageEventType} + if pdu.StateKey != nil { + evtType.Class = event.StateEventType + } + eventID, err := pdu.GetEventID(roomVersion) + if err != nil { + return nil, err + } + roomID := pdu.RoomID + if pdu.Type == "m.room.create" && roomVersion.RoomIDIsCreateEventID() { + roomID = id.RoomID(strings.Replace(string(eventID), "$", "!", 1)) + } + evt := &event.Event{ + StateKey: pdu.StateKey, + Sender: pdu.Sender, + Type: evtType, + Timestamp: pdu.OriginServerTS, + ID: eventID, + RoomID: roomID, + Redacts: ptr.Val(pdu.Redacts), + } + err = json.Unmarshal(pdu.Content, &evt.Content) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal content: %w", err) + } + if len(pdu.Unsigned) > 0 { + err = json.Unmarshal(pdu.Unsigned, &evt.Unsigned) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal unsigned content: %w", err) + } + } + return evt, nil +} + +func (pdu *PDU) AddSignature(serverName string, keyID id.KeyID, signature string) { + if signature == "" { + return + } + if pdu.Signatures == nil { + pdu.Signatures = make(map[string]map[id.KeyID]string) + } + if _, ok := pdu.Signatures[serverName]; !ok { + pdu.Signatures[serverName] = make(map[id.KeyID]string) + } + pdu.Signatures[serverName][keyID] = signature +} diff --git a/mautrix-patched/federation/pdu/pdu_test.go b/mautrix-patched/federation/pdu/pdu_test.go new file mode 100644 index 00000000..59d7c3a6 --- /dev/null +++ b/mautrix-patched/federation/pdu/pdu_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "encoding/json/v2" + "time" + + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +type serverKey struct { + key id.SigningKey + validUntilTS time.Time +} + +type serverDetails struct { + serverName string + keys map[id.KeyID]serverKey +} + +func (sd serverDetails) getKey(serverName string, keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + if serverName != sd.serverName { + return "", time.Time{}, nil + } + key, ok := sd.keys[keyID] + if ok { + return key.key, key.validUntilTS, nil + } + return "", time.Time{}, nil +} + +var mauniumNet = serverDetails{ + serverName: "maunium.net", + keys: map[id.KeyID]serverKey{ + "ed25519:a_xxeS": { + key: "lVt/CC3tv74OH6xTph2JrUmeRj/j+1q0HVa0Xf4QlCg", + validUntilTS: time.Now(), + }, + }, +} +var envsNet = serverDetails{ + serverName: "envs.net", + keys: map[id.KeyID]serverKey{ + "ed25519:a_zIqy": { + key: "vCUcZpt9hUn0aabfh/9GP/6sZvXcydww8DUstPHdJm0", + validUntilTS: time.UnixMilli(1722360538068), + }, + "ed25519:wuJyKT": { + key: "xbE1QssgomL4wCSlyMYF5/7KxVyM4HPwAbNa+nFFnx0", + validUntilTS: time.Now(), + }, + }, +} +var matrixOrg = serverDetails{ + serverName: "matrix.org", + keys: map[id.KeyID]serverKey{ + "ed25519:auto": { + key: "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw", + validUntilTS: time.UnixMilli(1576767829750), + }, + "ed25519:a_RXGa": { + key: "l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ", + validUntilTS: time.Now(), + }, + }, +} +var continuwuityOrg = serverDetails{ + serverName: "continuwuity.org", + keys: map[id.KeyID]serverKey{ + "ed25519:PwHlNsFu": { + key: "8eNx2s0zWW+heKAmOH5zKv/nCPkEpraDJfGHxDu6hFI", + validUntilTS: time.Now(), + }, + }, +} +var novaAstraltechOrg = serverDetails{ + serverName: "nova.astraltech.org", + keys: map[id.KeyID]serverKey{ + "ed25519:a_afpo": { + key: "O1Y9GWuKo9xkuzuQef6gROxtTgxxAbS3WPNghPYXF3o", + validUntilTS: time.Now(), + }, + }, +} + +type testPDU struct { + name string + pdu string + eventID id.EventID + roomVersion id.RoomVersion + redacted bool + serverDetails +} + +var roomV4MessageTestPDU = testPDU{ + name: "m.room.message in v4 room", + pdu: `{"auth_events":["$OB87jNemaIVDHAfu0-pa_cP7OPFXUXCbFpjYVi8gll4","$RaWbTF9wQfGQgUpe1S13wzICtGTB2PNKRHUNHu9IO1c","$ZmEWOXw6cC4Rd1wTdY5OzeLJVzjhrkxFPwwKE4gguGk"],"content":{"body":"the last one is saying it shouldn't have effects","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":13103,"hashes":{"sha256":"c2wb8qMlvzIPCP1Wd+eYZ4BRgnGYxS97dR1UlJjVMeg"},"origin_server_ts":1752875275263,"prev_events":["$-7_BMI3BXwj3ayoxiJvraJxYWTKwjiQ6sh7CW_Brvj0"],"room_id":"!JiiOHXrIUCtcOJsZCa:matrix.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"99TAqHpBkUEtgCraXsVXogmf/hnijPbgbG9eACtA+mbix3Y6gURI4QGQgcX/NhcE3pJQZ/YDjmbuvCnKvEccAA"}},"unsigned":{"age_ts":1752875275281}}`, + eventID: "$Jo_lmFR-e6lzrimzCA7DevIn2OwhuQYmd9xkcJBoqAA", + roomVersion: id.RoomV4, + serverDetails: mauniumNet, +} + +var roomV12MessageTestPDU = testPDU{ + name: "m.room.message in v12 room", + pdu: `{"auth_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA","$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":122,"hashes":{"sha256":"IQ0zlc+PXeEs6R3JvRkW3xTPV3zlGKSSd3x07KXGjzs"},"origin_server_ts":1755384351627,"prev_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir_test:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"0GDMddL2k7gF4V1VU8sL3wTfhAIzAu5iVH5jeavZ2VEg3J9/tHLWXAOn2tzkLaMRWl0/XpINT2YlH/rd2U21Ag"}},"unsigned":{"age_ts":1755384351627}}`, + eventID: "$xmP-wZfpannuHG-Akogi6c4YvqxChMtdyYbUMGOrMWc", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +} + +var testPDUs = []testPDU{roomV4MessageTestPDU, { + name: "m.room.message in v5 room", + pdu: `{"auth_events":["$hp0ImHqYgHTRbLeWKPeTeFmxdb5SdMJN9cfmTrTk7d0","$KAj7X7tnJbR9qYYMWJSw-1g414_KlPptbbkZm7_kUtg","$V-2ShOwZYhA_nxMijaf3lqFgIJgzE2UMeFPtOLnoBYM"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":2248,"hashes":{"sha256":"kV+JuLbWXJ2r6PjHT3wt8bFc/TfI1nTaSN3Lamg/xHs"},"origin_server_ts":1755422945654,"prev_events":["$49lFLem2Nk4dxHk9RDXxTdaq9InIJpmkHpzVnjKcYwg"],"room_id":"!vzBgJsjNzgHSdWsmki:mozilla.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"JIl60uVgfCLBZLPoSiE7wVkJ9U5cNEPVPuv1sCCYUOq5yOW56WD1adgpBUdX2UFpYkCHvkRnyQGxU0+6HBp5BA"}},"unsigned":{"age_ts":1755422945673}}`, + eventID: "$Qn4tHfuAe6PlnKXPZnygAU9wd6RXqMKtt_ZzstHTSgA", + roomVersion: id.RoomV5, + serverDetails: mauniumNet, +}, { + name: "m.room.message in v10 room", + pdu: `{"auth_events":["$--ilpwnsHaEdHrwiMrZNu5xHP6TthWG0FIXMHnlHCcs","$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ","$Z-qMWmiMvm-aIEffcfSO6lN7TyjyTOsIcHIymfzoo20"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":100885,"hashes":{"sha256":"jc9272JPpPIVreJC3UEAm3BNVnLX8sm3U/TZs23wsHo"},"origin_server_ts":1755422792518,"prev_events":["$HDtbzpSys36Hk-F2NsiXfp9slsGXBH0b58qyddj_q5E"],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"sAMLo9jPtNB0Jq67IQm06siEBx82qZa2edu56IDQ4tDylEV4Mq7iFO23gCghqXA7B/MqBsjXotGBxv6AvlJ2Dw"}},"unsigned":{"age_ts":1755422792540}}`, + eventID: "$4ZFr_ypfp4DyZQP4zyxM_cvuOMFkl07doJmwi106YFY", + roomVersion: id.RoomV10, + serverDetails: mauniumNet, +}, { + name: "m.room.message in v11 room", + pdu: `{"auth_events":["$L8Ak6A939llTRIsZrytMlLDXQhI4uLEjx-wb1zSg-Bw","$QJmr7mmGeXGD4Tof0ZYSPW2oRGklseyHTKtZXnF-YNM","$7bkKK_Z-cGQ6Ae4HXWGBwXyZi3YjC6rIcQzGfVyl3Eo"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":3212,"hashes":{"sha256":"K549YdTnv62Jn84Y7sS5ZN3+AdmhleZHbenbhUpR2R8"},"origin_server_ts":1754242687127,"prev_events":["$DAhJg4jVsqk5FRatE2hbT1dSA8D2ASy5DbjEHIMSHwY"],"room_id":"!offtopic-2:continuwuity.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"SkzZdZ+rH22kzCBBIAErTdB0Vg6vkFmzvwjlOarGul72EnufgtE/tJcd3a8szAdK7f1ZovRyQxDgVm/Ib2u0Aw"}},"unsigned":{"age_ts":1754242687146}}`, + eventID: `$qkWfTL7_l3oRZO2CItW8-Q0yAmi_l_1ua629ZDqponE`, + roomVersion: id.RoomV11, + serverDetails: mauniumNet, +}, roomV12MessageTestPDU, { + name: "m.room.create in v4 room", + pdu: `{"auth_events": [], "prev_events": [], "type": "m.room.create", "room_id": "!jxlRxnrZCsjpjDubDX:matrix.org", "sender": "@neilj:matrix.org", "content": {"room_version": "4", "predecessor": {"room_id": "!DYgXKezaHgMbiPMzjX:matrix.org", "event_id": "$156171636353XwPJT:matrix.org"}, "creator": "@neilj:matrix.org"}, "depth": 1, "prev_state": [], "state_key": "", "origin": "matrix.org", "origin_server_ts": 1561716363993, "hashes": {"sha256": "9tj8GpXjTAJvdNAbnuKLemZZk+Tjv2LAbGodSX6nJAo"}, "signatures": {"matrix.org": {"ed25519:auto": "2+sNt8uJUhzU4GPxnFVYtU2ZRgFdtVLT1vEZGUdJYN40zBpwYEGJy+kyb5matA+8/yLeYD9gu1O98lhleH0aCA"}}, "unsigned": {"age": 104769}}`, + eventID: "$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY", + roomVersion: id.RoomV4, + serverDetails: matrixOrg, +}, { + name: "m.room.create in v10 room", + pdu: `{"auth_events":[],"content":{"creator":"@creme:envs.net","predecessor":{"event_id":"$BxYNisKcyBDhPLiVC06t18qhv7wsT72MzMCqn5vRhfY","room_id":"!tEyFYiMHhwJlDXTxwf:envs.net"},"room_version":"10"},"depth":1,"hashes":{"sha256":"us3TrsIjBWpwbm+k3F9fUVnz9GIuhnb+LcaY47fWwUI"},"origin":"envs.net","origin_server_ts":1664394769527,"prev_events":[],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@creme:envs.net","state_key":"","type":"m.room.create","signatures":{"envs.net":{"ed25519:a_zIqy":"0g3FDaD1e5BekJYW2sR7dgxuKoZshrf8P067c9+jmH6frsWr2Ua86Ax08CFa/n46L8uvV2SGofP8iiVYgXCRBg"}},"unsigned":{"age":2060}}`, + eventID: "$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ", + roomVersion: id.RoomV10, + serverDetails: envsNet, +}, { + name: "m.room.create in v12 room", + pdu: `{"auth_events":[],"content":{"fi.mau.randomness":"AAXZ6aIc","predecessor":{"room_id":"!#test/room\nversion 11, with @\ud83d\udc08\ufe0f:maunium.net"},"room_version":"12"},"depth":1,"hashes":{"sha256":"d3L1M3KUdyIKWcShyW6grUoJ8GOjCdSIEvQrDVHSpE8"},"origin_server_ts":1754940000000,"prev_events":[],"sender":"@tulir:maunium.net","state_key":"","type":"m.room.create","signatures":{"maunium.net":{"ed25519:a_xxeS":"ebjIRpzToc82cjb/RGY+VUzZic0yeRZrjctgx0SUTJxkprXn3/i1KdiYULfl/aD0cUJ5eL8gLakOSk2glm+sBw"}},"unsigned":{"age_ts":1754939139045}}`, + eventID: "$mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +}, { + name: "m.room.member in v4 room", + pdu: `{"auth_events":["$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY","$jg2AgCfnwnjR-osoyM0lVYS21QrtfmZxhGO90PRkmO4","$wMGMP4Ucij2_d4h_fVDgIT2xooLZAgMcBruT9oo3Jio","$yyDgV8w0_e8qslmn0nh9OeSq_fO0zjpjTjSEdKFxDso"],"prev_events":["$zSjNuTXhUe3Rq6NpKD3sNyl8a_asMnBhGC5IbacHlJ4"],"type":"m.room.member","room_id":"!jxlRxnrZCsjpjDubDX:matrix.org","sender":"@tulir:maunium.net","content":{"membership":"join","displayname":"tulir","avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","clicked \"send membership event with no changes\"":true},"depth":14370,"prev_state":[],"state_key":"@tulir:maunium.net","origin":"maunium.net","origin_server_ts":1600871136259,"hashes":{"sha256":"Ga6bG9Mk0887ruzM9TAAfa1O3DbNssb+qSFtE9oeRL4"},"signatures":{"maunium.net":{"ed25519:a_xxeS":"fzOyDG3G3pEzixtWPttkRA1DfnHETiKbiG8SEBQe2qycQbZWPky7xX8WujSrUJH/+bxTABpQwEH49d+RakxtBw"}},"unsigned":{"age_ts":1600871136259,"replaces_state":"$jg2AgCfnwnjR-osoyM0lVYS21QrtfmZxhGO90PRkmO4"}}`, + eventID: "$VtuCNOfAWGow-cxy0ajeK3fvONcC8QzF2yWa43g0Gwo", + roomVersion: id.RoomV4, + serverDetails: mauniumNet, +}, { + name: "m.room.member in v10 room", + pdu: `{"auth_events":["$HQC4hWaioLKVbMH94qKbfb3UnL4ocql2vi-VdUYI48I","$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs","$kEPF8Aj87EzRmFPriu2zdyEY0rY15XSqywTYVLUUlCA","$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ"],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":182,"hashes":{"sha256":"0HscBc921QV2dxK2qY7qrnyoAgfxBM7kKvqAXlEk+GE"},"origin":"maunium.net","origin_server_ts":1665402609039,"prev_events":["$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs"],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member","signatures":{"maunium.net":{"ed25519:a_xxeS":"lkOW0FSJ8MJ0wZpdwLH1Uf6FSl2q9/u6KthRIlM0CwHDJG4sIZ9DrMA8BdU8L/PWoDS/CoDUlLanDh99SplgBw"}},"unsigned":{"age_ts":1665402609039,"replaces_state":"$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs"}}`, + eventID: "$--ilpwnsHaEdHrwiMrZNu5xHP6TthWG0FIXMHnlHCcs", + roomVersion: id.RoomV10, + serverDetails: mauniumNet, +}, { + name: "m.room.member of creator in v12 room", + pdu: `{"auth_events":[],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":2,"hashes":{"sha256":"IebdOBYaaWYIx2zq/lkVCnjWIXTLk1g+vgFpJMgd2/E"},"origin_server_ts":1754939139117,"prev_events":["$mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member","signatures":{"maunium.net":{"ed25519:a_xxeS":"rFCgF2hmavdm6+P6/f7rmuOdoSOmELFaH3JdWjgBLZXS2z51Ma7fa2v2+BkAH1FvBo9FLhvEoFVM4WbNQLXtAA"}},"unsigned":{"age_ts":1754939139117}}`, + eventID: "$accqGxfvhBvMP4Sf6P7t3WgnaJK6UbonO2ZmwqSE5Sg", + roomVersion: id.RoomV12, + serverDetails: mauniumNet, +}, { + name: "custom message event in v4 room", + pdu: `{"auth_events":["$VtuCNOfAWGow-cxy0ajeK3fvONcC8QzF2yWa43g0Gwo","$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY","$Gau_XwziYsr-rt3SouhbKN14twgmbKjcZZc_hz-nOgU"],"content":{"\ud83d\udc08\ufe0f":true,"\ud83d\udc15\ufe0f":false},"depth":69645,"hashes":{"sha256":"VHtWyCt+15ZesNnStU3FOkxrjzHJYZfd3JUgO9JWe0s"},"origin_server_ts":1755423939146,"prev_events":["$exmp4cj0OKOFSxuqBYiOYwQi5j_0XRc78d6EavAkhy0"],"room_id":"!jxlRxnrZCsjpjDubDX:matrix.org","sender":"@tulir:maunium.net","type":"\ud83d\udc08\ufe0f","signatures":{"maunium.net":{"ed25519:a_xxeS":"wfmP1XN4JBkKVkqrQnwysyEUslXt8hQRFwN9NC9vJaIeDMd0OJ6uqCas75808DuG71p23fzqbzhRnHckst6FCQ"}},"unsigned":{"age_ts":1755423939164}}`, + eventID: "$kAagtZAIEeZaLVCUSl74tAxQbdKbE22GU7FM-iAJBc0", + roomVersion: id.RoomV4, + serverDetails: mauniumNet, +}, { + name: "redacted m.room.member event in v11 room with 2 signatures", + pdu: `{"auth_events":["$9f12-_stoY07BOTmyguE1QlqvghLBh9Rk6PWRLoZn_M","$IP8hyjBkIDREVadyv0fPCGAW9IXGNllaZyxqQwiY_tA","$7dN5J8EveliaPkX6_QSejl4GQtem4oieavgALMeWZyE"],"content":{"membership":"join"},"depth":96978,"hashes":{"sha256":"APYA/aj3u+P0EwNaEofuSIlfqY3cK3lBz6RkwHX+Zak"},"origin_server_ts":1755664164485,"prev_events":["$XBN9W5Ll8VEH3eYqJaemxCBTDdy0hZB0sWpmyoUp93c"],"room_id":"!main-1:continuwuity.org","sender":"@6a19abdd4766:nova.astraltech.org","state_key":"@6a19abdd4766:nova.astraltech.org","type":"m.room.member","signatures":{"continuwuity.org":{"ed25519:PwHlNsFu":"+b/Fp2vWnC+Z2lI3GnCu7ZHdo3iWNDZ2AJqMoU9owMtLBPMxs4dVIsJXvaFq0ryawsgwDwKZ7f4xaFUNARJSDg"},"nova.astraltech.org":{"ed25519:a_afpo":"pXIngyxKukCPR7WOIIy8FTZxQ5L2dLiou5Oc8XS4WyY4YzJuckQzOaToigLLZxamfbN/jXbO+XUizpRpYccDAA"}},"unsigned":{}}`, + eventID: "$r6d9m125YWG28-Tln47bWtm6Jlv4mcSUWJTHijBlXLQ", + roomVersion: id.RoomV11, + serverDetails: novaAstraltechOrg, + redacted: true, +}} + +func parsePDU(pdu string) (out *pdu.PDU) { + exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) + return +} diff --git a/mautrix-patched/federation/pdu/redact.go b/mautrix-patched/federation/pdu/redact.go new file mode 100644 index 00000000..d7ee0c15 --- /dev/null +++ b/mautrix-patched/federation/pdu/redact.go @@ -0,0 +1,111 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "encoding/json/jsontext" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/id" +) + +func filteredObject(object jsontext.Value, allowedPaths ...string) jsontext.Value { + filtered := jsontext.Value("{}") + var err error + for _, path := range allowedPaths { + res := gjson.GetBytes(object, path) + if res.Exists() { + var raw jsontext.Value + if res.Index > 0 { + raw = object[res.Index : res.Index+len(res.Raw)] + } else { + raw = jsontext.Value(res.Raw) + } + filtered, err = sjson.SetRawBytes(filtered, path, raw) + if err != nil { + panic(err) + } + } + } + return filtered +} + +func (pdu *PDU) Clone() *PDU { + return ptr.Clone(pdu) +} + +func (pdu *PDU) RedactForSignature(roomVersion id.RoomVersion) *PDU { + pdu.Signatures = nil + return pdu.Redact(roomVersion) +} + +var emptyObject = jsontext.Value("{}") + +func RedactContent(eventType string, content jsontext.Value, roomVersion id.RoomVersion) jsontext.Value { + switch eventType { + case "m.room.member": + allowedPaths := []string{"membership"} + if roomVersion.RestrictedJoinsFix() { + allowedPaths = append(allowedPaths, "join_authorised_via_users_server") + } + if roomVersion.UpdatedRedactionRules() { + allowedPaths = append(allowedPaths, exgjson.Path("third_party_invite", "signed")) + } + return filteredObject(content, allowedPaths...) + case "m.room.create": + if !roomVersion.UpdatedRedactionRules() { + return filteredObject(content, "creator") + } + return content + case "m.room.join_rules": + if roomVersion.RestrictedJoins() { + return filteredObject(content, "join_rule", "allow") + } + return filteredObject(content, "join_rule") + case "m.room.power_levels": + allowedKeys := []string{"ban", "events", "events_default", "kick", "redact", "state_default", "users", "users_default"} + if roomVersion.UpdatedRedactionRules() { + allowedKeys = append(allowedKeys, "invite") + } + return filteredObject(content, allowedKeys...) + case "m.room.history_visibility": + return filteredObject(content, "history_visibility") + case "m.room.redaction": + if roomVersion.RedactsInContent() { + return filteredObject(content, "redacts") + } + return emptyObject + case "m.room.aliases": + if roomVersion.SpecialCasedAliasesAuth() { + return filteredObject(content, "aliases") + } + return emptyObject + default: + return emptyObject + } +} + +func (pdu *PDU) Redact(roomVersion id.RoomVersion) *PDU { + pdu.Unknown = nil + pdu.Unsigned = nil + if roomVersion.UpdatedRedactionRules() { + pdu.DeprecatedPrevState = nil + pdu.DeprecatedOrigin = nil + pdu.DeprecatedMembership = nil + } + if pdu.Type != "m.room.redaction" || roomVersion.RedactsInContent() { + pdu.Redacts = nil + } + pdu.Content = RedactContent(pdu.Type, pdu.Content, roomVersion) + return pdu +} diff --git a/mautrix-patched/federation/pdu/signature.go b/mautrix-patched/federation/pdu/signature.go new file mode 100644 index 00000000..109281bc --- /dev/null +++ b/mautrix-patched/federation/pdu/signature.go @@ -0,0 +1,67 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "time" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { + err := pdu.FillContentHash() + if err != nil { + return err + } + rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) + } + signature := ed25519.Sign(privateKey, rawJSON) + pdu.AddSignature(serverName, keyID, base64.RawStdEncoding.EncodeToString(signature)) + return nil +} + +func (pdu *PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error { + sigs := pdu.Signatures[serverName] + if len(sigs) == 0 { + return fmt.Errorf("no signatures found for server %s", serverName) + } + rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) + } + verified := false + var errorList []error + for keyID, sig := range sigs { + originServerTS := time.UnixMilli(pdu.OriginServerTS) + key, validUntil, err := getKey(serverName, keyID, originServerTS) + if err != nil { + return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) + } else if key == "" { + errorList = append(errorList, fmt.Errorf("key %s not found for %s", keyID, serverName)) + } else if validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity() { + errorList = append(errorList, fmt.Errorf("key %s for %s is only valid until %s, but event is from %s", keyID, serverName, validUntil, originServerTS)) + } else if err = signutil.VerifyJSONCanonical(key, sig, rawJSON); err != nil { + return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) + } else { + verified = true + } + } + if !verified { + return fmt.Errorf("no verifiable signatures found for server %s: %w", serverName, errors.Join(errorList...)) + } + return nil +} diff --git a/mautrix-patched/federation/pdu/signature_test.go b/mautrix-patched/federation/pdu/signature_test.go new file mode 100644 index 00000000..01df5076 --- /dev/null +++ b/mautrix-patched/federation/pdu/signature_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json/jsontext" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +func TestPDU_VerifySignature(t *testing.T) { + for _, test := range testPDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, test.getKey) + assert.NoError(t, err) + }) + } +} + +func TestPDU_VerifySignature_Fail_NoKey(t *testing.T) { + test := roomV12MessageTestPDU + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + return + }) + assert.Error(t, err) +} + +func TestPDU_VerifySignature_V4ExpiredKey(t *testing.T) { + test := roomV4MessageTestPDU + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + key = test.keys[keyID].key + validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + return + }) + assert.NoError(t, err) +} + +func TestPDU_VerifySignature_V12ExpiredKey(t *testing.T) { + test := roomV12MessageTestPDU + parsed := parsePDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + key = test.keys[keyID].key + validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + return + }) + assert.Error(t, err) +} + +func TestPDU_VerifySignature_V12InvalidSignature(t *testing.T) { + test := roomV12MessageTestPDU + parsed := parsePDU(test.pdu) + for _, sigs := range parsed.Signatures { + for key := range sigs { + sigs[key] = sigs[key][:len(sigs[key])-3] + "ABC" + } + } + err := parsed.VerifySignature(test.roomVersion, test.serverName, test.getKey) + assert.Error(t, err) +} + +func TestPDU_Sign(t *testing.T) { + pubKey, privKey := exerrors.Must2(ed25519.GenerateKey(nil)) + evt := &pdu.PDU{ + AuthEvents: []id.EventID{"$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA", "$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"}, + Content: jsontext.Value(`{"msgtype":"m.text","body":"Hello, world!"}`), + Depth: 123, + OriginServerTS: 1755384351627, + PrevEvents: []id.EventID{"$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"}, + RoomID: "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", + Sender: "@tulir:example.com", + Type: "m.room.message", + } + err := evt.Sign(id.RoomV12, "example.com", "ed25519:rand", privKey) + require.NoError(t, err) + err = evt.VerifySignature(id.RoomV11, "example.com", func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { + if serverName == "example.com" && keyID == "ed25519:rand" { + key = id.SigningKey(base64.RawStdEncoding.EncodeToString(pubKey)) + validUntil = time.Now() + } + return + }) + require.NoError(t, err) + +} diff --git a/mautrix-patched/federation/pdu/v1.go b/mautrix-patched/federation/pdu/v1.go new file mode 100644 index 00000000..8760d081 --- /dev/null +++ b/mautrix-patched/federation/pdu/v1.go @@ -0,0 +1,278 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu + +import ( + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "time" + + "github.com/tidwall/gjson" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +type V1EventReference struct { + ID id.EventID + Hashes Hashes +} + +var ( + _ json.UnmarshalerFrom = (*V1EventReference)(nil) + _ json.MarshalerTo = (*V1EventReference)(nil) +) + +func (er *V1EventReference) MarshalJSONTo(enc *jsontext.Encoder) error { + return json.MarshalEncode(enc, []any{er.ID, er.Hashes}) +} + +func (er *V1EventReference) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + var ref V1EventReference + var data []jsontext.Value + if err := json.UnmarshalDecode(dec, &data); err != nil { + return err + } else if len(data) != 2 { + return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: expected array with 2 elements, got %d", len(data)) + } else if err = json.Unmarshal(data[0], &ref.ID); err != nil { + return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: failed to unmarshal event ID: %w", err) + } else if err = json.Unmarshal(data[1], &ref.Hashes); err != nil { + return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: failed to unmarshal hashes: %w", err) + } + *er = ref + return nil +} + +type RoomV1PDU struct { + AuthEvents []V1EventReference `json:"auth_events"` + Content jsontext.Value `json:"content"` + Depth int64 `json:"depth"` + EventID id.EventID `json:"event_id"` + Hashes *Hashes `json:"hashes,omitzero"` + OriginServerTS int64 `json:"origin_server_ts"` + PrevEvents []V1EventReference `json:"prev_events"` + Redacts *id.EventID `json:"redacts,omitzero"` + RoomID id.RoomID `json:"room_id"` + Sender id.UserID `json:"sender"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitzero"` + StateKey *string `json:"state_key,omitzero"` + Type string `json:"type"` + Unsigned jsontext.Value `json:"unsigned,omitzero"` + + Unknown jsontext.Value `json:",unknown"` + + // Deprecated legacy fields + DeprecatedPrevState jsontext.Value `json:"prev_state,omitzero"` + DeprecatedOrigin jsontext.Value `json:"origin,omitzero"` + DeprecatedMembership jsontext.Value `json:"membership,omitzero"` +} + +func (pdu *RoomV1PDU) GetRoomID() (id.RoomID, error) { + return pdu.RoomID, nil +} + +func (pdu *RoomV1PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { + if !pdu.SupportsRoomVersion(roomVersion) { + return "", fmt.Errorf("RoomV1PDU.GetEventID: unsupported room version %s", roomVersion) + } + return pdu.EventID, nil +} + +func (pdu *RoomV1PDU) RedactForSignature(roomVersion id.RoomVersion) *RoomV1PDU { + pdu.Signatures = nil + return pdu.Redact(roomVersion) +} + +func (pdu *RoomV1PDU) Redact(roomVersion id.RoomVersion) *RoomV1PDU { + pdu.Unknown = nil + pdu.Unsigned = nil + if pdu.Type != "m.room.redaction" { + pdu.Redacts = nil + } + pdu.Content = RedactContent(pdu.Type, pdu.Content, roomVersion) + return pdu +} + +func (pdu *RoomV1PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) { + if !pdu.SupportsRoomVersion(roomVersion) { + return [32]byte{}, fmt.Errorf("RoomV1PDU.GetReferenceHash: unsupported room version %s", roomVersion) + } + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { + if err := pdu.FillContentHash(); err != nil { + return [32]byte{}, err + } + } + rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *RoomV1PDU) CalculateContentHash() ([32]byte, error) { + if pdu == nil { + return [32]byte{}, ErrPDUIsNil + } + pduClone := pdu.Clone() + pduClone.Signatures = nil + pduClone.Unsigned = nil + pduClone.Hashes = nil + rawJSON, err := canonicaljson.Marshal(pduClone) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) + } + return sha256.Sum256(rawJSON), nil +} + +func (pdu *RoomV1PDU) FillContentHash() error { + if pdu == nil { + return ErrPDUIsNil + } else if pdu.Hashes != nil { + return nil + } else if hash, err := pdu.CalculateContentHash(); err != nil { + return err + } else { + pdu.Hashes = &Hashes{SHA256: hash[:]} + return nil + } +} + +func (pdu *RoomV1PDU) VerifyContentHash() bool { + if pdu == nil || pdu.Hashes == nil { + return false + } + calculatedHash, err := pdu.CalculateContentHash() + if err != nil { + return false + } + return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) +} + +func (pdu *RoomV1PDU) Clone() *RoomV1PDU { + return ptr.Clone(pdu) +} + +func (pdu *RoomV1PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { + if !pdu.SupportsRoomVersion(roomVersion) { + return fmt.Errorf("RoomV1PDU.Sign: unsupported room version %s", roomVersion) + } + err := pdu.FillContentHash() + if err != nil { + return err + } + rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) + } + signature := ed25519.Sign(privateKey, rawJSON) + if pdu.Signatures == nil { + pdu.Signatures = make(map[string]map[id.KeyID]string) + } + if _, ok := pdu.Signatures[serverName]; !ok { + pdu.Signatures[serverName] = make(map[id.KeyID]string) + } + pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) + return nil +} + +func (pdu *RoomV1PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error { + if !pdu.SupportsRoomVersion(roomVersion) { + return fmt.Errorf("RoomV1PDU.VerifySignature: unsupported room version %s", roomVersion) + } + rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) + if err != nil { + return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) + } + verified := false + for keyID, sig := range pdu.Signatures[serverName] { + originServerTS := time.UnixMilli(pdu.OriginServerTS) + key, _, err := getKey(serverName, keyID, originServerTS) + if err != nil { + return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) + } else if key == "" { + return fmt.Errorf("key %s not found for %s", keyID, serverName) + } else if err = signutil.VerifyJSONCanonical(key, sig, rawJSON); err != nil { + return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) + } else { + verified = true + } + } + if !verified { + return fmt.Errorf("no verifiable signatures found for server %s", serverName) + } + return nil +} + +func (pdu *RoomV1PDU) SupportsRoomVersion(roomVersion id.RoomVersion) bool { + switch roomVersion { + case id.RoomV0, id.RoomV1, id.RoomV2: + return true + default: + return false + } +} + +func (pdu *RoomV1PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { + if !pdu.SupportsRoomVersion(roomVersion) { + return nil, fmt.Errorf("RoomV1PDU.ToClientEvent: unsupported room version %s", roomVersion) + } + evtType := event.Type{Type: pdu.Type, Class: event.MessageEventType} + if pdu.StateKey != nil { + evtType.Class = event.StateEventType + } + evt := &event.Event{ + StateKey: pdu.StateKey, + Sender: pdu.Sender, + Type: evtType, + Timestamp: pdu.OriginServerTS, + ID: pdu.EventID, + RoomID: pdu.RoomID, + Redacts: ptr.Val(pdu.Redacts), + } + err := json.Unmarshal(pdu.Content, &evt.Content) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal content: %w", err) + } + return evt, nil +} + +func (pdu *RoomV1PDU) AuthEventSelection(_ id.RoomVersion) (keys AuthEventSelection) { + if pdu.Type == event.StateCreate.Type && pdu.StateKey != nil { + return AuthEventSelection{} + } + keys = make(AuthEventSelection, 0, 3) + keys.Add(event.StateCreate.Type, "") + keys.Add(event.StatePowerLevels.Type, "") + keys.Add(event.StateMember.Type, pdu.Sender.String()) + if pdu.Type == event.StateMember.Type && pdu.StateKey != nil { + keys.Add(event.StateMember.Type, *pdu.StateKey) + membership := event.Membership(gjson.GetBytes(pdu.Content, "membership").Str) + if membership == event.MembershipJoin || membership == event.MembershipInvite || membership == event.MembershipKnock { + keys.Add(event.StateJoinRules.Type, "") + } + if membership == event.MembershipInvite { + thirdPartyInviteToken := gjson.GetBytes(pdu.Content, thirdPartyInviteTokenPath).Str + if thirdPartyInviteToken != "" { + keys.Add(event.StateThirdPartyInvite.Type, thirdPartyInviteToken) + } + } + } + return +} diff --git a/mautrix-patched/federation/pdu/v1_test.go b/mautrix-patched/federation/pdu/v1_test.go new file mode 100644 index 00000000..ecf2dbd2 --- /dev/null +++ b/mautrix-patched/federation/pdu/v1_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build goexperiment.jsonv2 + +package pdu_test + +import ( + "encoding/base64" + "encoding/json/v2" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/federation/pdu" + "maunium.net/go/mautrix/id" +) + +var testV1PDUs = []testPDU{{ + name: "m.room.message in v1 room", + pdu: `{"auth_events":[["$159234730483190eXavq:matrix.org",{"sha256":"VprZrhMqOQyKbfF3UE26JXE8D27ih4R/FGGc8GZ0Whs"}],["$143454825711DhCxH:matrix.org",{"sha256":"3sJh/5GOB094OKuhbjL634Gt69YIcge9GD55ciJa9ok"}],["$156837651426789wiPdh:maunium.net",{"sha256":"FGyR3sxJ/VxYabDkO/5qtwrPR3hLwGknJ0KX0w3GUHE"}]],"content":{"body":"photo-1526336024174-e58f5cdd8e13.jpg","info":{"h":1620,"mimetype":"image/jpeg","size":208053,"w":1080},"msgtype":"m.image","url":"mxc://maunium.net/aEqEghIjFPAerIhCxJCYpQeC"},"depth":16669,"event_id":"$16738169022163bokdi:maunium.net","hashes":{"sha256":"XYB47Gf2vAci3BTguIJaC75ZYGMuVY65jcvoUVgpcLA"},"origin":"maunium.net","origin_server_ts":1673816902100,"prev_events":[["$1673816901121325UMCjA:matrix.org",{"sha256":"t7e0IYHLI3ydIPoIU8a8E/pIWXH9cNLlQBEtGyGtHwc"}]],"room_id":"!jhpZBTbckszblMYjMK:matrix.org","sender":"@cat:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"uRZbEm+P+Y1ZVgwBn5I6SlaUZdzlH1bB4nv81yt5EIQ0b1fZ8YgM4UWMijrrXp3+NmqRFl0cakSM3MneJOtFCw"}},"unsigned":{"age_ts":1673816902100}}`, + eventID: "$16738169022163bokdi:maunium.net", + roomVersion: id.RoomV1, + serverDetails: mauniumNet, +}, { + name: "m.room.create in v1 room", + pdu: `{"origin": "matrix.org", "signatures": {"matrix.org": {"ed25519:auto": "XTejpXn5REoHrZWgCpJglGX7MfOWS2zUjYwJRLrwW2PQPbFdqtL+JnprBXwIP2C1NmgWSKG+am1QdApu0KoHCQ"}}, "origin_server_ts": 1434548257426, "sender": "@appservice-irc:matrix.org", "event_id": "$143454825711DhCxH:matrix.org", "prev_events": [], "unsigned": {"age": 12872287834}, "state_key": "", "content": {"creator": "@appservice-irc:matrix.org"}, "depth": 1, "prev_state": [], "room_id": "!jhpZBTbckszblMYjMK:matrix.org", "auth_events": [], "hashes": {"sha256": "+SSdmeeoKI/6yK6sY4XAFljWFiugSlCiXQf0QMCZjTs"}, "type": "m.room.create"}`, + eventID: "$143454825711DhCxH:matrix.org", + roomVersion: id.RoomV1, + serverDetails: matrixOrg, +}, { + name: "m.room.member in v1 room", + pdu: `{"auth_events": [["$1536447669931522zlyWe:matrix.org", {"sha256": "UkzPGd7cPAGvC0FVx3Yy2/Q0GZhA2kcgj8MGp5pjYV8"}], ["$143454825711DhCxH:matrix.org", {"sha256": "3sJh/5GOB094OKuhbjL634Gt69YIcge9GD55ciJa9ok"}], ["$143454825714nUEqZ:matrix.org", {"sha256": "NjuZXu8EDMfIfejPcNlC/IdnKQAGpPIcQjHaf0BZaHk"}]], "prev_events": [["$15660585503271JRRMm:maunium.net", {"sha256": "/Sm7uSLkYMHapp6I3NuEVJlk2JucW2HqjsQy9vzhciA"}]], "type": "m.room.member", "room_id": "!jhpZBTbckszblMYjMK:matrix.org", "sender": "@tulir:maunium.net", "content": {"membership": "join", "avatar_url": "mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO", "displayname": "tulir"}, "depth": 10485, "prev_state": [], "state_key": "@tulir:maunium.net", "event_id": "$15660585693272iEryv:maunium.net", "origin": "maunium.net", "origin_server_ts": 1566058569201, "hashes": {"sha256": "1D6fdDzKsMGCxSqlXPA7I9wGQNTutVuJke1enGHoWK8"}, "signatures": {"maunium.net": {"ed25519:a_xxeS": "Lj/zDK6ozr4vgsxyL8jY56wTGWoA4jnlvkTs5paCX1w3nNKHnQnSMi+wuaqI6yv5vYh9usGWco2LLMuMzYXcBg"}}, "unsigned": {"age_ts": 1566058569201, "replaces_state": "$15660585383268liyBc:maunium.net"}}`, + eventID: "$15660585693272iEryv:maunium.net", + roomVersion: id.RoomV1, + serverDetails: mauniumNet, +}} + +func parseV1PDU(pdu string) (out *pdu.RoomV1PDU) { + exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) + return +} + +func TestRoomV1PDU_CalculateContentHash(t *testing.T) { + for _, test := range testV1PDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parseV1PDU(test.pdu) + contentHash := exerrors.Must(parsed.CalculateContentHash()) + assert.Equal( + t, + base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), + base64.RawStdEncoding.EncodeToString(contentHash[:]), + ) + }) + } +} + +func TestRoomV1PDU_VerifyContentHash(t *testing.T) { + for _, test := range testV1PDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parseV1PDU(test.pdu) + assert.True(t, parsed.VerifyContentHash()) + }) + } +} + +func TestRoomV1PDU_VerifySignature(t *testing.T) { + for _, test := range testV1PDUs { + t.Run(test.name, func(t *testing.T) { + parsed := parseV1PDU(test.pdu) + err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { + key, ok := test.keys[keyID] + if ok { + return key.key, key.validUntilTS, nil + } + return "", time.Time{}, nil + }) + assert.NoError(t, err) + }) + } +} diff --git a/mautrix-patched/federation/resolution.go b/mautrix-patched/federation/resolution.go new file mode 100644 index 00000000..a3188266 --- /dev/null +++ b/mautrix-patched/federation/resolution.go @@ -0,0 +1,198 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" +) + +type ResolvedServerName struct { + ServerName string `json:"server_name"` + HostHeader string `json:"host_header"` + IPPort []string `json:"ip_port"` + Expires time.Time `json:"expires"` +} + +type ResolveServerNameOpts struct { + HTTPClient *http.Client + DNSClient *net.Resolver +} + +var ( + ErrInvalidServerName = errors.New("invalid server name") +) + +// ResolveServerName implements the full server discovery algorithm as specified in https://spec.matrix.org/v1.11/server-server-api/#resolving-server-names +func ResolveServerName(ctx context.Context, serverName string, opts ...*ResolveServerNameOpts) (*ResolvedServerName, error) { + var opt ResolveServerNameOpts + if len(opts) > 0 && opts[0] != nil { + opt = *opts[0] + } + if opt.HTTPClient == nil { + opt.HTTPClient = http.DefaultClient + } + if opt.DNSClient == nil { + opt.DNSClient = net.DefaultResolver + } + output := ResolvedServerName{ + ServerName: serverName, + HostHeader: serverName, + IPPort: []string{serverName}, + Expires: time.Now().Add(24 * time.Hour), + } + hostname, port, ok := ParseServerName(serverName) + if !ok { + return nil, ErrInvalidServerName + } + // Steps 1 and 2: handle IP literals and hostnames with port + if net.ParseIP(hostname) != nil || port != 0 { + if port == 0 { + port = 8448 + } + output.IPPort = []string{net.JoinHostPort(hostname, strconv.Itoa(int(port)))} + return &output, nil + } + // Step 3: resolve .well-known + wellKnown, expiry, err := RequestWellKnown(ctx, opt.HTTPClient, hostname) + if err != nil { + zerolog.Ctx(ctx).Trace(). + Str("server_name", serverName). + Err(err). + Msg("Failed to get well-known data") + } else if wellKnown != nil { + output.Expires = expiry + output.HostHeader = wellKnown.Server + wkHost, wkPort, ok := ParseServerName(wellKnown.Server) + if ok { + hostname, port = wkHost, wkPort + } + // Step 3.1 and 3.2: IP literals and hostnames with port inside .well-known + if net.ParseIP(hostname) != nil || port != 0 { + if port == 0 { + port = 8448 + } + output.IPPort = []string{net.JoinHostPort(hostname, strconv.Itoa(int(port)))} + return &output, nil + } + } + // Step 3.3, 3.4, 4 and 5: resolve SRV records + srv, err := RequestSRV(ctx, opt.DNSClient, hostname) + if err != nil { + // TODO log more noisily for abnormal errors? + zerolog.Ctx(ctx).Trace(). + Str("server_name", serverName). + Str("hostname", hostname). + Err(err). + Msg("Failed to get SRV record") + } else if len(srv) > 0 { + output.IPPort = make([]string, len(srv)) + for i, record := range srv { + output.IPPort[i] = net.JoinHostPort(strings.TrimRight(record.Target, "."), strconv.Itoa(int(record.Port))) + } + return &output, nil + } + // Step 6 or 3.5: no SRV records were found, so default to port 8448 + output.IPPort = []string{net.JoinHostPort(hostname, "8448")} + return &output, nil +} + +// RequestSRV resolves the `_matrix-fed._tcp` SRV record for the given hostname. +// If the new matrix-fed record is not found, it falls back to the old `_matrix._tcp` record. +func RequestSRV(ctx context.Context, cli *net.Resolver, hostname string) ([]*net.SRV, error) { + _, target, err := cli.LookupSRV(ctx, "matrix-fed", "tcp", hostname) + var dnsErr *net.DNSError + if err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound { + _, target, err = cli.LookupSRV(ctx, "matrix", "tcp", hostname) + } + return target, err +} + +func parseCacheControl(resp *http.Response) time.Duration { + cc := resp.Header.Get("Cache-Control") + if cc == "" { + return 0 + } + parts := strings.Split(cc, ",") + for _, part := range parts { + kv := strings.SplitN(strings.TrimSpace(part), "=", 1) + switch kv[0] { + case "no-cache", "no-store": + return 0 + case "max-age": + if len(kv) < 2 { + continue + } + maxAge, err := strconv.Atoi(kv[1]) + if err != nil || maxAge < 0 { + continue + } + age, _ := strconv.Atoi(resp.Header.Get("Age")) + return time.Duration(maxAge-age) * time.Second + } + } + return 0 +} + +const ( + MinCacheDuration = 1 * time.Hour + MaxCacheDuration = 72 * time.Hour + DefaultCacheDuration = 24 * time.Hour +) + +// RequestWellKnown sends a request to the well-known endpoint of a server and returns the response, +// plus the time when the cache should expire. +func RequestWellKnown(ctx context.Context, cli *http.Client, hostname string) (*RespWellKnown, time.Time, error) { + wellKnownURL := url.URL{ + Scheme: "https", + Host: hostname, + Path: "/.well-known/matrix/server", + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL.String(), nil) + if err != nil { + return nil, time.Time{}, fmt.Errorf("failed to prepare request: %w", err) + } + resp, err := cli.Do(req) + if err != nil { + return nil, time.Time{}, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, time.Time{}, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } else if resp.ContentLength > mautrix.WellKnownMaxSize { + return nil, time.Time{}, fmt.Errorf("response too large: %d bytes", resp.ContentLength) + } + var respData RespWellKnown + err = json.NewDecoder(io.LimitReader(resp.Body, mautrix.WellKnownMaxSize)).Decode(&respData) + if err != nil { + return nil, time.Time{}, fmt.Errorf("failed to decode response: %w", err) + } else if respData.Server == "" { + return nil, time.Time{}, errors.New("server name not found in response") + } + cacheDuration := parseCacheControl(resp) + if cacheDuration <= 0 { + cacheDuration = DefaultCacheDuration + } else if cacheDuration < MinCacheDuration { + cacheDuration = MinCacheDuration + } else if cacheDuration > MaxCacheDuration { + cacheDuration = MaxCacheDuration + } + return &respData, time.Now().Add(24 * time.Hour), nil +} diff --git a/mautrix-patched/federation/resolution_test.go b/mautrix-patched/federation/resolution_test.go new file mode 100644 index 00000000..62200454 --- /dev/null +++ b/mautrix-patched/federation/resolution_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/federation" +) + +type resolveTestCase struct { + name string + serverName string + expected federation.ResolvedServerName +} + +func TestResolveServerName(t *testing.T) { + // See https://t2bot.io/docs/resolvematrix/ for more info on the RM test cases + testCases := []resolveTestCase{{ + "maunium", + "maunium.net", + federation.ResolvedServerName{ + HostHeader: "federation.mau.chat", + IPPort: []string{"meow.host.mau.fi:443"}, + }, + }, { + "IP literal", + "135.181.208.158", + federation.ResolvedServerName{ + HostHeader: "135.181.208.158", + IPPort: []string{"135.181.208.158:8448"}, + }, + }, { + "IP literal with port", + "135.181.208.158:8447", + federation.ResolvedServerName{ + HostHeader: "135.181.208.158:8447", + IPPort: []string{"135.181.208.158:8447"}, + }, + }, { + "RM Step 2", + "2.s.resolvematrix.dev:7652", + federation.ResolvedServerName{ + HostHeader: "2.s.resolvematrix.dev:7652", + IPPort: []string{"2.s.resolvematrix.dev:7652"}, + }, + }, { + "RM Step 3B", + "3b.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "wk.3b.s.resolvematrix.dev:7753", + IPPort: []string{"wk.3b.s.resolvematrix.dev:7753"}, + }, + }, { + "RM Step 3C", + "3c.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "wk.3c.s.resolvematrix.dev", + IPPort: []string{"srv.wk.3c.s.resolvematrix.dev:7754"}, + }, + }, { + "RM Step 3C MSC4040", + "3c.msc4040.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "wk.3c.msc4040.s.resolvematrix.dev", + IPPort: []string{"srv.wk.3c.msc4040.s.resolvematrix.dev:7053"}, + }, + }, { + "RM Step 3D", + "3d.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "wk.3d.s.resolvematrix.dev", + IPPort: []string{"wk.3d.s.resolvematrix.dev:8448"}, + }, + }, { + "RM Step 4", + "4.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "4.s.resolvematrix.dev", + IPPort: []string{"srv.4.s.resolvematrix.dev:7855"}, + }, + }, { + "RM Step 4 MSC4040", + "4.msc4040.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "4.msc4040.s.resolvematrix.dev", + IPPort: []string{"srv.4.msc4040.s.resolvematrix.dev:7054"}, + }, + }, { + "RM Step 5", + "5.s.resolvematrix.dev", + federation.ResolvedServerName{ + HostHeader: "5.s.resolvematrix.dev", + IPPort: []string{"5.s.resolvematrix.dev:8448"}, + }, + }} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.expected.ServerName = tc.serverName + resp, err := federation.ResolveServerName(context.TODO(), tc.serverName) + require.NoError(t, err) + resp.Expires = time.Time{} + assert.Equal(t, tc.expected, *resp) + }) + } +} diff --git a/mautrix-patched/federation/serverauth.go b/mautrix-patched/federation/serverauth.go new file mode 100644 index 00000000..cd300341 --- /dev/null +++ b/mautrix-patched/federation/serverauth.go @@ -0,0 +1,264 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" + "sync" + + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type ServerAuth struct { + Keys KeyCache + Client *Client + GetDestination func(XMatrixAuth) string + MaxBodySize int64 + + keyFetchLocks map[string]*sync.Mutex + keyFetchLocksLock sync.Mutex +} + +func NewServerAuth(client *Client, keyCache KeyCache, getDestination func(auth XMatrixAuth) string) *ServerAuth { + return &ServerAuth{ + Keys: keyCache, + Client: client, + GetDestination: getDestination, + MaxBodySize: 50 * 1024 * 1024, + keyFetchLocks: make(map[string]*sync.Mutex), + } +} + +var MUnauthorized = mautrix.RespError{ErrCode: "M_UNAUTHORIZED", StatusCode: http.StatusUnauthorized} + +var ( + errMissingAuthHeader = MUnauthorized.WithMessage("Missing Authorization header") + errInvalidAuthHeader = MUnauthorized.WithMessage("Authorization header does not start with X-Matrix") + errMalformedAuthHeader = MUnauthorized.WithMessage("X-Matrix value is missing required components") + errInvalidDestination = MUnauthorized.WithMessage("Invalid destination in X-Matrix header") + errFailedToQueryKeys = MUnauthorized.WithMessage("Failed to query server keys") + errInvalidSelfSignatures = MUnauthorized.WithMessage("Server keys don't have valid self-signatures") + errRequestBodyTooLarge = mautrix.MTooLarge.WithMessage("Request body too large") + errInvalidJSONBody = mautrix.MBadJSON.WithMessage("Request body is not valid JSON") + errBodyReadFailed = mautrix.MUnknown.WithMessage("Failed to read request body") + errInvalidRequestSignature = MUnauthorized.WithMessage("Failed to verify request signature") +) + +type XMatrixAuth struct { + Origin string + Destination string + KeyID id.KeyID + Signature string +} + +func (xma XMatrixAuth) String() string { + return fmt.Sprintf( + `X-Matrix origin="%s",destination="%s",key="%s",sig="%s"`, + xma.Origin, + xma.Destination, + xma.KeyID, + xma.Signature, + ) +} + +func ParseXMatrixAuth(auth string) (xma XMatrixAuth) { + auth = strings.TrimPrefix(auth, "X-Matrix ") + // TODO upgrade to strings.SplitSeq after Go 1.24 is the minimum + for _, part := range strings.Split(auth, ",") { + part = strings.TrimSpace(part) + eqIdx := strings.Index(part, "=") + if eqIdx == -1 || strings.Count(part, "=") > 1 { + continue + } + val := strings.Trim(part[eqIdx+1:], "\"") + switch strings.ToLower(part[:eqIdx]) { + case "origin": + xma.Origin = val + case "destination": + xma.Destination = val + case "key": + xma.KeyID = id.KeyID(val) + case "sig": + xma.Signature = val + } + } + return +} + +func (sa *ServerAuth) GetKeysWithCache(ctx context.Context, serverName string, keyID id.KeyID) (*ServerKeyResponse, error) { + res, err := sa.Keys.LoadKeys(serverName) + if err != nil { + return nil, fmt.Errorf("failed to read cache: %w", err) + } else if res.HasKey(keyID) { + return res, nil + } + + sa.keyFetchLocksLock.Lock() + lock, ok := sa.keyFetchLocks[serverName] + if !ok { + lock = &sync.Mutex{} + sa.keyFetchLocks[serverName] = lock + } + sa.keyFetchLocksLock.Unlock() + + lock.Lock() + defer lock.Unlock() + res, err = sa.Keys.LoadKeys(serverName) + if err != nil { + return nil, fmt.Errorf("failed to read cache: %w", err) + } else if res != nil { + if res.HasKey(keyID) { + return res, nil + } else if !sa.Keys.ShouldReQuery(serverName) { + zerolog.Ctx(ctx).Trace(). + Str("server_name", serverName). + Stringer("key_id", keyID). + Msg("Not sending key request for missing key ID, last query was too recent") + return res, nil + } + } + res, err = sa.Client.ServerKeys(ctx, serverName) + if err != nil { + sa.Keys.StoreFetchError(serverName, err) + return nil, err + } + sa.Keys.StoreKeys(res) + return res, nil +} + +type fixedLimitedReader struct { + R io.Reader + N int64 + Err error +} + +func (l *fixedLimitedReader) Read(p []byte) (n int, err error) { + if l.N <= 0 { + return 0, l.Err + } + if int64(len(p)) > l.N { + p = p[0:l.N] + } + n, err = l.R.Read(p) + l.N -= int64(n) + return +} + +func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.RespError) { + defer func() { + _ = r.Body.Close() + }() + log := zerolog.Ctx(r.Context()) + if r.ContentLength > sa.MaxBodySize { + return nil, &errRequestBodyTooLarge + } + auth := r.Header.Get("Authorization") + if auth == "" { + return nil, &errMissingAuthHeader + } else if !strings.HasPrefix(auth, "X-Matrix ") { + return nil, &errInvalidAuthHeader + } + parsed := ParseXMatrixAuth(auth) + if parsed.Origin == "" || parsed.KeyID == "" || parsed.Signature == "" { + log.Trace().Str("auth_header", auth).Msg("Malformed X-Matrix header") + return nil, &errMalformedAuthHeader + } + destination := sa.GetDestination(parsed) + if destination == "" || (parsed.Destination != "" && parsed.Destination != destination) { + log.Trace(). + Str("got_destination", parsed.Destination). + Str("expected_destination", destination). + Msg("Invalid destination in X-Matrix header") + return nil, &errInvalidDestination + } + resp, err := sa.GetKeysWithCache(r.Context(), parsed.Origin, parsed.KeyID) + if err != nil { + if !errors.Is(err, ErrRecentKeyQueryFailed) { + log.Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to query keys to authenticate request") + } else { + log.Trace().Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to query keys to authenticate request (cached error)") + } + return nil, &errFailedToQueryKeys + } else if err := resp.VerifySelfSignature(); err != nil { + log.Trace().Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to validate self-signatures of server keys") + return nil, &errInvalidSelfSignatures + } + key, ok := resp.VerifyKeys[parsed.KeyID] + if !ok { + keys := slices.Collect(maps.Keys(resp.VerifyKeys)) + log.Trace(). + Stringer("expected_key_id", parsed.KeyID). + Any("found_key_ids", keys). + Msg("Didn't find expected key ID to verify request") + return nil, ptr.Ptr(MUnauthorized.WithMessage("Key ID %q not found (got %v)", parsed.KeyID, keys)) + } + var reqBody []byte + if r.ContentLength != 0 && r.Method != http.MethodGet && r.Method != http.MethodHead { + reqBody, err = io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: errRequestBodyTooLarge}) + if errors.Is(err, errRequestBodyTooLarge) { + return nil, &errRequestBodyTooLarge + } else if err != nil { + log.Err(err). + Str("server_name", parsed.Origin). + Msg("Failed to read request body to authenticate") + return nil, &errBodyReadFailed + } else if !json.Valid(reqBody) { + return nil, &errInvalidJSONBody + } + } + err = (&signableRequest{ + Method: r.Method, + URI: r.URL.RequestURI(), + Origin: parsed.Origin, + Destination: destination, + Content: reqBody, + }).Verify(key.Key, parsed.Signature) + if err != nil { + log.Trace().Err(err).Msg("Request has invalid signature") + return nil, &errInvalidRequestSignature + } + ctx := context.WithValue(r.Context(), contextKeyDestinationServer, destination) + ctx = context.WithValue(ctx, contextKeyOriginServer, parsed.Origin) + ctx = log.With(). + Str("origin_server_name", parsed.Origin). + Str("destination_server_name", destination). + Logger().WithContext(ctx) + modifiedReq := r.WithContext(ctx) + if reqBody != nil { + modifiedReq.Body = io.NopCloser(bytes.NewReader(reqBody)) + } + return modifiedReq, nil +} + +func (sa *ServerAuth) AuthenticateMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if modifiedReq, err := sa.Authenticate(r); err != nil { + err.Write(w) + } else { + next.ServeHTTP(w, modifiedReq) + } + }) +} diff --git a/mautrix-patched/federation/serverauth_test.go b/mautrix-patched/federation/serverauth_test.go new file mode 100644 index 00000000..07c92ada --- /dev/null +++ b/mautrix-patched/federation/serverauth_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation_test + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/exhttp" + + "maunium.net/go/mautrix/federation" +) + +func TestServerKeyResponse_VerifySelfSignature(t *testing.T) { + cli := federation.NewClient("", nil, nil, exhttp.SensibleClientSettings) + cli.AllowIP = nil + ctx := context.Background() + for _, name := range []string{"matrix.org", "maunium.net", "cd.mau.dev", "uwu.mau.dev"} { + t.Run(name, func(t *testing.T) { + resp, err := cli.ServerKeys(ctx, name) + require.NoError(t, err) + assert.NoError(t, resp.VerifySelfSignature()) + }) + } +} + +func TestServerKeyResponse_FailWithFilter(t *testing.T) { + cli := federation.NewClient("", nil, nil, exhttp.SensibleClientSettings) + cli.AllowIP = func(ip net.IP) bool { + return false + } + ctx := context.Background() + _, err := cli.ServerKeys(ctx, "matrix.org") + assert.ErrorIs(t, err, federation.ErrIPFiltered) +} diff --git a/mautrix-patched/federation/servername.go b/mautrix-patched/federation/servername.go new file mode 100644 index 00000000..33590712 --- /dev/null +++ b/mautrix-patched/federation/servername.go @@ -0,0 +1,95 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "net" + "strconv" + "strings" +) + +func isSpecCompliantIPv6(host string) bool { + // IPv6address = 2*45IPv6char + // IPv6char = DIGIT / %x41-46 / %x61-66 / ":" / "." + // ; 0-9, A-F, a-f, :, . + if len(host) < 2 || len(host) > 45 { + return false + } + for _, ch := range host { + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') && ch != ':' && ch != '.' { + return false + } + } + return true +} + +func isValidIPv4Chunk(str string) bool { + if len(str) == 0 || len(str) > 3 { + return false + } + for _, ch := range str { + if ch < '0' || ch > '9' { + return false + } + } + return true + +} + +func isSpecCompliantIPv4(host string) bool { + // IPv4address = 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT + if len(host) < 7 || len(host) > 15 { + return false + } + parts := strings.Split(host, ".") + return len(parts) == 4 && + isValidIPv4Chunk(parts[0]) && + isValidIPv4Chunk(parts[1]) && + isValidIPv4Chunk(parts[2]) && + isValidIPv4Chunk(parts[3]) +} + +func isSpecCompliantDNSName(host string) bool { + // dns-name = 1*255dns-char + // dns-char = DIGIT / ALPHA / "-" / "." + if len(host) == 0 || len(host) > 255 { + return false + } + for _, ch := range host { + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && ch != '-' && ch != '.' { + return false + } + } + return true +} + +// ParseServerName parses the port and hostname from a Matrix server name and validates that +// it matches the grammar specified in https://spec.matrix.org/v1.11/appendices/#server-name +func ParseServerName(serverName string) (host string, port uint16, ok bool) { + if len(serverName) == 0 || len(serverName) > 255 { + return + } + colonIdx := strings.LastIndexByte(serverName, ':') + if colonIdx > 0 { + u64Port, err := strconv.ParseUint(serverName[colonIdx+1:], 10, 16) + if err == nil { + port = uint16(u64Port) + serverName = serverName[:colonIdx] + } + } + if serverName[0] == '[' { + if serverName[len(serverName)-1] != ']' { + return + } + host = serverName[1 : len(serverName)-1] + ok = isSpecCompliantIPv6(host) && net.ParseIP(host) != nil + } else { + host = serverName + ok = isSpecCompliantDNSName(host) || isSpecCompliantIPv4(host) + } + return +} diff --git a/mautrix-patched/federation/servername_test.go b/mautrix-patched/federation/servername_test.go new file mode 100644 index 00000000..156d692f --- /dev/null +++ b/mautrix-patched/federation/servername_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/federation" +) + +type parseTestCase struct { + name string + serverName string + hostname string + port uint16 +} + +func TestParseServerName(t *testing.T) { + testCases := []parseTestCase{{ + "Domain", + "matrix.org", + "matrix.org", + 0, + }, { + "Domain with port", + "matrix.org:8448", + "matrix.org", + 8448, + }, { + "IPv4 literal", + "1.2.3.4", + "1.2.3.4", + 0, + }, { + "IPv4 literal with port", + "1.2.3.4:8448", + "1.2.3.4", + 8448, + }, { + "IPv6 literal", + "[1234:5678::abcd]", + "1234:5678::abcd", + 0, + }, { + "IPv6 literal with port", + "[1234:5678::abcd]:8448", + "1234:5678::abcd", + 8448, + }} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + hostname, port, ok := federation.ParseServerName(tc.serverName) + assert.True(t, ok) + assert.Equal(t, tc.hostname, hostname) + assert.Equal(t, tc.port, port) + }) + } +} diff --git a/mautrix-patched/federation/signingkey.go b/mautrix-patched/federation/signingkey.go new file mode 100644 index 00000000..fe21ad27 --- /dev/null +++ b/mautrix-patched/federation/signingkey.go @@ -0,0 +1,168 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/tidwall/sjson" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/federation/signutil" + "maunium.net/go/mautrix/id" +) + +// SigningKey is a Matrix federation signing key pair. +type SigningKey struct { + ID id.KeyID + Pub id.SigningKey + Priv ed25519.PrivateKey +} + +// SynapseString returns a string representation of the private key compatible with Synapse's .signing.key file format. +// +// The output of this function can be parsed back into a [SigningKey] using the [ParseSynapseKey] function. +func (sk *SigningKey) SynapseString() string { + alg, keyID := sk.ID.Parse() + return fmt.Sprintf("%s %s %s", alg, keyID, base64.RawStdEncoding.EncodeToString(sk.Priv.Seed())) +} + +// ParseSynapseKey parses a Synapse-compatible private key string into a SigningKey. +func ParseSynapseKey(key string) (*SigningKey, error) { + parts := strings.Split(key, " ") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid key format (expected 3 space-separated parts, got %d)", len(parts)) + } else if parts[0] != string(id.KeyAlgorithmEd25519) { + return nil, fmt.Errorf("unsupported key algorithm %s (only ed25519 is supported)", parts[0]) + } + seed, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid private key: %w", err) + } + priv := ed25519.NewKeyFromSeed(seed) + pub := base64.RawStdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)) + return &SigningKey{ + ID: id.NewKeyID(id.KeyAlgorithmEd25519, parts[1]), + Pub: id.SigningKey(pub), + Priv: priv, + }, nil +} + +// GenerateSigningKey generates a new random signing key. +func GenerateSigningKey() *SigningKey { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + return &SigningKey{ + ID: id.NewKeyID(id.KeyAlgorithmEd25519, base64.RawURLEncoding.EncodeToString(pub[:4])), + Pub: id.SigningKey(base64.RawStdEncoding.EncodeToString(pub)), + Priv: priv, + } +} + +// ServerKeyResponse is the response body for the `GET /_matrix/key/v2/server` endpoint. +// It's also used inside the query endpoint response structs. +type ServerKeyResponse struct { + ServerName string `json:"server_name"` + VerifyKeys map[id.KeyID]ServerVerifyKey `json:"verify_keys"` + OldVerifyKeys map[id.KeyID]OldVerifyKey `json:"old_verify_keys,omitempty"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` + ValidUntilTS jsontime.UnixMilli `json:"valid_until_ts"` + + Raw json.RawMessage `json:"-"` +} + +type QueryKeysResponse struct { + ServerKeys []*ServerKeyResponse `json:"server_keys"` +} + +func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { + if skr == nil { + return false + } else if _, ok := skr.VerifyKeys[keyID]; ok { + return true + } + return false +} + +func (skr *ServerKeyResponse) VerifySelfSignature() error { + for keyID, key := range skr.VerifyKeys { + if err := signutil.VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw); err != nil { + return fmt.Errorf("failed to verify self signature for key %s: %w", keyID, err) + } + } + return nil +} + +type marshalableSKR ServerKeyResponse + +func (skr *ServerKeyResponse) UnmarshalJSON(data []byte) error { + skr.Raw = data + return json.Unmarshal(data, (*marshalableSKR)(skr)) +} + +type ServerVerifyKey struct { + Key id.SigningKey `json:"key"` +} + +func (svk *ServerVerifyKey) Decode() (ed25519.PublicKey, error) { + return base64.RawStdEncoding.DecodeString(string(svk.Key)) +} + +type OldVerifyKey struct { + Key id.SigningKey `json:"key"` + ExpiredTS jsontime.UnixMilli `json:"expired_ts"` +} + +func (sk *SigningKey) SignJSON(data any) (string, error) { + marshaled, err := canonicaljson.Marshal(data) + if err != nil { + return "", err + } + marshaled, err = sjson.DeleteBytes(marshaled, "signatures") + if err != nil { + return "", err + } + err = canonicaljson.Canonicalize(&marshaled) + if err != nil { + return "", fmt.Errorf("failed to canonicalize JSON after deleting signatures: %w", err) + } + return base64.RawStdEncoding.EncodeToString(sk.SignCanonicalJSON(marshaled)), err +} + +func (sk *SigningKey) SignCanonicalJSON(data json.RawMessage) []byte { + return ed25519.Sign(sk.Priv, data) +} + +// GenerateKeyResponse generates a key response signed by this key with the given server name and optionally some old verify keys. +func (sk *SigningKey) GenerateKeyResponse(serverName string, oldVerifyKeys map[id.KeyID]OldVerifyKey) *ServerKeyResponse { + skr := &ServerKeyResponse{ + ServerName: serverName, + OldVerifyKeys: oldVerifyKeys, + ValidUntilTS: jsontime.UM(time.Now().Add(24 * time.Hour)), + VerifyKeys: map[id.KeyID]ServerVerifyKey{ + sk.ID: {Key: sk.Pub}, + }, + } + signature, err := sk.SignJSON(skr) + if err != nil { + panic(err) + } + skr.Signatures = map[string]map[id.KeyID]string{ + serverName: { + sk.ID: signature, + }, + } + return skr +} diff --git a/mautrix-patched/federation/signutil/verify.go b/mautrix-patched/federation/signutil/verify.go new file mode 100644 index 00000000..d8d80a8f --- /dev/null +++ b/mautrix-patched/federation/signutil/verify.go @@ -0,0 +1,120 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package signutil + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.mau.fi/util/exgjson" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/id" +) + +var ErrSignatureNotFound = errors.New("signature not found") +var ErrInvalidSignature = errors.New("invalid signature") + +func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) error { + var err error + message, ok := data.(json.RawMessage) + if !ok { + message, err = canonicaljson.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + } else { + // Canonicalize later may mutate the data, so clone the input + message = bytes.Clone(message) + } + sigVal := gjson.GetBytes(message, exgjson.Path("signatures", serverName, string(keyID))) + if sigVal.Type != gjson.String { + return ErrSignatureNotFound + } + message, err = sjson.DeleteBytes(message, "signatures") + if err != nil { + return fmt.Errorf("failed to delete signatures: %w", err) + } + message, err = sjson.DeleteBytes(message, "unsigned") + if err != nil { + return fmt.Errorf("failed to delete unsigned: %w", err) + } + err = canonicaljson.Canonicalize(&message) + if err != nil { + return fmt.Errorf("failed to canonicalize JSON after deleting signatures and unsigned: %w", err) + } + return VerifyJSONCanonical(key, sigVal.Str, message) +} + +func VerifyJSONAny(key id.SigningKey, data any) error { + var err error + message, ok := data.(json.RawMessage) + if !ok { + message, err = canonicaljson.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + } else { + // Canonicalize later may mutate the data, so clone the input + message = bytes.Clone(message) + } + sigs := gjson.GetBytes(message, "signatures") + if !sigs.IsObject() { + return ErrSignatureNotFound + } + message, err = sjson.DeleteBytes(message, "signatures") + if err != nil { + return fmt.Errorf("failed to delete signatures: %w", err) + } + message, err = sjson.DeleteBytes(message, "unsigned") + if err != nil { + return fmt.Errorf("failed to delete unsigned: %w", err) + } + err = canonicaljson.Canonicalize(&message) + if err != nil { + return fmt.Errorf("failed to canonicalize JSON after deleting signatures and unsigned: %w", err) + } + var validated bool + sigs.ForEach(func(_, value gjson.Result) bool { + if !value.IsObject() { + return true + } + value.ForEach(func(_, value gjson.Result) bool { + if value.Type != gjson.String { + return true + } + validated = VerifyJSONCanonical(key, value.Str, message) == nil + return !validated + }) + return !validated + }) + if !validated { + return ErrInvalidSignature + } + return nil +} + +func VerifyJSONCanonical(key id.SigningKey, sig string, message json.RawMessage) error { + sigBytes, err := base64.RawStdEncoding.DecodeString(sig) + if err != nil { + return fmt.Errorf("failed to decode signature: %w", err) + } + keyBytes, err := base64.RawStdEncoding.DecodeString(string(key)) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + if !ed25519.Verify(keyBytes, message, sigBytes) { + return ErrInvalidSignature + } + return nil +} diff --git a/mautrix-patched/filter.go b/mautrix-patched/filter.go new file mode 100644 index 00000000..54973dab --- /dev/null +++ b/mautrix-patched/filter.go @@ -0,0 +1,95 @@ +// Copyright 2017 Jan Christian Grünhage + +package mautrix + +import ( + "errors" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type EventFormat string + +const ( + EventFormatClient EventFormat = "client" + EventFormatFederation EventFormat = "federation" +) + +// Filter is used by clients to specify how the server should filter responses to e.g. sync requests +// Specified by: https://spec.matrix.org/v1.2/client-server-api/#filtering +type Filter struct { + AccountData *FilterPart `json:"account_data,omitempty"` + EventFields []string `json:"event_fields,omitempty"` + EventFormat EventFormat `json:"event_format,omitempty"` + Presence *FilterPart `json:"presence,omitempty"` + Room *RoomFilter `json:"room,omitempty"` + + BeeperToDevice *FilterPart `json:"com.beeper.to_device,omitempty"` +} + +// RoomFilter is used to define filtering rules for room events +type RoomFilter struct { + AccountData *FilterPart `json:"account_data,omitempty"` + Ephemeral *FilterPart `json:"ephemeral,omitempty"` + IncludeLeave bool `json:"include_leave,omitempty"` + NotRooms []id.RoomID `json:"not_rooms,omitempty"` + Rooms []id.RoomID `json:"rooms,omitempty"` + State *FilterPart `json:"state,omitempty"` + Timeline *FilterPart `json:"timeline,omitempty"` +} + +// FilterPart is used to define filtering rules for specific categories of events +type FilterPart struct { + NotRooms []id.RoomID `json:"not_rooms,omitempty"` + Rooms []id.RoomID `json:"rooms,omitempty"` + Limit int `json:"limit,omitempty"` + NotSenders []id.UserID `json:"not_senders,omitempty"` + NotTypes []event.Type `json:"not_types,omitempty"` + Senders []id.UserID `json:"senders,omitempty"` + Types []event.Type `json:"types,omitempty"` + ContainsURL *bool `json:"contains_url,omitempty"` + LazyLoadMembers bool `json:"lazy_load_members,omitempty"` + IncludeRedundantMembers bool `json:"include_redundant_members,omitempty"` + UnreadThreadNotifications bool `json:"unread_thread_notifications,omitempty"` +} + +// Validate checks if the filter contains valid property values +func (filter *Filter) Validate() error { + if filter.EventFormat != EventFormatClient && filter.EventFormat != EventFormatFederation { + return errors.New("bad event_format value") + } + return nil +} + +// DefaultFilter returns the default filter used by the Matrix server if no filter is provided in the request +func DefaultFilter() Filter { + return Filter{ + AccountData: DefaultFilterPart(), + EventFields: nil, + EventFormat: "client", + Presence: DefaultFilterPart(), + Room: &RoomFilter{ + AccountData: DefaultFilterPart(), + Ephemeral: DefaultFilterPart(), + IncludeLeave: false, + NotRooms: nil, + Rooms: nil, + State: DefaultFilterPart(), + Timeline: DefaultFilterPart(), + }, + } +} + +// DefaultFilterPart returns the default filter part used by the Matrix server if no filter is provided in the request +func DefaultFilterPart() *FilterPart { + return &FilterPart{ + NotRooms: nil, + Rooms: nil, + Limit: 20, + NotSenders: nil, + NotTypes: nil, + Senders: nil, + Types: nil, + } +} diff --git a/mautrix-patched/format/doc.go b/mautrix-patched/format/doc.go new file mode 100644 index 00000000..84a10b60 --- /dev/null +++ b/mautrix-patched/format/doc.go @@ -0,0 +1,5 @@ +// Package format contains utilities for working with Matrix HTML, specifically +// methods to parse Markdown into HTML and to parse Matrix HTML into text or markdown. +// +// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes +package format diff --git a/mautrix-patched/format/htmlparser.go b/mautrix-patched/format/htmlparser.go new file mode 100644 index 00000000..a504fa3a --- /dev/null +++ b/mautrix-patched/format/htmlparser.go @@ -0,0 +1,520 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package format + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + + "go.mau.fi/util/exstrings" + "golang.org/x/net/html" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type TagStack []string + +func (ts TagStack) Index(tag string) int { + for i := len(ts) - 1; i >= 0; i-- { + if ts[i] == tag { + return i + } + } + return -1 +} + +func (ts TagStack) Has(tag string) bool { + return ts.Index(tag) >= 0 +} + +type Context struct { + Ctx context.Context + ReturnData map[string]any + TagStack TagStack + + PreserveWhitespace bool +} + +func NewContext(ctx context.Context) Context { + return Context{ + Ctx: ctx, + ReturnData: map[string]any{}, + TagStack: make(TagStack, 0, 4), + } +} + +func (ctx Context) WithTag(tag string) Context { + ctx.TagStack = append(ctx.TagStack, tag) + return ctx +} + +func (ctx Context) WithWhitespace() Context { + ctx.PreserveWhitespace = true + return ctx +} + +type TextConverter func(string, Context) string +type SpoilerConverter func(text, reason string, ctx Context) string +type LinkConverter func(text, href string, ctx Context) string +type ColorConverter func(text, fg, bg string, ctx Context) string +type CodeBlockConverter func(code, language string, ctx Context) string +type PillConverter func(displayname, mxid, eventID string, ctx Context) string +type ImageConverter func(src, alt, title, width, height string, isEmoji bool) string + +const ContextKeyMentions = "_mentions" + +func DefaultPillConverter(displayname, mxid, eventID string, ctx Context) string { + switch { + case len(mxid) == 0, mxid[0] == '@': + existingMentions, _ := ctx.ReturnData[ContextKeyMentions].([]id.UserID) + ctx.ReturnData[ContextKeyMentions] = append(existingMentions, id.UserID(mxid)) + // User link, always just show the displayname + return displayname + case len(eventID) > 0: + // Event ID link, always just show the link + return fmt.Sprintf("https://matrix.to/#/%s/%s", mxid, eventID) + case mxid[0] == '!' && displayname == mxid: + // Room ID link with no separate display text, just show the link + return fmt.Sprintf("https://matrix.to/#/%s", mxid) + case mxid[0] == '#': + // Room alias link, just show the alias + return mxid + default: + // Other link (e.g. room ID link with display text), show text and link + return fmt.Sprintf("%s (https://matrix.to/#/%s)", displayname, mxid) + } +} + +func onlyBacktickCount(line string) (count int) { + for i := 0; i < len(line); i++ { + if line[i] != '`' { + return -1 + } + count++ + } + return +} + +func DefaultMonospaceBlockConverter(code, language string, ctx Context) string { + if len(code) == 0 || code[len(code)-1] != '\n' { + code += "\n" + } + fence := "```" + for line := range strings.SplitSeq(code, "\n") { + count := onlyBacktickCount(strings.TrimSpace(line)) + if count >= len(fence) { + fence = strings.Repeat("`", count+1) + } + } + return fmt.Sprintf("%s%s\n%s%s", fence, language, code, fence) +} + +// HTMLParser is a somewhat customizable Matrix HTML parser. +type HTMLParser struct { + PillConverter PillConverter + TabsToSpaces int + Newline string + HorizontalLine string + BoldConverter TextConverter + ItalicConverter TextConverter + StrikethroughConverter TextConverter + UnderlineConverter TextConverter + MathConverter TextConverter + MathBlockConverter TextConverter + LinkConverter LinkConverter + SpoilerConverter SpoilerConverter + ColorConverter ColorConverter + MonospaceBlockConverter CodeBlockConverter + MonospaceConverter TextConverter + TextConverter TextConverter + ImageConverter ImageConverter +} + +// TaggedString is a string that also contains a HTML tag. +type TaggedString struct { + string + tag string +} + +func (parser *HTMLParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) { + for _, attr := range node.Attr { + if attr.Key == attribute { + return attr.Val, true + } + } + return "", false +} + +func (parser *HTMLParser) getAttribute(node *html.Node, attribute string) string { + val, _ := parser.maybeGetAttribute(node, attribute) + return val +} + +// Digits counts the number of digits (and the sign, if negative) in an integer. +func Digits(num int) int { + if num == 0 { + return 1 + } else if num < 0 { + return Digits(-num) + 1 + } + return int(math.Floor(math.Log10(float64(num))) + 1) +} + +func (parser *HTMLParser) listToString(node *html.Node, ctx Context) string { + ordered := node.Data == "ol" + taggedChildren := parser.nodeToTaggedStrings(node.FirstChild, ctx) + counter := 1 + indentLength := 0 + if ordered { + start := parser.getAttribute(node, "start") + if len(start) > 0 { + counter, _ = strconv.Atoi(start) + } + + longestIndex := (counter - 1) + len(taggedChildren) + indentLength = Digits(longestIndex) + } + indent := strings.Repeat(" ", indentLength+2) + var children []string + for _, child := range taggedChildren { + if child.tag != "li" { + continue + } + var prefix string + // TODO make bullets and numbering configurable + if ordered { + indexPadding := indentLength - Digits(counter) + if indexPadding < 0 { + // This will happen on negative start indexes where longestIndex is usually wrong, otherwise shouldn't happen + indexPadding = 0 + } + prefix = fmt.Sprintf("%d. %s", counter, strings.Repeat(" ", indexPadding)) + } else { + prefix = "* " + } + str := prefix + child.string + counter++ + parts := strings.Split(str, "\n") + for i, part := range parts[1:] { + parts[i+1] = indent + part + } + str = strings.Join(parts, "\n") + children = append(children, str) + } + return strings.Join(children, "\n") +} + +func (parser *HTMLParser) basicFormatToString(node *html.Node, ctx Context) string { + str := parser.nodeToTagAwareString(node.FirstChild, ctx) + switch node.Data { + case "b", "strong": + if parser.BoldConverter != nil { + return parser.BoldConverter(str, ctx) + } + return fmt.Sprintf("**%s**", str) + case "i", "em": + if parser.ItalicConverter != nil { + return parser.ItalicConverter(str, ctx) + } + return fmt.Sprintf("_%s_", str) + case "s", "del", "strike": + if parser.StrikethroughConverter != nil { + return parser.StrikethroughConverter(str, ctx) + } + return fmt.Sprintf("~~%s~~", str) + case "u", "ins": + if parser.UnderlineConverter != nil { + return parser.UnderlineConverter(str, ctx) + } + case "tt", "code": + if parser.MonospaceConverter != nil { + return parser.MonospaceConverter(str, ctx) + } + return SafeMarkdownCode(str) + } + return str +} + +func (parser *HTMLParser) spanToString(node *html.Node, ctx Context) string { + str := parser.nodeToTagAwareString(node.FirstChild, ctx) + if node.Data == "span" || node.Data == "div" { + math, _ := parser.maybeGetAttribute(node, "data-mx-maths") + if math != "" && parser.MathConverter != nil { + if node.Data == "div" && parser.MathBlockConverter != nil { + str = parser.MathBlockConverter(math, ctx) + } else { + str = parser.MathConverter(math, ctx) + } + } + } + if node.Data == "span" { + reason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler") + if isSpoiler { + if parser.SpoilerConverter != nil { + str = parser.SpoilerConverter(str, reason, ctx) + } else if len(reason) > 0 { + str = fmt.Sprintf("||%s|%s||", reason, str) + } else { + str = fmt.Sprintf("||%s||", str) + } + } + } + if parser.ColorConverter != nil && (node.Data == "span" || node.Data == "font") { + fg := parser.getAttribute(node, "data-mx-color") + if len(fg) == 0 && node.Data == "font" { + fg = parser.getAttribute(node, "color") + } + bg := parser.getAttribute(node, "data-mx-bg-color") + if len(bg) > 0 || len(fg) > 0 { + str = parser.ColorConverter(str, fg, bg, ctx) + } + } + return str +} + +func (parser *HTMLParser) headerToString(node *html.Node, ctx Context) string { + children := parser.nodeToStrings(node.FirstChild, ctx) + length := int(node.Data[1] - '0') + prefix := strings.Repeat("#", length) + " " + return prefix + strings.Join(children, "") +} + +func (parser *HTMLParser) blockquoteToString(node *html.Node, ctx Context) string { + str := parser.nodeToTagAwareString(node.FirstChild, ctx) + childrenArr := strings.Split(strings.TrimSpace(str), "\n") + // TODO make blockquote prefix configurable + for index, child := range childrenArr { + childrenArr[index] = "> " + child + } + return strings.Join(childrenArr, "\n") +} + +func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) string { + str := parser.nodeToTagAwareString(node.FirstChild, ctx) + href := parser.getAttribute(node, "href") + if len(href) == 0 { + return str + } + if parser.PillConverter != nil { + parsedMatrix, err := id.ParseMatrixURIOrMatrixToURL(href) + if err == nil && parsedMatrix != nil { + return parser.PillConverter(str, parsedMatrix.PrimaryIdentifier(), parsedMatrix.SecondaryIdentifier(), ctx) + } + } + if parser.LinkConverter != nil { + return parser.LinkConverter(str, href, ctx) + } else if str == href || + str == strings.TrimPrefix(href, "mailto:") || + str == strings.TrimPrefix(href, "http://") || + str == strings.TrimPrefix(href, "https://") { + return str + } + return fmt.Sprintf("%s (%s)", str, href) +} + +func (parser *HTMLParser) imgToString(node *html.Node, ctx Context) string { + src := parser.getAttribute(node, "src") + alt := parser.getAttribute(node, "alt") + title := parser.getAttribute(node, "title") + width := parser.getAttribute(node, "width") + height := parser.getAttribute(node, "height") + _, isEmoji := parser.maybeGetAttribute(node, "data-mx-emoticon") + if parser.ImageConverter != nil { + return parser.ImageConverter(src, alt, title, width, height, isEmoji) + } + return alt +} + +func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) string { + ctx = ctx.WithTag(node.Data) + switch node.Data { + case "blockquote": + return parser.blockquoteToString(node, ctx) + case "ol", "ul": + return parser.listToString(node, ctx) + case "h1", "h2", "h3", "h4", "h5", "h6": + return parser.headerToString(node, ctx) + case "br": + return parser.Newline + case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tt", "code": + return parser.basicFormatToString(node, ctx) + case "span", "div", "font": + return parser.spanToString(node, ctx) + case "a": + return parser.linkToString(node, ctx) + case "p": + return parser.nodeToTagAwareString(node.FirstChild, ctx) + case "img": + return parser.imgToString(node, ctx) + case "hr": + return parser.HorizontalLine + case "input": + return parser.inputToString(node, ctx) + case "pre": + var preStr, language string + if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { + class := parser.getAttribute(node.FirstChild, "class") + if strings.HasPrefix(class, "language-") { + language = class[len("language-"):] + } + preStr = parser.nodeToString(node.FirstChild.FirstChild, ctx.WithWhitespace()) + } else { + preStr = parser.nodeToString(node.FirstChild, ctx.WithWhitespace()) + } + if parser.MonospaceBlockConverter != nil { + return parser.MonospaceBlockConverter(preStr, language, ctx) + } + return DefaultMonospaceBlockConverter(preStr, language, ctx) + default: + return parser.nodeToTagAwareString(node.FirstChild, ctx) + } +} + +func (parser *HTMLParser) inputToString(node *html.Node, ctx Context) string { + if len(ctx.TagStack) > 1 && ctx.TagStack[len(ctx.TagStack)-2] == "li" { + _, checked := parser.maybeGetAttribute(node, "checked") + if checked { + return "[x]" + } + return "[ ]" + } + return parser.nodeToTagAwareString(node.FirstChild, ctx) +} + +func (parser *HTMLParser) singleNodeToString(node *html.Node, ctx Context) TaggedString { + switch node.Type { + case html.TextNode: + if !ctx.PreserveWhitespace { + node.Data = exstrings.CollapseSpaces(strings.ReplaceAll(node.Data, "\n", "")) + } + if parser.TextConverter != nil { + node.Data = parser.TextConverter(node.Data, ctx) + } + return TaggedString{node.Data, "text"} + case html.ElementNode: + return TaggedString{parser.tagToString(node, ctx), node.Data} + case html.DocumentNode: + return TaggedString{parser.nodeToTagAwareString(node.FirstChild, ctx), "html"} + default: + return TaggedString{"", "unknown"} + } +} + +func (parser *HTMLParser) nodeToTaggedStrings(node *html.Node, ctx Context) (strs []TaggedString) { + for ; node != nil; node = node.NextSibling { + strs = append(strs, parser.singleNodeToString(node, ctx)) + } + return +} + +var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "pre", "blockquote", "div", "hr", "table"} + +func (parser *HTMLParser) isBlockTag(tag string) bool { + for _, blockTag := range BlockTags { + if tag == blockTag { + return true + } + } + return false +} + +func (parser *HTMLParser) nodeToTagAwareString(node *html.Node, ctx Context) string { + strs := parser.nodeToTaggedStrings(node, ctx) + var output strings.Builder + for _, str := range strs { + tstr := str.string + if parser.isBlockTag(str.tag) { + tstr = fmt.Sprintf("\n%s\n", tstr) + } + output.WriteString(tstr) + } + return strings.TrimSpace(output.String()) +} + +func (parser *HTMLParser) nodeToStrings(node *html.Node, ctx Context) (strs []string) { + for ; node != nil; node = node.NextSibling { + strs = append(strs, parser.singleNodeToString(node, ctx).string) + } + return +} + +func (parser *HTMLParser) nodeToString(node *html.Node, ctx Context) string { + return strings.Join(parser.nodeToStrings(node, ctx), "") +} + +// Parse converts Matrix HTML into text using the settings in this parser. +func (parser *HTMLParser) Parse(htmlData string, ctx Context) string { + if parser.TabsToSpaces >= 0 { + htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", parser.TabsToSpaces), -1) + } + node, _ := html.Parse(strings.NewReader(htmlData)) + return parser.nodeToTagAwareString(node, ctx) +} + +var TextHTMLParser = &HTMLParser{ + TabsToSpaces: 4, + Newline: "\n", + HorizontalLine: "\n---\n", + PillConverter: DefaultPillConverter, +} + +var MarkdownHTMLParser = &HTMLParser{ + TabsToSpaces: 4, + Newline: "\n", + HorizontalLine: "\n---\n", + PillConverter: DefaultPillConverter, + LinkConverter: func(text, href string, ctx Context) string { + if text == href { + return fmt.Sprintf("<%s>", href) + } + return fmt.Sprintf("[%s](%s)", text, href) + }, + MathConverter: func(s string, c Context) string { + return fmt.Sprintf("$%s$", s) + }, + MathBlockConverter: func(s string, c Context) string { + return fmt.Sprintf("$$\n%s\n$$", s) + }, + UnderlineConverter: func(s string, c Context) string { + return fmt.Sprintf("%s", s) + }, +} + +// HTMLToText converts Matrix HTML into text with the default settings. +func HTMLToText(html string) string { + return (&HTMLParser{ + TabsToSpaces: 4, + Newline: "\n", + HorizontalLine: "\n---\n", + PillConverter: DefaultPillConverter, + }).Parse(html, NewContext(context.TODO())) +} + +func HTMLToMarkdownFull(parser *HTMLParser, html string) (parsed string, mentions *event.Mentions) { + if parser == nil { + parser = MarkdownHTMLParser + } + ctx := NewContext(context.TODO()) + parsed = parser.Parse(html, ctx) + mentionList, _ := ctx.ReturnData[ContextKeyMentions].([]id.UserID) + mentions = &event.Mentions{ + UserIDs: mentionList, + } + return +} + +// HTMLToMarkdown converts Matrix HTML into markdown with the default settings. +// +// Currently, the only difference to HTMLToText is how links are formatted. +func HTMLToMarkdown(html string) string { + parsed, _ := HTMLToMarkdownFull(nil, html) + return parsed +} diff --git a/mautrix-patched/format/markdown.go b/mautrix-patched/format/markdown.go new file mode 100644 index 00000000..77ced0dc --- /dev/null +++ b/mautrix-patched/format/markdown.go @@ -0,0 +1,145 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package format + +import ( + "fmt" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" + "go.mau.fi/util/exstrings" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format/mdext" + "maunium.net/go/mautrix/id" +) + +const paragraphStart = "

      " +const paragraphEnd = "

      " + +var Extensions = goldmark.WithExtensions(extension.Strikethrough, extension.Table, mdext.Spoiler) +var HTMLOptions = goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()) + +var withHTML = goldmark.New(Extensions, HTMLOptions) +var noHTML = goldmark.New(Extensions, HTMLOptions, goldmark.WithExtensions(mdext.EscapeHTML)) + +// UnwrapSingleParagraph removes paragraph tags surrounding a string if the string only contains a single paragraph. +func UnwrapSingleParagraph(html string) string { + html = strings.TrimRight(html, "\n") + if strings.HasPrefix(html, paragraphStart) && strings.HasSuffix(html, paragraphEnd) { + htmlBodyWithoutP := html[len(paragraphStart) : len(html)-len(paragraphEnd)] + if !strings.Contains(htmlBodyWithoutP, paragraphStart) { + return htmlBodyWithoutP + } + } + return html +} + +var mdEscapeRegex = regexp.MustCompile("([\\\\`*_[\\]()])") + +func EscapeMarkdown(text string) string { + text = mdEscapeRegex.ReplaceAllString(text, "\\$1") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, "<", "<") + return text +} + +type uriAble interface { + String() string + URI() *id.MatrixURI +} + +func MarkdownMention(id uriAble) string { + return MarkdownMentionWithName(id.String(), id) +} + +func MarkdownMentionWithName(name string, id uriAble) string { + return MarkdownLink(name, id.URI().MatrixToURL()) +} + +func MarkdownMentionRoomID(name string, id id.RoomID, via ...string) string { + if name == "" { + name = id.String() + } + return MarkdownLink(name, id.URI(via...).MatrixToURL()) +} + +func MarkdownLink(name string, url string) string { + return fmt.Sprintf("[%s](%s)", EscapeMarkdown(name), EscapeMarkdown(url)) +} + +func SafeMarkdownCode[T ~string](textInput T) string { + if textInput == "" { + return "` `" + } + text := strings.ReplaceAll(string(textInput), "\n", " ") + backtickCount := exstrings.LongestSequenceOf(text, '`') + if backtickCount == 0 { + return fmt.Sprintf("`%s`", text) + } + quotes := strings.Repeat("`", backtickCount+1) + if text[0] == '`' || text[len(text)-1] == '`' { + return fmt.Sprintf("%s %s %s", quotes, text, quotes) + } + return fmt.Sprintf("%s%s%s", quotes, text, quotes) +} + +func RenderMarkdownCustom(text string, renderer goldmark.Markdown) event.MessageEventContent { + var buf strings.Builder + err := renderer.Convert([]byte(text), &buf) + if err != nil { + panic(fmt.Errorf("markdown parser errored: %w", err)) + } + htmlBody := UnwrapSingleParagraph(buf.String()) + return HTMLToContent(htmlBody) +} + +func TextToContent(text string) event.MessageEventContent { + return event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + Mentions: &event.Mentions{}, + } +} + +func HTMLToContentFull(renderer *HTMLParser, html string) event.MessageEventContent { + text, mentions := HTMLToMarkdownFull(renderer, html) + if html != text { + return event.MessageEventContent{ + FormattedBody: html, + Format: event.FormatHTML, + MsgType: event.MsgText, + Body: text, + Mentions: mentions, + } + } + return TextToContent(text) +} + +func HTMLToContent(html string) event.MessageEventContent { + return HTMLToContentFull(nil, html) +} + +func RenderMarkdown(text string, allowMarkdown, allowHTML bool) event.MessageEventContent { + var htmlBody string + + if allowMarkdown { + rndr := withHTML + if !allowHTML { + rndr = noHTML + } + return RenderMarkdownCustom(text, rndr) + } else if allowHTML { + htmlBody = strings.Replace(text, "\n", "
      ", -1) + return HTMLToContent(htmlBody) + } else { + return TextToContent(text) + } +} diff --git a/mautrix-patched/format/markdown_test.go b/mautrix-patched/format/markdown_test.go new file mode 100644 index 00000000..46ea4886 --- /dev/null +++ b/mautrix-patched/format/markdown_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package format_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/format/mdext" + "maunium.net/go/mautrix/id" +) + +func TestRenderMarkdown_PlainText(t *testing.T) { + content := format.RenderMarkdown("hello world", true, true) + assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) + content = format.RenderMarkdown("hello world", true, false) + assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) + content = format.RenderMarkdown("hello world", false, true) + assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) + content = format.RenderMarkdown("hello world", false, false) + assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) + content = format.RenderMarkdown(`
      mention`, false, false) + assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "mention", Mentions: &event.Mentions{}}, content) +} + +func TestRenderMarkdown_EscapeHTML(t *testing.T) { + content := format.RenderMarkdown("hello world", true, false) + assert.Equal(t, event.MessageEventContent{ + MsgType: event.MsgText, + Body: "hello world", + Format: event.FormatHTML, + FormattedBody: "<b>hello world</b>", + Mentions: &event.Mentions{}, + }, content) +} + +func TestRenderMarkdown_HTML(t *testing.T) { + content := format.RenderMarkdown("hello world", false, true) + assert.Equal(t, event.MessageEventContent{ + MsgType: event.MsgText, + Body: "**hello world**", + Format: event.FormatHTML, + FormattedBody: "hello world", + Mentions: &event.Mentions{}, + }, content) + + content = format.RenderMarkdown("hello world", true, true) + assert.Equal(t, event.MessageEventContent{ + MsgType: event.MsgText, + Body: "**hello world**", + Format: event.FormatHTML, + FormattedBody: "hello world", + Mentions: &event.Mentions{}, + }, content) + + content = format.RenderMarkdown(`[mention](https://matrix.to/#/@user:example.com)`, true, false) + assert.Equal(t, event.MessageEventContent{ + MsgType: event.MsgText, + Body: "mention", + Format: event.FormatHTML, + FormattedBody: `mention`, + Mentions: &event.Mentions{ + UserIDs: []id.UserID{"@user:example.com"}, + }, + }, content) +} + +var spoilerTests = map[string]string{ + "test ||bar||": "test bar", + "test ||reason|**bar**||": `test bar`, + + "test ||reason|[bar](https://example.com)||": `test bar`, + "test [||reason|foo||](https://example.com)": `test foo`, + "test [||foo||](https://example.com)": `test foo`, + "test [||_foo_||](https://example.com)": `test foo`, + // FIXME wrapping spoilers in italic/bold/strikethrough doesn't work for some reason + //"test **[||foo||](https://example.com)**": `test foo`, + //"test **||foo||**": `test foo`, + + "* ||foo||": `
      • foo
      `, + "> ||foo||": "

      foo

      ", +} + +func TestRenderMarkdown_Spoiler(t *testing.T) { + for markdown, html := range spoilerTests { + rendered := format.RenderMarkdown(markdown, true, false) + assert.Equal(t, markdown, rendered.Body) + assert.Equal(t, html, strings.ReplaceAll(rendered.FormattedBody, "\n", "")) + } +} + +var simpleSpoilerTests = map[string]string{ + "test ||bar||": "test bar", + "test [||foo||](https://example.com)": `test foo`, + "test [||*foo*||](https://example.com)": `test foo`, + "* ||foo||": `
      • foo
      `, + "> ||foo||": "

      foo

      ", + + // Simple spoiler renderer supports wrapping fully already + "test **[||foo||](https://example.com)**": `test foo`, + "test **||foo||**": `test foo`, + "test **||*foo*||**": `test foo`, + "test ~~**||*foo*||**~~": `test foo`, + "> ||~~***foo***~~||": "

      foo

      ", + + "a \\||test||": "a ||test||", + "\\~~test~~": "~~test~~", + "\\*test* hmm": "*test* hmm", +} + +func render(renderer goldmark.Markdown, text string) string { + var buf strings.Builder + err := renderer.Convert([]byte(text), &buf) + if err != nil { + panic(err) + } + return buf.String() +} + +func TestRenderMarkdown_SimpleSpoiler(t *testing.T) { + renderer := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.EscapeHTML), format.HTMLOptions) + for markdown, html := range simpleSpoilerTests { + rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) + assert.Equal(t, html, strings.ReplaceAll(rendered, "\n", "")) + } +} + +var discordUnderlineTests = map[string]string{ + "**test**": "test", + "*test*": "test", + "_test_": "test", + "__test__": "test", + "__*test*__": "test", + "___test___": "test", + "____test____": "test", + "**__test__**": "test", + "__***test***__": "test", + "__~~***test***~~__": "test", + + //"\\__test__": "__test__", + //"\\**test**": "**test**", +} + +func TestRenderMarkdown_DiscordUnderline(t *testing.T) { + renderer := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, mdext.DiscordUnderline, mdext.EscapeHTML), format.HTMLOptions) + for markdown, html := range discordUnderlineTests { + rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) + assert.Equal(t, html, strings.ReplaceAll(rendered, "\n", "")) + } +} + +var mathTests = map[string]string{ + "$foo$": `foo`, + "hello $foo$ world": `hello foo world`, + "$$\nfoo\nbar\n$$": `
      foo
      bar
      `, + "`$foo$`": `$foo$`, + "```\n$foo$\n```": `
      $foo$\n
      `, + "~~meow $foo$ asd~~": `meow foo asd`, + "$5 or $10": `$5 or $10`, + "5$ or 10$": `5$ or 10$`, + "$5 or 10$": `5 or 10`, + "$*500*$": `*500*`, + "$$\n*500*\n$$": `
      *500*
      `, + + // TODO: This doesn't work :( + // Maybe same reason as the spoiler wrapping not working? + //"~~$foo$~~": `foo`, +} + +func TestRenderMarkdown_Math(t *testing.T) { + renderer := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, mdext.Math, mdext.EscapeHTML), format.HTMLOptions) + for markdown, html := range mathTests { + rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) + assert.Equal(t, html, strings.ReplaceAll(rendered, "\n", "\\n"), "with input %q", markdown) + } +} + +var customEmojiTests = map[string]string{ + `![:meow:](mxc://example.com/emoji.png "Emoji: meow")`: `:meow:`, +} + +func TestRenderMarkdown_CustomEmoji(t *testing.T) { + renderer := goldmark.New(goldmark.WithExtensions(mdext.CustomEmoji), format.HTMLOptions) + for markdown, html := range customEmojiTests { + rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) + assert.Equal(t, html, rendered, "with input %q", markdown) + } +} + +var codeTests = map[string]string{ + "meow": "`meow`", + "me`ow": "``me`ow``", + "`me`ow": "`` `me`ow ``", + "me`ow`": "`` me`ow` ``", + "`meow`": "`` `meow` ``", + "`````````": "`````````` ````````` ``````````", +} + +func TestSafeMarkdownCode(t *testing.T) { + for input, expected := range codeTests { + assert.Equal(t, expected, format.SafeMarkdownCode(input), "with input %q", input) + } +} diff --git a/mautrix-patched/format/mdext/customemoji.go b/mautrix-patched/format/mdext/customemoji.go new file mode 100644 index 00000000..a8adad37 --- /dev/null +++ b/mautrix-patched/format/mdext/customemoji.go @@ -0,0 +1,73 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +type extCustomEmoji struct{} +type customEmojiRenderer struct { + funcs functionCapturer +} + +// CustomEmoji is an extension that converts certain markdown images into Matrix custom emojis. +var CustomEmoji = &extCustomEmoji{} + +type functionCapturer struct { + renderImage renderer.NodeRendererFunc + renderText renderer.NodeRendererFunc + renderString renderer.NodeRendererFunc +} + +func (fc *functionCapturer) Register(kind ast.NodeKind, rendererFunc renderer.NodeRendererFunc) { + switch kind { + case ast.KindImage: + fc.renderImage = rendererFunc + case ast.KindText: + fc.renderText = rendererFunc + case ast.KindString: + fc.renderString = rendererFunc + } +} + +var ( + _ renderer.NodeRendererFuncRegisterer = (*functionCapturer)(nil) + _ renderer.Option = (*functionCapturer)(nil) +) + +func (fc *functionCapturer) SetConfig(cfg *renderer.Config) { + cfg.NodeRenderers[0].Value.(renderer.NodeRenderer).RegisterFuncs(fc) +} + +func (eeh *extCustomEmoji) Extend(m goldmark.Markdown) { + var fc functionCapturer + m.Renderer().AddOptions(&fc) + m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(&customEmojiRenderer{fc}, 0))) +} + +func (cer *customEmojiRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindImage, cer.renderImage) +} + +var emojiPrefix = []byte("Emoji: ") +var uriSchemeSeparator = []byte("://") + +func (cer *customEmojiRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n, ok := node.(*ast.Image) + if ok && entering && bytes.HasPrefix(n.Title, emojiPrefix) && bytes.Contains(n.Destination, uriSchemeSeparator) { + n.Title = bytes.TrimPrefix(n.Title, emojiPrefix) + n.SetAttributeString("data-mx-emoticon", nil) + n.SetAttributeString("height", "32") + } + return cer.funcs.renderImage(w, source, node, entering) +} diff --git a/mautrix-patched/format/mdext/discordunderline.go b/mautrix-patched/format/mdext/discordunderline.go new file mode 100644 index 00000000..c52e86d9 --- /dev/null +++ b/mautrix-patched/format/mdext/discordunderline.go @@ -0,0 +1,137 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type astDiscordUnderline struct { + ast.BaseInline +} + +func (n *astDiscordUnderline) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +var astKindDiscordUnderline = ast.NewNodeKind("DiscordUnderline") + +func (n *astDiscordUnderline) Kind() ast.NodeKind { + return astKindDiscordUnderline +} + +type discordUnderlineDelimiterProcessor struct{} + +func (p *discordUnderlineDelimiterProcessor) IsDelimiter(b byte) bool { + return b == '_' +} + +func (p *discordUnderlineDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == closer.Char +} + +func (p *discordUnderlineDelimiterProcessor) OnMatch(consumes int) ast.Node { + if consumes == 1 { + // Slightly hacky hack: if the delimiter parser tries to give us text wrapped with a single underline, + // send it over to the emphasis area instead of returning an underline node. + return ast.NewEmphasis(consumes) + } + return &astDiscordUnderline{} +} + +var defaultDiscordUnderlineDelimiterProcessor = &discordUnderlineDelimiterProcessor{} + +type discordUnderlineParser struct{} + +var defaultDiscordUnderlineParser = &discordUnderlineParser{} + +// NewDiscordUnderlineParser return a new InlineParser that parses +// Discord underline expressions. +func NewDiscordUnderlineParser() parser.InlineParser { + return defaultDiscordUnderlineParser +} + +func (s *discordUnderlineParser) Trigger() []byte { + return []byte{'_'} +} + +func (s *discordUnderlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 2, defaultDiscordUnderlineDelimiterProcessor) + if node == nil { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +func (s *discordUnderlineParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +// discordUnderlineHTMLRenderer is a renderer.NodeRenderer implementation that +// renders discord underline nodes. +type discordUnderlineHTMLRenderer struct { + html.Config +} + +// NewDiscordUnderlineHTMLRenderer returns a new discordUnderlineHTMLRenderer. +func NewDiscordUnderlineHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &discordUnderlineHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *discordUnderlineHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(astKindDiscordUnderline, r.renderDiscordUnderline) +} + +var DiscordUnderlineAttributeFilter = html.GlobalAttributeFilter + +func (r *discordUnderlineHTMLRenderer) renderDiscordUnderline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if n.Attributes() != nil { + _, _ = w.WriteString("') + } else { + _, _ = w.WriteString("") + } + } else { + _, _ = w.WriteString("") + } + return ast.WalkContinue, nil +} + +type discordUnderline struct{} + +// DiscordUnderline is an extension that allow you to use underline expression like '__text__' . +var DiscordUnderline = &discordUnderline{} + +func (e *discordUnderline) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + // This must be a higher priority (= lower priority number) than the emphasis parser + // in https://github.com/yuin/goldmark/blob/v1.4.12/parser/parser.go#L601 + util.Prioritized(NewDiscordUnderlineParser(), 450), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewDiscordUnderlineHTMLRenderer(), 500), + )) +} diff --git a/mautrix-patched/format/mdext/filteredparser.go b/mautrix-patched/format/mdext/filteredparser.go new file mode 100644 index 00000000..3b6a4153 --- /dev/null +++ b/mautrix-patched/format/mdext/filteredparser.go @@ -0,0 +1,48 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "reflect" + + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/util" +) + +func filterParsers(list []util.PrioritizedValue, forbidden map[reflect.Type]struct{}) []util.PrioritizedValue { + n := 0 + for _, item := range list { + if _, isForbidden := forbidden[reflect.TypeOf(item.Value)]; isForbidden { + continue + } + list[n] = item + n++ + } + return list[:n] +} + +// ParserWithoutFeatures returns a Goldmark parser with the provided default features removed. +// +// e.g. to disable lists, use +// +// markdown := goldmark.New(goldmark.WithParser( +// mdext.ParserWithoutFeatures(goldmark.NewListParser(), goldmark.NewListItemParser()) +// )) +func ParserWithoutFeatures(features ...any) parser.Parser { + forbiddenTypes := make(map[reflect.Type]struct{}, len(features)) + for _, feature := range features { + forbiddenTypes[reflect.TypeOf(feature)] = struct{}{} + } + filteredBlockParsers := filterParsers(parser.DefaultBlockParsers(), forbiddenTypes) + filteredInlineParsers := filterParsers(parser.DefaultInlineParsers(), forbiddenTypes) + filteredParagraphTransformers := filterParsers(parser.DefaultParagraphTransformers(), forbiddenTypes) + return parser.NewParser( + parser.WithBlockParsers(filteredBlockParsers...), + parser.WithInlineParsers(filteredInlineParsers...), + parser.WithParagraphTransformers(filteredParagraphTransformers...), + ) +} diff --git a/mautrix-patched/format/mdext/indentableparagraph.go b/mautrix-patched/format/mdext/indentableparagraph.go new file mode 100644 index 00000000..a6ebd6c0 --- /dev/null +++ b/mautrix-patched/format/mdext/indentableparagraph.go @@ -0,0 +1,28 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/util" +) + +// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine. +// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear). +type indentableParagraphParser struct { + parser.BlockParser +} + +var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()} + +func (b *indentableParagraphParser) CanAcceptIndentedLine() bool { + return true +} + +// FixIndentedParagraphs is a goldmark option which fixes indented paragraphs when disabling CodeBlockParser. +var FixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500))) diff --git a/mautrix-patched/format/mdext/math.go b/mautrix-patched/format/mdext/math.go new file mode 100644 index 00000000..e6a6ecc5 --- /dev/null +++ b/mautrix-patched/format/mdext/math.go @@ -0,0 +1,240 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "bytes" + "fmt" + stdhtml "html" + "regexp" + "strings" + "unicode" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var astKindMath = ast.NewNodeKind("Math") + +type astMath struct { + ast.BaseInline + value []byte +} + +func (n *astMath) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func (n *astMath) Kind() ast.NodeKind { + return astKindMath +} + +type astMathBlock struct { + ast.BaseBlock +} + +func (n *astMathBlock) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func (n *astMathBlock) Kind() ast.NodeKind { + return astKindMath +} + +type inlineMathParser struct{} + +var defaultInlineMathParser = &inlineMathParser{} + +func NewInlineMathParser() parser.InlineParser { + return defaultInlineMathParser +} + +const mathDelimiter = '$' + +func (s *inlineMathParser) Trigger() []byte { + return []byte{mathDelimiter} +} + +// This ignores lines where there's no space after the closing $ to avoid false positives +var latexInlineRegexp = regexp.MustCompile(`^(\$[^$]*\$)(?:$|\s)`) + +func (s *inlineMathParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + // Ignore lines where the opening $ comes after a letter or number to avoid false positives + if unicode.IsLetter(before) || unicode.IsNumber(before) { + return nil + } + line, segment := block.PeekLine() + idx := latexInlineRegexp.FindSubmatchIndex(line) + if idx == nil { + return nil + } + block.Advance(idx[3]) + return &astMath{ + value: block.Value(text.NewSegment(segment.Start+1, segment.Start+idx[3]-1)), + } +} + +func (s *inlineMathParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +type blockMathParser struct{} + +var defaultBlockMathParser = &blockMathParser{} + +func NewBlockMathParser() parser.BlockParser { + return defaultBlockMathParser +} + +var mathBlockInfoKey = parser.NewContextKey() + +type mathBlockData struct { + indent int + length int + node ast.Node +} + +func (b *blockMathParser) Trigger() []byte { + return []byte{'$'} +} + +func (b *blockMathParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + line, _ := reader.PeekLine() + pos := pc.BlockOffset() + if pos < 0 || (line[pos] != mathDelimiter) { + return nil, parser.NoChildren + } + findent := pos + i := pos + for ; i < len(line) && line[i] == mathDelimiter; i++ { + } + oFenceLength := i - pos + if oFenceLength < 2 { + return nil, parser.NoChildren + } + if i < len(line)-1 { + rest := line[i:] + left := util.TrimLeftSpaceLength(rest) + right := util.TrimRightSpaceLength(rest) + if left < len(rest)-right { + value := rest[left : len(rest)-right] + if bytes.IndexByte(value, mathDelimiter) > -1 { + return nil, parser.NoChildren + } + } + } + node := &astMathBlock{} + pc.Set(mathBlockInfoKey, &mathBlockData{findent, oFenceLength, node}) + return node, parser.NoChildren + +} + +func (b *blockMathParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + line, segment := reader.PeekLine() + fdata := pc.Get(mathBlockInfoKey).(*mathBlockData) + + w, pos := util.IndentWidth(line, reader.LineOffset()) + if w < 4 { + i := pos + for ; i < len(line) && line[i] == mathDelimiter; i++ { + } + length := i - pos + if length >= fdata.length && util.IsBlank(line[i:]) { + newline := 1 + if line[len(line)-1] != '\n' { + newline = 0 + } + reader.Advance(segment.Stop - segment.Start - newline + segment.Padding) + return parser.Close + } + } + pos, padding := util.IndentPositionPadding(line, reader.LineOffset(), segment.Padding, fdata.indent) + if pos < 0 { + pos = util.FirstNonSpacePosition(line) + if pos < 0 { + pos = 0 + } + padding = 0 + } + seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding) + seg.ForceNewline = true // EOF as newline + node.Lines().Append(seg) + reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding) + return parser.Continue | parser.NoChildren +} + +func (b *blockMathParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { + fdata := pc.Get(mathBlockInfoKey).(*mathBlockData) + if fdata.node == node { + pc.Set(mathBlockInfoKey, nil) + } +} + +func (b *blockMathParser) CanInterruptParagraph() bool { + return true +} + +func (b *blockMathParser) CanAcceptIndentedLine() bool { + return false +} + +type mathHTMLRenderer struct { + html.Config +} + +func NewMathHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &mathHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *mathHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(astKindMath, r.renderMath) +} + +func (r *mathHTMLRenderer) renderMath(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + tag := "span" + var tex string + switch typed := n.(type) { + case *astMathBlock: + tag = "div" + tex = string(n.Lines().Value(source)) + case *astMath: + tex = string(typed.value) + } + tex = stdhtml.EscapeString(strings.TrimSpace(tex)) + _, _ = fmt.Fprintf(w, `<%s data-mx-maths="%s">%s`, tag, tex, strings.ReplaceAll(tex, "\n", "
      "), tag) + } + return ast.WalkSkipChildren, nil +} + +type math struct{} + +// Math is an extension that allow you to use math like '$$text$$'. +var Math = &math{} + +func (e *math) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(NewInlineMathParser(), 500), + ), parser.WithBlockParsers( + util.Prioritized(NewBlockMathParser(), 850), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewMathHTMLRenderer(), 500), + )) +} diff --git a/mautrix-patched/format/mdext/nohtml.go b/mautrix-patched/format/mdext/nohtml.go new file mode 100644 index 00000000..bee69828 --- /dev/null +++ b/mautrix-patched/format/mdext/nohtml.go @@ -0,0 +1,61 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type extEscapeHTML struct{} +type escapingHTMLRenderer struct{} + +// EscapeHTML is an extension that escapes HTML in the input markdown instead of passing it through as-is. +var EscapeHTML = &extEscapeHTML{} +var defaultEHR = &escapingHTMLRenderer{} + +func (eeh *extEscapeHTML) Extend(m goldmark.Markdown) { + m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultEHR, 0))) +} + +func (ehr *escapingHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindHTMLBlock, ehr.renderHTMLBlock) + reg.Register(ast.KindRawHTML, ehr.renderRawHTML) +} + +func (ehr *escapingHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + n := node.(*ast.RawHTML) + l := n.Segments.Len() + for i := 0; i < l; i++ { + segment := n.Segments.At(i) + html.DefaultWriter.RawWrite(w, segment.Value(source)) + } + return ast.WalkSkipChildren, nil +} + +func (ehr *escapingHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.HTMLBlock) + if entering { + l := n.Lines().Len() + for i := 0; i < l; i++ { + line := n.Lines().At(i) + html.DefaultWriter.RawWrite(w, line.Value(source)) + } + } else { + if n.HasClosure() { + closure := n.ClosureLine + html.DefaultWriter.RawWrite(w, closure.Value(source)) + } + } + return ast.WalkContinue, nil +} diff --git a/mautrix-patched/format/mdext/shortemphasis.go b/mautrix-patched/format/mdext/shortemphasis.go new file mode 100644 index 00000000..62190326 --- /dev/null +++ b/mautrix-patched/format/mdext/shortemphasis.go @@ -0,0 +1,96 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var ShortEmphasis goldmark.Extender = &shortEmphasisExtender{} + +type shortEmphasisExtender struct{} + +func (s *shortEmphasisExtender) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&italicsParser{}, 500), + util.Prioritized(&boldParser{}, 500), + )) +} + +type italicsDelimiterProcessor struct{} + +func (p *italicsDelimiterProcessor) IsDelimiter(b byte) bool { + return b == '_' +} + +func (p *italicsDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == closer.Char +} + +func (p *italicsDelimiterProcessor) OnMatch(consumes int) ast.Node { + return ast.NewEmphasis(1) +} + +var defaultItalicsDelimiterProcessor = &italicsDelimiterProcessor{} + +type italicsParser struct{} + +func (s *italicsParser) Trigger() []byte { + return []byte{'_'} +} + +func (s *italicsParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultItalicsDelimiterProcessor) + if node == nil || node.OriginalLength > 1 || before == '_' { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +type boldDelimiterProcessor struct{} + +func (p *boldDelimiterProcessor) IsDelimiter(b byte) bool { + return b == '*' +} + +func (p *boldDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == closer.Char +} + +func (p *boldDelimiterProcessor) OnMatch(consumes int) ast.Node { + return ast.NewEmphasis(2) +} + +var defaultBoldDelimiterProcessor = &boldDelimiterProcessor{} + +type boldParser struct{} + +func (s *boldParser) Trigger() []byte { + return []byte{'*'} +} + +func (s *boldParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultBoldDelimiterProcessor) + if node == nil || node.OriginalLength > 1 || before == '*' { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} diff --git a/mautrix-patched/format/mdext/shortstrike.go b/mautrix-patched/format/mdext/shortstrike.go new file mode 100644 index 00000000..00328f22 --- /dev/null +++ b/mautrix-patched/format/mdext/shortstrike.go @@ -0,0 +1,76 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "github.com/yuin/goldmark" + gast "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var ShortStrike goldmark.Extender = &shortStrikeExtender{length: 1} +var LongStrike goldmark.Extender = &shortStrikeExtender{length: 2} + +type shortStrikeExtender struct { + length int +} + +func (s *shortStrikeExtender) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&strikethroughParser{length: s.length}, 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(extension.NewStrikethroughHTMLRenderer(), 500), + )) +} + +type strikethroughDelimiterProcessor struct{} + +func (p *strikethroughDelimiterProcessor) IsDelimiter(b byte) bool { + return b == '~' +} + +func (p *strikethroughDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == closer.Char +} + +func (p *strikethroughDelimiterProcessor) OnMatch(consumes int) gast.Node { + return ast.NewStrikethrough() +} + +var defaultStrikethroughDelimiterProcessor = &strikethroughDelimiterProcessor{} + +type strikethroughParser struct { + length int +} + +func (s *strikethroughParser) Trigger() []byte { + return []byte{'~'} +} + +func (s *strikethroughParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { + before := block.PrecendingCharacter() + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultStrikethroughDelimiterProcessor) + if node == nil || node.OriginalLength != s.length || before == '~' { + return nil + } + + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +func (s *strikethroughParser) CloseBlock(parent gast.Node, pc parser.Context) { + // nothing to do +} diff --git a/mautrix-patched/format/mdext/simplespoiler.go b/mautrix-patched/format/mdext/simplespoiler.go new file mode 100644 index 00000000..a72ad8cd --- /dev/null +++ b/mautrix-patched/format/mdext/simplespoiler.go @@ -0,0 +1,62 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type simpleSpoilerParser struct{} + +var defaultSimpleSpoilerParser = &simpleSpoilerParser{} + +func NewSimpleSpoilerParser() parser.InlineParser { + return defaultSimpleSpoilerParser +} + +func (s *simpleSpoilerParser) Trigger() []byte { + return []byte{'|'} +} + +func (s *simpleSpoilerParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + // This is basically copied from https://github.com/yuin/goldmark/blob/master/extension/strikethrough.go + before := block.PrecendingCharacter() + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 2, defaultSpoilerDelimiterProcessor) + if node == nil { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +func (s *simpleSpoilerParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +type simpleSpoiler struct{} + +// SimpleSpoiler is an extension that allow you to use simple spoiler expression like '||text||' . +// +// For spoilers with reasons ('||reason|text||'), use the Spoiler extension. +var SimpleSpoiler = &simpleSpoiler{} + +func (e *simpleSpoiler) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(NewSimpleSpoilerParser(), 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewSpoilerHTMLRenderer(), 500), + )) +} diff --git a/mautrix-patched/format/mdext/spoiler.go b/mautrix-patched/format/mdext/spoiler.go new file mode 100644 index 00000000..bd2f6d2e --- /dev/null +++ b/mautrix-patched/format/mdext/spoiler.go @@ -0,0 +1,168 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mdext + +import ( + "bytes" + "fmt" + stdhtml "html" + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var astKindSpoiler = ast.NewNodeKind("Spoiler") + +type astSpoiler struct { + ast.BaseInline + reason string +} + +func (n *astSpoiler) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func (n *astSpoiler) Kind() ast.NodeKind { + return astKindSpoiler +} + +type spoilerDelimiterProcessor struct{} + +var defaultSpoilerDelimiterProcessor = &spoilerDelimiterProcessor{} + +func (p *spoilerDelimiterProcessor) IsDelimiter(b byte) bool { + return b == '|' +} + +func (p *spoilerDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == closer.Char +} + +func (p *spoilerDelimiterProcessor) OnMatch(consumes int) ast.Node { + return &astSpoiler{} +} + +type spoilerParser struct{} + +var defaultSpoilerParser = &spoilerParser{} + +func NewSpoilerParser() parser.InlineParser { + return defaultSpoilerParser +} + +func (s *spoilerParser) Trigger() []byte { + return []byte{'|'} +} + +var spoilerRegex = regexp.MustCompile(`^\|\|(?:([^|]+?)\|[^|])?`) +var spoilerContextKey = parser.NewContextKey() + +type spoilerContext struct { + reason string + segment text.Segment + bottom *parser.Delimiter +} + +func (s *spoilerParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, segment := block.PeekLine() + if spoiler, ok := pc.Get(spoilerContextKey).(spoilerContext); ok { + if !bytes.HasPrefix(line, []byte("||")) { + return nil + } + block.Advance(2) + pc.Set(spoilerContextKey, nil) + n := &astSpoiler{ + BaseInline: ast.BaseInline{}, + reason: spoiler.reason, + } + parser.ProcessDelimiters(spoiler.bottom, pc) + var c ast.Node = spoiler.bottom + for c != nil { + next := c.NextSibling() + parent.RemoveChild(parent, c) + n.AppendChild(n, c) + c = next + } + return n + } + match := spoilerRegex.FindSubmatch(line) + if match == nil { + return nil + } + length := 2 + reason := string(match[1]) + if len(reason) > 0 { + length += len(match[1]) + 1 + } + block.Advance(length) + delim := parser.NewDelimiter(true, false, length, '|', defaultSpoilerDelimiterProcessor) + pc.Set(spoilerContextKey, spoilerContext{ + reason: reason, + segment: segment, + bottom: delim, + }) + return delim +} + +func (s *spoilerParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +type spoilerHTMLRenderer struct { + html.Config +} + +func NewSpoilerHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &spoilerHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *spoilerHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(astKindSpoiler, r.renderSpoiler) +} + +func (r *spoilerHTMLRenderer) renderSpoiler(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + node := n.(*astSpoiler) + if len(node.reason) == 0 { + _, _ = w.WriteString("") + } else { + _, _ = fmt.Fprintf(w, ``, stdhtml.EscapeString(node.reason)) + } + } else { + _, _ = w.WriteString("") + } + return ast.WalkContinue, nil +} + +type extSpoiler struct{} + +// Spoiler is an extension that allow you to use spoiler expression like '||text||' or ||reason|text|| . +// +// There are some types of nested formatting that aren't supported with advanced spoilers. +// The SimpleSpoiler extension that doesn't support reasons can be used to work around those. +var Spoiler = &extSpoiler{} + +func (e *extSpoiler) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(NewSpoilerParser(), 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewSpoilerHTMLRenderer(), 500), + )) +} diff --git a/mautrix-patched/go.mod b/mautrix-patched/go.mod new file mode 100644 index 00000000..757d9d9a --- /dev/null +++ b/mautrix-patched/go.mod @@ -0,0 +1,42 @@ +module maunium.net/go/mautrix + +go 1.25.0 + +toolchain go1.26.4 + +require ( + filippo.io/edwards25519 v1.2.0 + github.com/chzyer/readline v1.5.1 + github.com/coder/websocket v1.8.15 + github.com/lib/pq v1.12.3 + github.com/mattn/go-sqlite3 v1.14.45 + github.com/rs/xid v1.6.0 + github.com/rs/zerolog v1.35.1 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.19.0 + github.com/tidwall/sjson v1.2.5 + github.com/yuin/goldmark v1.8.2 + go.mau.fi/util v0.9.10 + go.mau.fi/zeroconfig v0.2.0 + golang.org/x/crypto v0.53.0 + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + gopkg.in/yaml.v3 v3.0.1 + maunium.net/go/mauflag v1.0.0 +) + +require ( + github.com/coreos/go-systemd/v22 v22.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect +) diff --git a/mautrix-patched/go.sum b/mautrix-patched/go.sum new file mode 100644 index 00000000..3f8cd0a7 --- /dev/null +++ b/mautrix-patched/go.sum @@ -0,0 +1,74 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= +go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= +go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= +go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= +maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= diff --git a/mautrix-patched/id/contenturi.go b/mautrix-patched/id/contenturi.go new file mode 100644 index 00000000..67127b6c --- /dev/null +++ b/mautrix-patched/id/contenturi.go @@ -0,0 +1,183 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" +) + +var ( + ErrInvalidContentURI = errors.New("invalid Matrix content URI") + ErrInputNotJSONString = errors.New("input doesn't look like a JSON string") +) + +// Deprecated: use variables prefixed with Err +var ( + InvalidContentURI = ErrInvalidContentURI + InputNotJSONString = ErrInputNotJSONString +) + +// ContentURIString is a string that's expected to be a Matrix content URI. +// It's useful for delaying the parsing of the content URI to move errors from the event content +// JSON parsing step to a later step where more appropriate errors can be produced. +type ContentURIString string + +func (uriString ContentURIString) Parse() (ContentURI, error) { + return ParseContentURI(string(uriString)) +} + +func (uriString ContentURIString) ParseOrIgnore() ContentURI { + parsed, _ := ParseContentURI(string(uriString)) + return parsed +} + +// ContentURI represents a Matrix content URI. +// https://spec.matrix.org/v1.2/client-server-api/#matrix-content-mxc-uris +type ContentURI struct { + Homeserver string + FileID string +} + +func MustParseContentURI(uri string) ContentURI { + parsed, err := ParseContentURI(uri) + if err != nil { + panic(err) + } + return parsed +} + +// ParseContentURI parses a Matrix content URI. +func ParseContentURI(uri string) (parsed ContentURI, err error) { + if len(uri) == 0 { + return + } else if !strings.HasPrefix(uri, "mxc://") { + err = ErrInvalidContentURI + } else if index := strings.IndexRune(uri[6:], '/'); index == -1 || index == len(uri)-7 { + err = ErrInvalidContentURI + } else { + parsed.Homeserver = uri[6 : 6+index] + parsed.FileID = uri[6+index+1:] + } + return +} + +var mxcBytes = []byte("mxc://") + +func ParseContentURIBytes(uri []byte) (parsed ContentURI, err error) { + if len(uri) == 0 { + return + } else if !bytes.HasPrefix(uri, mxcBytes) { + err = ErrInvalidContentURI + } else if index := bytes.IndexRune(uri[6:], '/'); index == -1 || index == len(uri)-7 { + err = ErrInvalidContentURI + } else { + parsed.Homeserver = string(uri[6 : 6+index]) + parsed.FileID = string(uri[6+index+1:]) + } + return +} + +func (uri *ContentURI) UnmarshalJSON(raw []byte) (err error) { + if string(raw) == "null" { + *uri = ContentURI{} + return nil + } else if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return fmt.Errorf("ContentURI: %w", ErrInputNotJSONString) + } + parsed, err := ParseContentURIBytes(raw[1 : len(raw)-1]) + if err != nil { + return err + } + *uri = parsed + return nil +} + +func (uri *ContentURI) MarshalJSON() ([]byte, error) { + if uri == nil || uri.IsEmpty() { + return []byte("null"), nil + } + return json.Marshal(uri.String()) +} + +func (uri *ContentURI) UnmarshalText(raw []byte) (err error) { + parsed, err := ParseContentURIBytes(raw) + if err != nil { + return err + } + *uri = parsed + return nil +} + +func (uri ContentURI) MarshalText() ([]byte, error) { + return []byte(uri.String()), nil +} + +func (uri *ContentURI) Scan(i interface{}) error { + var parsed ContentURI + var err error + switch value := i.(type) { + case nil: + // don't do anything, set uri to empty + case []byte: + parsed, err = ParseContentURIBytes(value) + case string: + parsed, err = ParseContentURI(value) + default: + return fmt.Errorf("invalid type %T for ContentURI.Scan", i) + } + if err != nil { + return err + } + *uri = parsed + return nil +} + +func (uri *ContentURI) Value() (driver.Value, error) { + if uri == nil { + return nil, nil + } + return uri.String(), nil +} + +func (uri ContentURI) String() string { + if uri.IsEmpty() { + return "" + } + return fmt.Sprintf("mxc://%s/%s", uri.Homeserver, uri.FileID) +} + +func (uri ContentURI) CUString() ContentURIString { + return ContentURIString(uri.String()) +} + +func (uri ContentURI) IsEmpty() bool { + return len(uri.Homeserver) == 0 || len(uri.FileID) == 0 +} + +var simpleHomeserverRegex = regexp.MustCompile(`^[a-zA-Z0-9.:-]+$`) + +func (uri ContentURI) IsValid() bool { + return IsValidMediaID(uri.FileID) && uri.Homeserver != "" && simpleHomeserverRegex.MatchString(uri.Homeserver) +} + +func IsValidMediaID(mediaID string) bool { + if len(mediaID) == 0 { + return false + } + for _, char := range mediaID { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' && char != '_' { + return false + } + } + return true +} diff --git a/mautrix-patched/id/crypto.go b/mautrix-patched/id/crypto.go new file mode 100644 index 00000000..5ac0252b --- /dev/null +++ b/mautrix-patched/id/crypto.go @@ -0,0 +1,232 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "encoding/base64" + "fmt" + "strings" + + "go.mau.fi/util/random" +) + +// OlmMsgType is an Olm message type +type OlmMsgType int + +const ( + OlmMsgTypePreKey OlmMsgType = 0 + OlmMsgTypeMsg OlmMsgType = 1 +) + +// Algorithm is a Matrix message encryption algorithm. +// https://spec.matrix.org/v1.2/client-server-api/#messaging-algorithm-names +type Algorithm string + +const ( + AlgorithmOlmV1 Algorithm = "m.olm.v1.curve25519-aes-sha2" + AlgorithmMegolmV1 Algorithm = "m.megolm.v1.aes-sha2" + AlgorithmBeeperStreamV1 Algorithm = "com.beeper.stream.v1.aes-gcm" +) + +type KeyAlgorithm string + +const ( + KeyAlgorithmCurve25519 KeyAlgorithm = "curve25519" + KeyAlgorithmEd25519 KeyAlgorithm = "ed25519" + KeyAlgorithmSignedCurve25519 KeyAlgorithm = "signed_curve25519" +) + +type CrossSigningUsage string + +const ( + XSUsageMaster CrossSigningUsage = "master" + XSUsageSelfSigning CrossSigningUsage = "self_signing" + XSUsageUserSigning CrossSigningUsage = "user_signing" +) + +type KeyBackupAlgorithm string + +const ( + KeyBackupAlgorithmMegolmBackupV1 KeyBackupAlgorithm = "m.megolm_backup.v1.curve25519-aes-sha2" +) + +type KeySource string + +func (source KeySource) String() string { + return string(source) +} + +func (source KeySource) Int() int { + switch source { + case KeySourceDirect: + return 100 + case KeySourceBackup: + return 90 + case KeySourceImport: + return 80 + case KeySourceForward: + return 50 + default: + return 0 + } +} + +const ( + KeySourceDirect KeySource = "direct" + KeySourceBackup KeySource = "backup" + KeySourceImport KeySource = "import" + KeySourceForward KeySource = "forward" +) + +// BackupVersion is an arbitrary string that identifies a server side key backup. +type KeyBackupVersion string + +func (version KeyBackupVersion) String() string { + return string(version) +} + +// A SessionID is an arbitrary string that identifies an Olm or Megolm session. +type SessionID string + +func (sessionID SessionID) String() string { + return string(sessionID) +} + +// Ed25519 is the base64 representation of an Ed25519 public key +type Ed25519 string +type SigningKey = Ed25519 + +func (ed25519 Ed25519) String() string { + return string(ed25519) +} + +func (ed25519 Ed25519) Bytes() []byte { + val, _ := base64.RawStdEncoding.DecodeString(string(ed25519)) + // TODO handle errors + return val +} + +func (ed25519 Ed25519) Fingerprint() string { + spacedSigningKey := make([]byte, len(ed25519)+(len(ed25519)-1)/4) + var ptr = 0 + for i, chr := range ed25519 { + spacedSigningKey[ptr] = byte(chr) + ptr++ + if i%4 == 3 { + spacedSigningKey[ptr] = ' ' + ptr++ + } + } + return string(spacedSigningKey) +} + +// Curve25519 is the base64 representation of an Curve25519 public key +type Curve25519 string +type SenderKey = Curve25519 +type IdentityKey = Curve25519 + +func (curve25519 Curve25519) String() string { + return string(curve25519) +} + +func (curve25519 Curve25519) Bytes() []byte { + val, _ := base64.RawStdEncoding.DecodeString(string(curve25519)) + // TODO handle errors + return val +} + +// A DeviceID is an arbitrary string that references a specific device. +type DeviceID string + +func (deviceID DeviceID) String() string { + return string(deviceID) +} + +// A DeviceKeyID is a string formatted as : that is used as the key in deviceid-key mappings. +type DeviceKeyID string + +func NewDeviceKeyID(algorithm KeyAlgorithm, deviceID DeviceID) DeviceKeyID { + return DeviceKeyID(fmt.Sprintf("%s:%s", algorithm, deviceID)) +} + +func (deviceKeyID DeviceKeyID) String() string { + return string(deviceKeyID) +} + +func (deviceKeyID DeviceKeyID) Parse() (Algorithm, DeviceID) { + index := strings.IndexRune(string(deviceKeyID), ':') + if index < 0 || len(deviceKeyID) <= index+1 { + return "", "" + } + return Algorithm(deviceKeyID[:index]), DeviceID(deviceKeyID[index+1:]) +} + +// A KeyID a string formatted as : that is used as the key in one-time-key mappings. +type KeyID string + +func NewKeyID(algorithm KeyAlgorithm, keyID string) KeyID { + return KeyID(fmt.Sprintf("%s:%s", algorithm, keyID)) +} + +func (keyID KeyID) String() string { + return string(keyID) +} + +func (keyID KeyID) Parse() (KeyAlgorithm, string) { + index := strings.IndexRune(string(keyID), ':') + if index < 0 || len(keyID) <= index+1 { + return "", "" + } + return KeyAlgorithm(keyID[:index]), string(keyID[index+1:]) +} + +// Device contains the identity details of a device and some additional info. +type Device struct { + UserID UserID + DeviceID DeviceID + IdentityKey Curve25519 + SigningKey Ed25519 + + Trust TrustState + Deleted bool + Name string +} + +func (device *Device) Fingerprint() string { + return device.SigningKey.Fingerprint() +} + +type CrossSigningKey struct { + Key Ed25519 + First Ed25519 +} + +// Secret storage keys +type Secret string + +func (s Secret) String() string { + return string(s) +} + +const ( + SecretXSMaster Secret = "m.cross_signing.master" + SecretXSSelfSigning Secret = "m.cross_signing.self_signing" + SecretXSUserSigning Secret = "m.cross_signing.user_signing" + SecretMegolmBackupV1 Secret = "m.megolm_backup.v1" +) + +// VerificationTransactionID is a unique identifier for a verification +// transaction. +type VerificationTransactionID string + +func NewVerificationTransactionID() VerificationTransactionID { + return VerificationTransactionID(random.String(32)) +} + +func (t VerificationTransactionID) String() string { + return string(t) +} diff --git a/mautrix-patched/id/matrixuri.go b/mautrix-patched/id/matrixuri.go new file mode 100644 index 00000000..d5c78bc7 --- /dev/null +++ b/mautrix-patched/id/matrixuri.go @@ -0,0 +1,309 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// Errors that can happen when parsing matrix: URIs +var ( + ErrInvalidScheme = errors.New("matrix URI scheme must be exactly 'matrix'") + ErrInvalidPartCount = errors.New("matrix URIs must have exactly 2 or 4 segments") + ErrInvalidFirstSegment = errors.New("invalid identifier in first segment of matrix URI") + ErrEmptySecondSegment = errors.New("the second segment of the matrix URI must not be empty") + ErrInvalidThirdSegment = errors.New("invalid identifier in third segment of matrix URI") + ErrEmptyFourthSegment = errors.New("the fourth segment of the matrix URI must not be empty when the third segment is present") +) + +// Errors that can happen when parsing matrix.to URLs +var ( + ErrNotMatrixTo = errors.New("that URL is not a matrix.to URL") + ErrInvalidMatrixToPartCount = errors.New("matrix.to URLs must have exactly 1 or 2 segments") + ErrEmptyMatrixToPrimaryIdentifier = errors.New("the primary identifier in the matrix.to URL is empty") + ErrInvalidMatrixToPrimaryIdentifier = errors.New("the primary identifier in the matrix.to URL has an invalid sigil") + ErrInvalidMatrixToSecondaryIdentifier = errors.New("the secondary identifier in the matrix.to URL has an invalid sigil") +) + +var ErrNotMatrixToOrMatrixURI = errors.New("that URL is not a matrix.to URL nor matrix: URI") + +// MatrixURI contains the result of parsing a matrix: URI using ParseMatrixURI +type MatrixURI struct { + Sigil1 rune + Sigil2 rune + MXID1 string + MXID2 string + Via []string + Action string +} + +// SigilToPathSegment contains a mapping from Matrix identifier sigils to matrix: URI path segments. +var SigilToPathSegment = map[rune]string{ + '$': "e", + '#': "r", + '!': "roomid", + '@': "u", +} + +func (uri *MatrixURI) getQuery() url.Values { + q := make(url.Values) + if len(uri.Via) > 0 { + q["via"] = uri.Via + } + if len(uri.Action) > 0 { + q.Set("action", uri.Action) + } + return q +} + +// String converts the parsed matrix: URI back into the string representation. +func (uri *MatrixURI) String() string { + if uri == nil { + return "" + } + parts := []string{ + SigilToPathSegment[uri.Sigil1], + url.PathEscape(uri.MXID1), + } + if uri.Sigil2 != 0 { + parts = append(parts, SigilToPathSegment[uri.Sigil2], url.PathEscape(uri.MXID2)) + } + return (&url.URL{ + Scheme: "matrix", + Opaque: strings.Join(parts, "/"), + RawQuery: uri.getQuery().Encode(), + }).String() +} + +// MatrixToURL converts to parsed matrix: URI into a matrix.to URL +func (uri *MatrixURI) MatrixToURL() string { + if uri == nil { + return "" + } + fragment := fmt.Sprintf("#/%s", url.PathEscape(uri.PrimaryIdentifier())) + if uri.Sigil2 != 0 { + fragment = fmt.Sprintf("%s/%s", fragment, url.PathEscape(uri.SecondaryIdentifier())) + } + query := uri.getQuery().Encode() + if len(query) > 0 { + fragment = fmt.Sprintf("%s?%s", fragment, query) + } + // It would be nice to use URL{...}.String() here, but figuring out the Fragment vs RawFragment stuff is a pain + return fmt.Sprintf("https://matrix.to/%s", fragment) +} + +// PrimaryIdentifier returns the first Matrix identifier in the URI. +// Currently room IDs, room aliases and user IDs can be in the primary identifier slot. +func (uri *MatrixURI) PrimaryIdentifier() string { + if uri == nil { + return "" + } + return fmt.Sprintf("%c%s", uri.Sigil1, uri.MXID1) +} + +// SecondaryIdentifier returns the second Matrix identifier in the URI. +// Currently only event IDs can be in the secondary identifier slot. +func (uri *MatrixURI) SecondaryIdentifier() string { + if uri == nil || uri.Sigil2 == 0 { + return "" + } + return fmt.Sprintf("%c%s", uri.Sigil2, uri.MXID2) +} + +// UserID returns the user ID from the URI if the primary identifier is a user ID. +func (uri *MatrixURI) UserID() UserID { + if uri != nil && uri.Sigil1 == '@' { + return UserID(uri.PrimaryIdentifier()) + } + return "" +} + +// RoomID returns the room ID from the URI if the primary identifier is a room ID. +func (uri *MatrixURI) RoomID() RoomID { + if uri != nil && uri.Sigil1 == '!' { + return RoomID(uri.PrimaryIdentifier()) + } + return "" +} + +// RoomAlias returns the room alias from the URI if the primary identifier is a room alias. +func (uri *MatrixURI) RoomAlias() RoomAlias { + if uri != nil && uri.Sigil1 == '#' { + return RoomAlias(uri.PrimaryIdentifier()) + } + return "" +} + +// EventID returns the event ID from the URI if the primary identifier is a room ID or alias and the secondary identifier is an event ID. +func (uri *MatrixURI) EventID() EventID { + if uri != nil && (uri.Sigil1 == '!' || uri.Sigil1 == '#') && uri.Sigil2 == '$' { + return EventID(uri.SecondaryIdentifier()) + } + return "" +} + +// ParseMatrixURIOrMatrixToURL parses the given matrix.to URL or matrix: URI into a unified representation. +func ParseMatrixURIOrMatrixToURL(uri string) (*MatrixURI, error) { + parsed, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("failed to parse URI: %w", err) + } + if parsed.Scheme == "matrix" { + return ProcessMatrixURI(parsed) + } else if strings.HasSuffix(parsed.Hostname(), "matrix.to") { + return ProcessMatrixToURL(parsed) + } else { + return nil, ErrNotMatrixToOrMatrixURI + } +} + +// ParseMatrixURI implements the matrix: URI parsing algorithm. +// +// Currently specified in https://github.com/matrix-org/matrix-doc/blob/master/proposals/2312-matrix-uri.md#uri-parsing-algorithm +func ParseMatrixURI(uri string) (*MatrixURI, error) { + // Step 1: parse the URI according to RFC 3986 + parsed, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("failed to parse URI: %w", err) + } + return ProcessMatrixURI(parsed) +} + +// ProcessMatrixURI implements steps 2-7 of the matrix: URI parsing algorithm +// (i.e. everything except parsing the URI itself, which is done with url.Parse or ParseMatrixURI) +func ProcessMatrixURI(uri *url.URL) (*MatrixURI, error) { + // Step 2: check that scheme is exactly `matrix` + if uri.Scheme != "matrix" { + return nil, ErrInvalidScheme + } + + // Step 3: split the path into segments separated by / + parts := strings.Split(uri.Opaque, "/") + + // Step 4: Check that the URI contains either 2 or 4 segments + if len(parts) != 2 && len(parts) != 4 { + return nil, ErrInvalidPartCount + } + + var parsed MatrixURI + + // Step 5: Construct the top-level Matrix identifier + // a: find the sigil from the first segment + switch parts[0] { + case "u", "user": + parsed.Sigil1 = '@' + case "r", "room": + parsed.Sigil1 = '#' + case "roomid": + parsed.Sigil1 = '!' + default: + return nil, fmt.Errorf("%w: '%s'", ErrInvalidFirstSegment, parts[0]) + } + // b: find the identifier from the second segment + if len(parts[1]) == 0 { + return nil, ErrEmptySecondSegment + } + var err error + parsed.MXID1, err = url.PathUnescape(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to url decode second segment %q: %w", parts[1], err) + } + + // Step 6: if the first part is a room and the URI has 4 segments, construct a second level identifier + if parsed.Sigil1 == '!' && len(parts) == 4 { + // a: find the sigil from the third segment + switch parts[2] { + case "e", "event": + parsed.Sigil2 = '$' + default: + return nil, fmt.Errorf("%w: '%s'", ErrInvalidThirdSegment, parts[0]) + } + + // b: find the identifier from the fourth segment + if len(parts[3]) == 0 { + return nil, ErrEmptyFourthSegment + } + parsed.MXID2, err = url.PathUnescape(parts[3]) + if err != nil { + return nil, fmt.Errorf("failed to url decode fourth segment %q: %w", parts[3], err) + } + } + + // Step 7: parse the query and extract via and action items + via, ok := uri.Query()["via"] + if ok && len(via) > 0 { + parsed.Via = via + } + action, ok := uri.Query()["action"] + if ok && len(action) > 0 { + parsed.Action = action[len(action)-1] + } + + return &parsed, nil +} + +// ParseMatrixToURL parses a matrix.to URL into the same container as ParseMatrixURI parses matrix: URIs. +func ParseMatrixToURL(uri string) (*MatrixURI, error) { + parsed, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + return ProcessMatrixToURL(parsed) +} + +// ProcessMatrixToURL is the equivalent of ProcessMatrixURI for matrix.to URLs. +func ProcessMatrixToURL(uri *url.URL) (*MatrixURI, error) { + if !strings.HasSuffix(uri.Hostname(), "matrix.to") { + return nil, ErrNotMatrixTo + } + + initialSplit := strings.SplitN(uri.Fragment, "?", 2) + parts := strings.Split(initialSplit[0], "/") + if len(initialSplit) > 1 { + uri.RawQuery = initialSplit[1] + } + + if len(parts) < 2 || len(parts) > 3 { + return nil, ErrInvalidMatrixToPartCount + } + + if len(parts[1]) == 0 { + return nil, ErrEmptyMatrixToPrimaryIdentifier + } + + var parsed MatrixURI + + parsed.Sigil1 = rune(parts[1][0]) + parsed.MXID1 = parts[1][1:] + _, isKnown := SigilToPathSegment[parsed.Sigil1] + if !isKnown { + return nil, ErrInvalidMatrixToPrimaryIdentifier + } + + if len(parts) == 3 && len(parts[2]) > 0 { + parsed.Sigil2 = rune(parts[2][0]) + parsed.MXID2 = parts[2][1:] + _, isKnown = SigilToPathSegment[parsed.Sigil2] + if !isKnown { + return nil, ErrInvalidMatrixToSecondaryIdentifier + } + } + + via, ok := uri.Query()["via"] + if ok && len(via) > 0 { + parsed.Via = via + } + action, ok := uri.Query()["action"] + if ok && len(action) > 0 { + parsed.Action = action[len(action)-1] + } + + return &parsed, nil +} diff --git a/mautrix-patched/id/matrixuri_test.go b/mautrix-patched/id/matrixuri_test.go new file mode 100644 index 00000000..90a0754d --- /dev/null +++ b/mautrix-patched/id/matrixuri_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix/id" +) + +var ( + roomIDLink = id.MatrixURI{Sigil1: '!', MXID1: "7NdBVvkd4aLSbgKt9RXl:example.org"} + roomIDViaLink = id.MatrixURI{Sigil1: '!', MXID1: "7NdBVvkd4aLSbgKt9RXl:example.org", Via: []string{"maunium.net", "matrix.org"}} + roomAliasLink = id.MatrixURI{Sigil1: '#', MXID1: "someroom:example.org"} + roomIDEventLink = id.MatrixURI{Sigil1: '!', MXID1: "7NdBVvkd4aLSbgKt9RXl:example.org", Sigil2: '$', MXID2: "uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s"} + userLink = id.MatrixURI{Sigil1: '@', MXID1: "user:example.org"} + + escapeRoomIDEventLink = id.MatrixURI{Sigil1: '!', MXID1: "meow & 🐈️:example.org", Sigil2: '$', MXID2: "uOH4C9cK4HhMeFWkUXMbdF/dtndJ0j9je+kIK3XpV1s"} +) + +func TestMatrixURI_MatrixToURL(t *testing.T) { + assert.Equal(t, "https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org", roomIDLink.MatrixToURL()) + assert.Equal(t, "https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org", roomIDViaLink.MatrixToURL()) + assert.Equal(t, "https://matrix.to/#/%23someroom:example.org", roomAliasLink.MatrixToURL()) + assert.Equal(t, "https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org/$uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s", roomIDEventLink.MatrixToURL()) + assert.Equal(t, "https://matrix.to/#/@user:example.org", userLink.MatrixToURL()) + assert.Equal(t, "https://matrix.to/#/%21meow%20&%20%F0%9F%90%88%EF%B8%8F:example.org/$uOH4C9cK4HhMeFWkUXMbdF%2FdtndJ0j9je+kIK3XpV1s", escapeRoomIDEventLink.MatrixToURL()) +} + +func TestMatrixURI_String(t *testing.T) { + assert.Equal(t, "matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org", roomIDLink.String()) + assert.Equal(t, "matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org", roomIDViaLink.String()) + assert.Equal(t, "matrix:r/someroom:example.org", roomAliasLink.String()) + assert.Equal(t, "matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org/e/uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s", roomIDEventLink.String()) + assert.Equal(t, "matrix:u/user:example.org", userLink.String()) + assert.Equal(t, "matrix:roomid/meow%20&%20%F0%9F%90%88%EF%B8%8F:example.org/e/uOH4C9cK4HhMeFWkUXMbdF%2FdtndJ0j9je+kIK3XpV1s", escapeRoomIDEventLink.String()) +} + +func TestParseMatrixURIOrMatrixToURL(t *testing.T) { + const inputURI = "matrix:u/user:example.org" + const inputMatrixToURL = "https://matrix.to/#/@user:example.org" + parsed1, err := id.ParseMatrixURIOrMatrixToURL(inputURI) + require.NoError(t, err) + require.NotNil(t, parsed1) + parsed2, err := id.ParseMatrixURIOrMatrixToURL(inputMatrixToURL) + require.NoError(t, err) + require.NotNil(t, parsed2) + + assert.Equal(t, parsed1, parsed2) + assert.Equal(t, inputURI, parsed2.String()) + assert.Equal(t, inputMatrixToURL, parsed1.MatrixToURL()) +} + +func TestParseMatrixURI_RoomAlias(t *testing.T) { + parsed1, err := id.ParseMatrixURI("matrix:r/someroom:example.org") + require.NoError(t, err) + require.NotNil(t, parsed1) + parsed2, err := id.ParseMatrixURI("matrix:room/someroom:example.org") + require.NoError(t, err) + require.NotNil(t, parsed2) + + assert.Equal(t, roomAliasLink, *parsed1) + assert.Equal(t, roomAliasLink, *parsed2) +} + +func TestParseMatrixURI_RoomID(t *testing.T) { + parsed, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org") + require.NoError(t, err) + require.NotNil(t, parsed) + parsedVia, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org") + require.NoError(t, err) + require.NotNil(t, parsedVia) + parsedEncoded, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl%3Aexample.org") + require.NoError(t, err) + require.NotNil(t, parsedEncoded) + + assert.Equal(t, roomIDLink, *parsed) + assert.Equal(t, roomIDLink, *parsedEncoded) + assert.Equal(t, roomIDViaLink, *parsedVia) +} + +func TestParseMatrixURI_UserID(t *testing.T) { + parsed1, err := id.ParseMatrixURI("matrix:u/user:example.org") + require.NoError(t, err) + require.NotNil(t, parsed1) + parsed2, err := id.ParseMatrixURI("matrix:user/user:example.org") + require.NoError(t, err) + require.NotNil(t, parsed2) + + assert.Equal(t, userLink, *parsed1) + assert.Equal(t, userLink, *parsed2) +} + +func TestParseMatrixURI_EventID(t *testing.T) { + parsed, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org/e/uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s") + require.NoError(t, err) + require.NotNil(t, parsed) + + assert.Equal(t, roomIDEventLink, *parsed) +} + +func TestParseMatrixToURL_RoomAlias(t *testing.T) { + parsed, err := id.ParseMatrixToURL("https://matrix.to/#/#someroom:example.org") + require.NoError(t, err) + require.NotNil(t, parsed) + parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%23someroom%3Aexample.org") + require.NoError(t, err) + require.NotNil(t, parsedEncoded) + + assert.Equal(t, roomAliasLink, *parsed) + assert.Equal(t, roomAliasLink, *parsedEncoded) +} + +func TestParseMatrixToURL_RoomID(t *testing.T) { + parsed, err := id.ParseMatrixToURL("https://matrix.to/#/!7NdBVvkd4aLSbgKt9RXl:example.org") + require.NoError(t, err) + require.NotNil(t, parsed) + parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl%3Aexample.org") + require.NoError(t, err) + require.NotNil(t, parsedEncoded) + parsedVia, err := id.ParseMatrixToURL("https://matrix.to/#/!7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org") + require.NoError(t, err) + require.NotNil(t, parsedVia) + parsedViaEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl%3Aexample.org?via=maunium.net&via=matrix.org") + require.NoError(t, err) + require.NotNil(t, parsedViaEncoded) + + assert.Equal(t, roomIDLink, *parsed) + assert.Equal(t, roomIDLink, *parsedEncoded) + assert.Equal(t, roomIDViaLink, *parsedVia) + assert.Equal(t, roomIDViaLink, *parsedViaEncoded) +} + +func TestParseMatrixToURL_UserID(t *testing.T) { + parsed, err := id.ParseMatrixToURL("https://matrix.to/#/@user:example.org") + require.NoError(t, err) + require.NotNil(t, parsed) + parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%40user%3Aexample.org") + require.NoError(t, err) + require.NotNil(t, parsedEncoded) + + assert.Equal(t, userLink, *parsed) + assert.Equal(t, userLink, *parsedEncoded) +} + +func TestParseMatrixToURL_EventID(t *testing.T) { + parsed, err := id.ParseMatrixToURL("https://matrix.to/#/!7NdBVvkd4aLSbgKt9RXl:example.org/$uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s") + require.NoError(t, err) + require.NotNil(t, parsed) + parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org/%24uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s") + require.NoError(t, err) + require.NotNil(t, parsedEncoded) + + assert.Equal(t, roomIDEventLink, *parsed) + assert.Equal(t, roomIDEventLink, *parsedEncoded) +} diff --git a/mautrix-patched/id/opaque.go b/mautrix-patched/id/opaque.go new file mode 100644 index 00000000..c1ad4988 --- /dev/null +++ b/mautrix-patched/id/opaque.go @@ -0,0 +1,101 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "fmt" +) + +// A RoomID is a string starting with ! that references a specific room. +// https://matrix.org/docs/spec/appendices#room-ids-and-event-ids +type RoomID string + +// A RoomAlias is a string starting with # that can be resolved into. +// https://matrix.org/docs/spec/appendices#room-aliases +type RoomAlias string + +func NewRoomAlias(localpart, server string) RoomAlias { + return RoomAlias(fmt.Sprintf("#%s:%s", localpart, server)) +} + +// An EventID is a string starting with $ that references a specific event. +// +// https://matrix.org/docs/spec/appendices#room-ids-and-event-ids +// https://matrix.org/docs/spec/rooms/v4#event-ids +type EventID string + +// A BatchID is a string identifying a batch of events being backfilled to a room. +// https://github.com/matrix-org/matrix-doc/pull/2716 +type BatchID string + +// A DelayID is a string identifying a delayed event. +type DelayID string + +func (roomID RoomID) String() string { + return string(roomID) +} + +func (roomID RoomID) URI(via ...string) *MatrixURI { + if roomID == "" { + return nil + } + return &MatrixURI{ + Sigil1: '!', + MXID1: string(roomID)[1:], + Via: via, + } +} + +func (roomID RoomID) EventURI(eventID EventID, via ...string) *MatrixURI { + if roomID == "" { + return nil + } else if eventID == "" { + return roomID.URI(via...) + } + return &MatrixURI{ + Sigil1: '!', + MXID1: string(roomID)[1:], + Sigil2: '$', + MXID2: string(eventID)[1:], + Via: via, + } +} + +func (roomAlias RoomAlias) String() string { + return string(roomAlias) +} + +func (roomAlias RoomAlias) URI() *MatrixURI { + if roomAlias == "" { + return nil + } + return &MatrixURI{ + Sigil1: '#', + MXID1: string(roomAlias)[1:], + } +} + +// Deprecated: room alias event links should not be used. Use room IDs instead. +func (roomAlias RoomAlias) EventURI(eventID EventID) *MatrixURI { + if roomAlias == "" { + return nil + } + return &MatrixURI{ + Sigil1: '#', + MXID1: string(roomAlias)[1:], + Sigil2: '$', + MXID2: string(eventID)[1:], + } +} + +func (eventID EventID) String() string { + return string(eventID) +} + +func (batchID BatchID) String() string { + return string(batchID) +} diff --git a/mautrix-patched/id/roomversion.go b/mautrix-patched/id/roomversion.go new file mode 100644 index 00000000..578c10bd --- /dev/null +++ b/mautrix-patched/id/roomversion.go @@ -0,0 +1,265 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "errors" + "fmt" + "slices" +) + +type RoomVersion string + +const ( + RoomV0 RoomVersion = "" // No room version, used for rooms created before room versions were introduced, equivalent to v1 + RoomV1 RoomVersion = "1" + RoomV2 RoomVersion = "2" + RoomV3 RoomVersion = "3" + RoomV4 RoomVersion = "4" + RoomV5 RoomVersion = "5" + RoomV6 RoomVersion = "6" + RoomV7 RoomVersion = "7" + RoomV8 RoomVersion = "8" + RoomV9 RoomVersion = "9" + RoomV10 RoomVersion = "10" + RoomV11 RoomVersion = "11" + RoomV12 RoomVersion = "12" +) + +func (rv RoomVersion) Equals(versions ...RoomVersion) bool { + return slices.Contains(versions, rv) +} + +func (rv RoomVersion) NotEquals(versions ...RoomVersion) bool { + return !rv.Equals(versions...) +} + +var ErrUnknownRoomVersion = errors.New("unknown room version") + +func (rv RoomVersion) unknownVersionError() error { + return fmt.Errorf("%w %s", ErrUnknownRoomVersion, rv) +} + +func (rv RoomVersion) IsKnown() bool { + switch rv { + case RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11, RoomV12: + return true + default: + return false + } +} + +type StateResVersion int + +const ( + // StateResV1 is the original state resolution algorithm. + StateResV1 StateResVersion = 0 + // StateResV2 is state resolution v2 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/1759 + StateResV2 StateResVersion = 1 + // StateResV2_1 is state resolution v2.1 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/4297 + StateResV2_1 StateResVersion = 2 +) + +// StateResVersion returns the version of the state resolution algorithm used by this room version. +func (rv RoomVersion) StateResVersion() StateResVersion { + switch rv { + case RoomV0, RoomV1: + return StateResV1 + case RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11: + return StateResV2 + case RoomV12: + return StateResV2_1 + default: + panic(rv.unknownVersionError()) + } +} + +type EventIDFormat int + +const ( + // EventIDFormatCustom is the original format used by room v1 and v2. + // Event IDs in this format are an arbitrary string followed by a colon and the server name. + EventIDFormatCustom EventIDFormat = 0 + // EventIDFormatBase64 is the format used by room v3 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/1659. + // Event IDs in this format are the standard unpadded base64-encoded SHA256 reference hash of the event. + EventIDFormatBase64 EventIDFormat = 1 + // EventIDFormatURLSafeBase64 is the format used by room v4 and later introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/2002. + // Event IDs in this format are the url-safe unpadded base64-encoded SHA256 reference hash of the event. + EventIDFormatURLSafeBase64 EventIDFormat = 2 +) + +// EventIDFormat returns the format of event IDs used by this room version. +func (rv RoomVersion) EventIDFormat() EventIDFormat { + switch rv { + case RoomV0, RoomV1, RoomV2: + return EventIDFormatCustom + case RoomV3: + return EventIDFormatBase64 + default: + return EventIDFormatURLSafeBase64 + } +} + +///////////////////// +// Room v5 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/2077 + +// EnforceSigningKeyValidity returns true if the `valid_until_ts` field of federation signing keys +// must be enforced on received events. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2076 +func (rv RoomVersion) EnforceSigningKeyValidity() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4) +} + +///////////////////// +// Room v6 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/2240 + +// SpecialCasedAliasesAuth returns true if the `m.room.aliases` event authorization is special cased +// to only always allow servers to modify the state event with their own server name as state key. +// This also implies that the `aliases` field is protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2432 +func (rv RoomVersion) SpecialCasedAliasesAuth() bool { + return rv.Equals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) +} + +// ForbidFloatsAndBigInts returns true if floats and integers greater than 2^53-1 or lower than -2^53+1 are forbidden everywhere. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2540 +func (rv RoomVersion) ForbidFloatsAndBigInts() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) +} + +// NotificationsPowerLevels returns true if the `notifications` field in `m.room.power_levels` is validated in event auth. +// However, the field is not protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2209 +func (rv RoomVersion) NotificationsPowerLevels() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) +} + +///////////////////// +// Room v7 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/2998 + +// Knocks returns true if the `knock` join rule is supported. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2403 +func (rv RoomVersion) Knocks() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6) +} + +///////////////////// +// Room v8 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3289 + +// RestrictedJoins returns true if the `restricted` join rule is supported. +// This also implies that the `allow` field in the `m.room.join_rules` event is supported and protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3083 +func (rv RoomVersion) RestrictedJoins() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7) +} + +///////////////////// +// Room v9 changes // +///////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3375 + +// RestrictedJoinsFix returns true if the `join_authorised_via_users_server` field in `m.room.member` events is protected from redactions. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3375 +func (rv RoomVersion) RestrictedJoinsFix() bool { + return rv.RestrictedJoins() && rv != RoomV8 +} + +////////////////////// +// Room v10 changes // +////////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3604 + +// ValidatePowerLevelInts returns true if the known values in `m.room.power_levels` must be integers (and not strings). +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3667 +func (rv RoomVersion) ValidatePowerLevelInts() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9) +} + +// KnockRestricted returns true if the `knock_restricted` join rule is supported. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/3787 +func (rv RoomVersion) KnockRestricted() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9) +} + +////////////////////// +// Room v11 changes // +////////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/3820 + +// CreatorInContent returns true if the `m.room.create` event has a `creator` field in content. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2175 +func (rv RoomVersion) CreatorInContent() bool { + return rv.Equals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) +} + +// RedactsInContent returns true if the `m.room.redaction` event has the `redacts` field in content instead of at the top level. +// The redaction protection is also moved from the top level to the content field. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2174 +// (and https://github.com/matrix-org/matrix-spec-proposals/pull/2176 for the redaction protection). +func (rv RoomVersion) RedactsInContent() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) +} + +// UpdatedRedactionRules returns true if various updates to the redaction algorithm are applied. +// +// Specifically: +// +// * the `membership`, `origin`, and `prev_state` fields at the top level of all events are no longer protected. +// * the entire content of `m.room.create` is protected. +// * the `redacts` field in `m.room.redaction` content is protected instead of the top-level field. +// * the `m.room.power_levels` event protects the `invite` field in content. +// * the `signed` field inside the `third_party_invite` field in content of `m.room.member` events is protected. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/2176, +// https://github.com/matrix-org/matrix-spec-proposals/pull/3821, and +// https://github.com/matrix-org/matrix-spec-proposals/pull/3989 +func (rv RoomVersion) UpdatedRedactionRules() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) +} + +////////////////////// +// Room v12 changes // +////////////////////// +// https://github.com/matrix-org/matrix-spec-proposals/pull/4304 + +// Return value of StateResVersion was changed to StateResV2_1 + +// PrivilegedRoomCreators returns true if the creator(s) of a room always have infinite power level. +// This also implies that the `m.room.create` event has an `additional_creators` field, +// and that the creators can't be present in the `m.room.power_levels` event. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/4289 +func (rv RoomVersion) PrivilegedRoomCreators() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11) +} + +// RoomIDIsCreateEventID returns true if the ID of rooms is the same as the ID of the `m.room.create` event. +// This also implies that `m.room.create` events do not have a `room_id` field. +// +// See https://github.com/matrix-org/matrix-spec-proposals/pull/4291 +func (rv RoomVersion) RoomIDIsCreateEventID() bool { + return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11) +} diff --git a/mautrix-patched/id/servername.go b/mautrix-patched/id/servername.go new file mode 100644 index 00000000..923705b6 --- /dev/null +++ b/mautrix-patched/id/servername.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "regexp" + "strconv" +) + +type ParsedServerNameType int + +const ( + ServerNameDNS ParsedServerNameType = iota + ServerNameIPv4 + ServerNameIPv6 +) + +type ParsedServerName struct { + Type ParsedServerNameType + Host string + Port int +} + +var ServerNameRegex = regexp.MustCompile(`^(?:\[([0-9A-Fa-f:.]{2,45})]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9A-Za-z.-]{1,255}))(?::(\d{1,5}))?$`) + +func ValidateServerName(serverName string) bool { + return len(serverName) <= 255 && len(serverName) > 0 && ServerNameRegex.MatchString(serverName) +} + +func ParseServerName(serverName string) *ParsedServerName { + if len(serverName) > 255 || len(serverName) < 1 { + return nil + } + match := ServerNameRegex.FindStringSubmatch(serverName) + if len(match) != 5 { + return nil + } + port, _ := strconv.Atoi(match[4]) + parsed := &ParsedServerName{ + Port: port, + } + switch { + case match[1] != "": + parsed.Type = ServerNameIPv6 + parsed.Host = match[1] + case match[2] != "": + parsed.Type = ServerNameIPv4 + parsed.Host = match[2] + case match[3] != "": + parsed.Type = ServerNameDNS + parsed.Host = match[3] + } + return parsed +} diff --git a/mautrix-patched/id/trust.go b/mautrix-patched/id/trust.go new file mode 100644 index 00000000..877ed6f1 --- /dev/null +++ b/mautrix-patched/id/trust.go @@ -0,0 +1,97 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "fmt" + "strings" +) + +// TrustState determines how trusted a device is. +type TrustState int + +const ( + TrustStateBlacklisted TrustState = -100 + TrustStateDeviceKeyMismatch TrustState = -5 + TrustStateUnset TrustState = 0 + TrustStateUnknownDevice TrustState = 10 + TrustStateForwarded TrustState = 20 + TrustStateBackup TrustState = 30 + TrustStateCrossSignedUntrusted TrustState = 50 + TrustStateCrossSignedTOFU TrustState = 100 + TrustStateCrossSignedVerified TrustState = 200 + TrustStateVerified TrustState = 300 + TrustStateInvalid TrustState = -2147483647 +) + +func (ts *TrustState) UnmarshalText(data []byte) error { + strData := string(data) + state := ParseTrustState(strData) + if state == TrustStateInvalid { + return fmt.Errorf("invalid trust state %q", strData) + } + *ts = state + return nil +} + +func (ts *TrustState) MarshalText() ([]byte, error) { + return []byte(ts.String()), nil +} + +func ParseTrustState(val string) TrustState { + switch strings.ToLower(val) { + case "blacklisted": + return TrustStateBlacklisted + case "device-key-mismatch": + return TrustStateDeviceKeyMismatch + case "unverified": + return TrustStateUnset + case "cross-signed-untrusted": + return TrustStateCrossSignedUntrusted + case "unknown-device": + return TrustStateUnknownDevice + case "forwarded": + return TrustStateForwarded + case "backup": + return TrustStateBackup + case "cross-signed-tofu", "cross-signed": + return TrustStateCrossSignedTOFU + case "cross-signed-verified", "cross-signed-trusted": + return TrustStateCrossSignedVerified + case "verified": + return TrustStateVerified + default: + return TrustStateInvalid + } +} + +func (ts TrustState) String() string { + switch ts { + case TrustStateBlacklisted: + return "blacklisted" + case TrustStateDeviceKeyMismatch: + return "device-key-mismatch" + case TrustStateUnset: + return "unverified" + case TrustStateCrossSignedUntrusted: + return "cross-signed-untrusted" + case TrustStateUnknownDevice: + return "unknown-device" + case TrustStateForwarded: + return "forwarded" + case TrustStateBackup: + return "backup" + case TrustStateCrossSignedTOFU: + return "cross-signed-tofu" + case TrustStateCrossSignedVerified: + return "cross-signed-verified" + case TrustStateVerified: + return "verified" + default: + return "invalid" + } +} diff --git a/mautrix-patched/id/userid.go b/mautrix-patched/id/userid.go new file mode 100644 index 00000000..726a0d58 --- /dev/null +++ b/mautrix-patched/id/userid.go @@ -0,0 +1,254 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" +) + +// UserID represents a Matrix user ID. +// https://matrix.org/docs/spec/appendices#user-identifiers +type UserID string + +const UserIDMaxLength = 255 + +func NewUserID(localpart, homeserver string) UserID { + return UserID(fmt.Sprintf("@%s:%s", localpart, homeserver)) +} + +func NewEncodedUserID(localpart, homeserver string) UserID { + return NewUserID(EncodeUserLocalpart(localpart), homeserver) +} + +var ( + ErrInvalidUserID = errors.New("is not a valid user ID") + ErrNoncompliantLocalpart = errors.New("contains characters that are not allowed") + ErrUserIDTooLong = errors.New("the given user ID is longer than 255 characters") + ErrEmptyLocalpart = errors.New("empty localparts are not allowed") + ErrNoncompliantServerPart = errors.New("is not a valid server name") +) + +// ParseCommonIdentifier parses a common identifier according to https://spec.matrix.org/v1.9/appendices/#common-identifier-format +func ParseCommonIdentifier[Stringish ~string](identifier Stringish) (sigil byte, localpart, homeserver string) { + if len(identifier) == 0 { + return + } + sigil = identifier[0] + strIdentifier := string(identifier) + colonIdx := strings.IndexByte(strIdentifier, ':') + if colonIdx > 0 { + localpart = strIdentifier[1:colonIdx] + homeserver = strIdentifier[colonIdx+1:] + } else { + localpart = strIdentifier[1:] + } + return +} + +// Parse parses the user ID into the localpart and server name. +// +// Note that this only enforces very basic user ID formatting requirements: user IDs start with +// a @, and contain a : after the @. If you want to enforce localpart validity, see the +// ParseAndValidate and ValidateUserLocalpart functions. +func (userID UserID) Parse() (localpart, homeserver string, err error) { + var sigil byte + sigil, localpart, homeserver = ParseCommonIdentifier(userID) + if sigil != '@' || homeserver == "" { + err = fmt.Errorf("'%s' %w", userID, ErrInvalidUserID) + } + return +} + +func (userID UserID) Localpart() string { + localpart, _, _ := userID.Parse() + return localpart +} + +func (userID UserID) Homeserver() string { + _, homeserver, _ := userID.Parse() + return homeserver +} + +// URI returns the user ID as a MatrixURI struct, which can then be stringified into a matrix: URI or a matrix.to URL. +// +// This does not parse or validate the user ID. Use the ParseAndValidate method if you want to ensure the user ID is valid first. +func (userID UserID) URI() *MatrixURI { + if userID == "" { + return nil + } + return &MatrixURI{ + Sigil1: '@', + MXID1: string(userID)[1:], + } +} + +var ValidLocalpartRegex = regexp.MustCompile("^[0-9a-z-.=_/+]+$") + +// ValidateUserLocalpart validates a Matrix user ID localpart using the grammar +// in https://matrix.org/docs/spec/appendices#user-identifier +func ValidateUserLocalpart(localpart string) error { + if len(localpart) == 0 { + return ErrEmptyLocalpart + } else if !ValidLocalpartRegex.MatchString(localpart) { + return fmt.Errorf("'%s' %w", localpart, ErrNoncompliantLocalpart) + } + return nil +} + +// ParseAndValidateStrict is a stricter version of ParseAndValidateRelaxed that checks the localpart to only allow non-historical localparts. +// This should be used with care: there are real users still using historical localparts. +func (userID UserID) ParseAndValidateStrict() (localpart, homeserver string, err error) { + localpart, homeserver, err = userID.ParseAndValidateRelaxed() + if err == nil { + err = ValidateUserLocalpart(localpart) + } + return +} + +// ParseAndValidateRelaxed parses the user ID into the localpart and server name like Parse, +// and also validates that the user ID is not too long and that the server name is valid. +func (userID UserID) ParseAndValidateRelaxed() (localpart, homeserver string, err error) { + if len(userID) > UserIDMaxLength { + err = ErrUserIDTooLong + return + } + localpart, homeserver, err = userID.Parse() + if err == nil && !ValidateServerName(homeserver) { + err = fmt.Errorf("%q %q", homeserver, ErrNoncompliantServerPart) + } + return +} + +func (userID UserID) ParseAndDecode() (localpart, homeserver string, err error) { + localpart, homeserver, err = userID.ParseAndValidateStrict() + if err == nil { + localpart, err = DecodeUserLocalpart(localpart) + } + return +} + +func (userID UserID) String() string { + return string(userID) +} + +const lowerhex = "0123456789abcdef" + +// encode the given byte using quoted-printable encoding (e.g "=2f") +// and writes it to the buffer +// See https://golang.org/src/mime/quotedprintable/writer.go +func encode(buf *bytes.Buffer, b byte) { + buf.WriteByte('=') + buf.WriteByte(lowerhex[b>>4]) + buf.WriteByte(lowerhex[b&0x0f]) +} + +// escape the given alpha character and writes it to the buffer +func escape(buf *bytes.Buffer, b byte) { + buf.WriteByte('_') + if b == '_' { + buf.WriteByte('_') // another _ + } else { + buf.WriteByte(b + 0x20) // ASCII shift A-Z to a-z + } +} + +func shouldEncode(b byte) bool { + return b != '-' && b != '.' && b != '_' && b != '+' && !(b >= '0' && b <= '9') && !(b >= 'a' && b <= 'z') && !(b >= 'A' && b <= 'Z') +} + +func shouldEscape(b byte) bool { + return (b >= 'A' && b <= 'Z') || b == '_' +} + +func isValidByte(b byte) bool { + return isValidEscapedChar(b) || (b >= '0' && b <= '9') || b == '.' || b == '=' || b == '-' || b == '+' +} + +func isValidEscapedChar(b byte) bool { + return b == '_' || (b >= 'a' && b <= 'z') +} + +// EncodeUserLocalpart encodes the given string into Matrix-compliant user ID localpart form. +// See https://spec.matrix.org/v1.2/appendices/#mapping-from-other-character-sets +// +// This returns a string with only the characters "a-z0-9._=-". The uppercase range A-Z +// are encoded using leading underscores ("_"). Characters outside the aforementioned ranges +// (including literal underscores ("_") and equals ("=")) are encoded as UTF8 code points (NOT NCRs) +// and converted to lower-case hex with a leading "=". For example: +// +// Alph@Bet_50up => _alph=40_bet=5f50up +func EncodeUserLocalpart(str string) string { + strBytes := []byte(str) + var outputBuffer bytes.Buffer + for _, b := range strBytes { + if shouldEncode(b) { + encode(&outputBuffer, b) + } else if shouldEscape(b) { + escape(&outputBuffer, b) + } else { + outputBuffer.WriteByte(b) + } + } + return outputBuffer.String() +} + +// DecodeUserLocalpart decodes the given string back into the original input string. +// Returns an error if the given string is not a valid user ID localpart encoding. +// See https://spec.matrix.org/v1.2/appendices/#mapping-from-other-character-sets +// +// This decodes quoted-printable bytes back into UTF8, and unescapes casing. For +// example: +// +// _alph=40_bet=5f50up => Alph@Bet_50up +// +// Returns an error if the input string contains characters outside the +// range "a-z0-9._=-", has an invalid quote-printable byte (e.g. not hex), or has +// an invalid _ escaped byte (e.g. "_5"). +func DecodeUserLocalpart(str string) (string, error) { + strBytes := []byte(str) + var outputBuffer bytes.Buffer + for i := 0; i < len(strBytes); i++ { + b := strBytes[i] + if !isValidByte(b) { + return "", fmt.Errorf("invalid encoded byte at position %d: %c", i, b) + } + + if b == '_' { // next byte is a-z and should be upper-case or is another _ and should be a literal _ + if i+1 >= len(strBytes) { + return "", fmt.Errorf("unexpected end of string after underscore at %d", i) + } + if !isValidEscapedChar(strBytes[i+1]) { // invalid escaping + return "", fmt.Errorf("unexpected byte %c after underscore at %d", strBytes[i+1], i) + } + if strBytes[i+1] == '_' { + outputBuffer.WriteByte('_') + } else { + outputBuffer.WriteByte(strBytes[i+1] - 0x20) // ASCII shift a-z to A-Z + } + i++ // skip next byte since we just handled it + } else if b == '=' { // next 2 bytes are hex and should be buffered ready to be read as utf8 + if i+2 >= len(strBytes) { + return "", fmt.Errorf("unexpected end of string after equals sign at %d", i) + } + dst := make([]byte, 1) + _, err := hex.Decode(dst, strBytes[i+1:i+3]) + if err != nil { + return "", err + } + outputBuffer.WriteByte(dst[0]) + i += 2 // skip next 2 bytes since we just handled it + } else { // pass through + outputBuffer.WriteByte(b) + } + } + return outputBuffer.String(), nil +} diff --git a/mautrix-patched/id/userid_test.go b/mautrix-patched/id/userid_test.go new file mode 100644 index 00000000..57a88066 --- /dev/null +++ b/mautrix-patched/id/userid_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package id_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/id" +) + +func TestUserID_Parse(t *testing.T) { + const inputUserID = "@s p a c e:maunium.net" + parsedLocalpart, parsedServerName, err := id.UserID(inputUserID).Parse() + assert.NoError(t, err) + assert.Equal(t, "s p a c e", parsedLocalpart) + assert.Equal(t, "maunium.net", parsedServerName) +} + +func TestUserID_Parse_Empty(t *testing.T) { + const inputUserID = "@:ponies.im" + parsedLocalpart, parsedServerName, err := id.UserID(inputUserID).Parse() + assert.NoError(t, err) + assert.Equal(t, "", parsedLocalpart) + assert.Equal(t, "ponies.im", parsedServerName) +} + +func TestUserID_Parse_Invalid(t *testing.T) { + const inputUserID = "hello world" + _, _, err := id.UserID(inputUserID).Parse() + assert.Error(t, err) + assert.True(t, errors.Is(err, id.ErrInvalidUserID)) +} + +func TestUserID_ParseAndValidateStrict_Invalid(t *testing.T) { + const inputUserID = "@s p a c e:maunium.net" + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() + assert.Error(t, err) + assert.True(t, errors.Is(err, id.ErrNoncompliantLocalpart)) +} + +func TestUserID_ParseAndValidateStrict_Empty(t *testing.T) { + const inputUserID = "@:ponies.im" + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() + assert.Error(t, err) + assert.True(t, errors.Is(err, id.ErrEmptyLocalpart)) +} + +func TestUserID_ParseAndValidateStrict_Long(t *testing.T) { + const inputUserID = "@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example.com" + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() + assert.Error(t, err) + assert.True(t, errors.Is(err, id.ErrUserIDTooLong)) +} + +func TestUserID_ParseAndValidateStrict_NotLong(t *testing.T) { + const inputUserID = "@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example.com" + _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() + assert.NoError(t, err) +} + +func TestUserIDEncoding(t *testing.T) { + const inputLocalpart = "This local+part contains IlLeGaL chäracters 🚨" + const encodedLocalpart = "_this=20local+part=20contains=20_il_le_ga_l=20ch=c3=a4racters=20=f0=9f=9a=a8" + const inputServerName = "example.com" + userID := id.NewEncodedUserID(inputLocalpart, inputServerName) + parsedLocalpart, parsedServerName, err := userID.ParseAndValidateStrict() + assert.NoError(t, err) + assert.Equal(t, encodedLocalpart, parsedLocalpart) + assert.Equal(t, inputServerName, parsedServerName) + decodedLocalpart, decodedServerName, err := userID.ParseAndDecode() + assert.NoError(t, err) + assert.Equal(t, inputLocalpart, decodedLocalpart) + assert.Equal(t, inputServerName, decodedServerName) +} + +func TestUserID_URI(t *testing.T) { + userID := id.NewUserID("hello", "example.com") + assert.Equal(t, userID.URI().String(), "matrix:u/hello:example.com") +} diff --git a/mautrix-patched/mediaproxy/mediaproxy.go b/mautrix-patched/mediaproxy/mediaproxy.go new file mode 100644 index 00000000..e456b71b --- /dev/null +++ b/mautrix-patched/mediaproxy/mediaproxy.go @@ -0,0 +1,534 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mediaproxy + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/ptr" + "go.mau.fi/util/requestlog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/federation" + "maunium.net/go/mautrix/id" +) + +type GetMediaResponse interface { + isGetMediaResponse() +} + +func (*GetMediaResponseURL) isGetMediaResponse() {} +func (*GetMediaResponseData) isGetMediaResponse() {} +func (*GetMediaResponseCallback) isGetMediaResponse() {} +func (*GetMediaResponseFile) isGetMediaResponse() {} + +type GetMediaResponseURL struct { + URL string + ExpiresAt time.Time +} + +type GetMediaResponseWriter interface { + GetMediaResponse + io.WriterTo + GetContentType() string + GetContentLength() int64 +} + +var ( + _ GetMediaResponseWriter = (*GetMediaResponseCallback)(nil) + _ GetMediaResponseWriter = (*GetMediaResponseData)(nil) +) + +type GetMediaResponseData struct { + Reader io.ReadCloser + ContentType string + ContentLength int64 +} + +func (d *GetMediaResponseData) WriteTo(w io.Writer) (int64, error) { + return io.Copy(w, d.Reader) +} + +func (d *GetMediaResponseData) GetContentType() string { + return d.ContentType +} + +func (d *GetMediaResponseData) GetContentLength() int64 { + return d.ContentLength +} + +func GetMediaResponseRawData(data []byte) *GetMediaResponseData { + return &GetMediaResponseData{ + Reader: io.NopCloser(bytes.NewReader(data)), + ContentType: http.DetectContentType(data), + ContentLength: int64(len(data)), + } +} + +type GetMediaResponseCallback struct { + Callback func(w io.Writer) (int64, error) + ContentType string + ContentLength int64 +} + +func (d *GetMediaResponseCallback) WriteTo(w io.Writer) (int64, error) { + return d.Callback(w) +} + +func (d *GetMediaResponseCallback) GetContentLength() int64 { + return d.ContentLength +} + +func (d *GetMediaResponseCallback) GetContentType() string { + return d.ContentType +} + +type FileMeta struct { + ContentType string + ReplacementFile string +} + +type GetMediaResponseFile struct { + Callback func(w *os.File) (*FileMeta, error) +} + +type GetMediaFunc = func(ctx context.Context, mediaID string, params map[string]string) (response GetMediaResponse, err error) + +type MediaProxy struct { + KeyServer *federation.KeyServer + ServerAuth *federation.ServerAuth + + GetMedia GetMediaFunc + PrepareProxyRequest func(*http.Request) + + serverName string + serverKey *federation.SigningKey + + FederationRouter *http.ServeMux + ClientMediaRouter *http.ServeMux +} + +func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProxy, error) { + parsed, err := federation.ParseSynapseKey(serverKey) + if err != nil { + return nil, err + } + mp := &MediaProxy{ + serverName: serverName, + serverKey: parsed, + GetMedia: getMedia, + KeyServer: &federation.KeyServer{ + KeyProvider: &federation.StaticServerKey{ + ServerName: serverName, + Key: parsed, + }, + WellKnownTarget: fmt.Sprintf("%s:443", serverName), + Version: federation.ServerVersion{ + Name: "mautrix-go media proxy", + Version: strings.TrimPrefix(mautrix.VersionWithCommit, "v"), + }, + }, + } + mp.FederationRouter = http.NewServeMux() + mp.FederationRouter.HandleFunc("GET /v1/media/download/{mediaID}", mp.DownloadMediaFederation) + mp.FederationRouter.HandleFunc("GET /v1/media/thumbnail/{mediaID}", mp.DownloadMediaFederation) + mp.FederationRouter.HandleFunc("GET /v1/version", mp.KeyServer.GetServerVersion) + mp.ClientMediaRouter = http.NewServeMux() + mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}", mp.DownloadMedia) + mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}/{fileName}", mp.DownloadMedia) + mp.ClientMediaRouter.HandleFunc("GET /thumbnail/{serverName}/{mediaID}", mp.DownloadMedia) + mp.ClientMediaRouter.HandleFunc("PUT /upload/{serverName}/{mediaID}", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("POST /upload", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("POST /create", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("GET /config", mp.UploadNotSupported) + mp.ClientMediaRouter.HandleFunc("GET /preview_url", mp.PreviewURLNotSupported) + return mp, nil +} + +type BasicConfig struct { + ServerName string `yaml:"server_name" json:"server_name"` + ServerKey string `yaml:"server_key" json:"server_key"` + FederationAuth bool `yaml:"federation_auth" json:"federation_auth"` + WellKnownResponse string `yaml:"well_known_response" json:"well_known_response"` +} + +func NewFromConfig(cfg BasicConfig, getMedia GetMediaFunc) (*MediaProxy, error) { + mp, err := New(cfg.ServerName, cfg.ServerKey, getMedia) + if err != nil { + return nil, err + } + if cfg.WellKnownResponse != "" { + mp.KeyServer.WellKnownTarget = cfg.WellKnownResponse + } + if cfg.FederationAuth { + mp.EnableServerAuth(nil, nil) + } + return mp, nil +} + +type ServerConfig struct { + Hostname string `yaml:"hostname" json:"hostname"` + Port uint16 `yaml:"port" json:"port"` +} + +func (mp *MediaProxy) Listen(cfg ServerConfig) error { + router := http.NewServeMux() + mp.RegisterRoutes(router, zerolog.Nop()) + return http.ListenAndServe(fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port), router) +} + +func (mp *MediaProxy) GetServerName() string { + return mp.serverName +} + +func (mp *MediaProxy) GetServerKey() *federation.SigningKey { + return mp.serverKey +} + +func (mp *MediaProxy) EnableServerAuth(client *federation.Client, keyCache federation.KeyCache) { + if keyCache == nil { + keyCache = federation.NewInMemoryCache() + } + if client == nil { + resCache, _ := keyCache.(federation.ResolutionCache) + client = federation.NewClient(mp.serverName, mp.serverKey, resCache, exhttp.SensibleClientSettings) + } + mp.ServerAuth = federation.NewServerAuth(client, keyCache, func(auth federation.XMatrixAuth) string { + return mp.GetServerName() + }) +} + +func (mp *MediaProxy) RegisterRoutes(router *http.ServeMux, log zerolog.Logger) { + errorBodies := exhttp.ErrorBodies{ + NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), + MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), + } + router.Handle("/_matrix/federation/", exhttp.ApplyMiddleware( + mp.FederationRouter, + exhttp.StripPrefix("/_matrix/federation"), + hlog.NewHandler(log), + hlog.RequestIDHandler("request_id", "Request-Id"), + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + exhttp.HandleErrors(errorBodies), + )) + router.Handle("/_matrix/client/v1/media/", exhttp.ApplyMiddleware( + mp.ClientMediaRouter, + exhttp.StripPrefix("/_matrix/client/v1/media"), + hlog.NewHandler(log), + hlog.RequestIDHandler("request_id", "Request-Id"), + exhttp.CORSMiddleware, + requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), + exhttp.HandleErrors(errorBodies), + )) + mp.KeyServer.Register(router, log) +} + +var ErrInvalidMediaIDSyntax = errors.New("invalid media ID syntax") + +func queryToMap(vals url.Values) map[string]string { + m := make(map[string]string, len(vals)) + for k, v := range vals { + m[k] = v[0] + } + return m +} + +func (mp *MediaProxy) getMedia(w http.ResponseWriter, r *http.Request) GetMediaResponse { + mediaID := r.PathValue("mediaID") + if !id.IsValidMediaID(mediaID) { + mautrix.MNotFound.WithMessage("Media ID %q is not valid", mediaID).Write(w) + return nil + } + resp, err := mp.GetMedia(r.Context(), mediaID, queryToMap(r.URL.Query())) + if err != nil { + var mautrixRespError mautrix.RespError + if errors.Is(err, ErrInvalidMediaIDSyntax) { + mautrix.MNotFound.WithMessage("This is a media proxy at %q, other media downloads are not available here", mp.serverName).Write(w) + } else if errors.As(err, &mautrixRespError) { + mautrixRespError.Write(w) + } else { + zerolog.Ctx(r.Context()).Err(err).Str("media_id", mediaID).Msg("Failed to get media URL") + mautrix.MNotFound.WithMessage("Media not found").Write(w) + } + return nil + } + return resp +} + +func startMultipart(ctx context.Context, w http.ResponseWriter) *multipart.Writer { + mpw := multipart.NewWriter(w) + w.Header().Set("Content-Type", strings.Replace(mpw.FormDataContentType(), "form-data", "mixed", 1)) + w.WriteHeader(http.StatusOK) + metaPart, err := mpw.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"application/json"}, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create multipart metadata field") + return nil + } + _, err = metaPart.Write([]byte(`{}`)) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to write multipart metadata field") + return nil + } + return mpw +} + +func (mp *MediaProxy) DownloadMediaFederation(w http.ResponseWriter, r *http.Request) { + if mp.ServerAuth != nil { + var err *mautrix.RespError + r, err = mp.ServerAuth.Authenticate(r) + if err != nil { + err.Write(w) + return + } + } + ctx := r.Context() + log := zerolog.Ctx(ctx) + + resp := mp.getMedia(w, r) + if resp == nil { + return + } + + var mpw *multipart.Writer + if urlResp, ok := resp.(*GetMediaResponseURL); ok { + mpw = startMultipart(ctx, w) + if mpw == nil { + return + } + _, err := mpw.CreatePart(textproto.MIMEHeader{ + "Location": {urlResp.URL}, + }) + if err != nil { + log.Err(err).Msg("Failed to create multipart redirect field") + return + } + } else if fileResp, ok := resp.(*GetMediaResponseFile); ok { + responseStarted, err := doTempFileDownload(fileResp, func(wt io.WriterTo, size int64, mimeType string) error { + mpw = startMultipart(ctx, w) + if mpw == nil { + return fmt.Errorf("failed to start multipart writer") + } + dataPart, err := mpw.CreatePart(textproto.MIMEHeader{ + "Content-Type": {mimeType}, + }) + if err != nil { + return fmt.Errorf("failed to create multipart data field: %w", err) + } + _, err = wt.WriteTo(dataPart) + return err + }) + if err != nil { + log.Err(err).Msg("Failed to do media proxy with temp file") + if !responseStarted { + var mautrixRespError mautrix.RespError + if errors.As(err, &mautrixRespError) { + mautrixRespError.Write(w) + } else { + mautrix.MUnknown.WithMessage("Internal error proxying media").Write(w) + } + } + return + } + } else if dataResp, ok := resp.(GetMediaResponseWriter); ok { + mpw = startMultipart(ctx, w) + if mpw == nil { + return + } + dataPart, err := mpw.CreatePart(textproto.MIMEHeader{ + "Content-Type": {dataResp.GetContentType()}, + }) + if err != nil { + log.Err(err).Msg("Failed to create multipart data field") + return + } + _, err = dataResp.WriteTo(dataPart) + if err != nil { + log.Err(err).Msg("Failed to write multipart data field") + return + } + } else { + panic(fmt.Errorf("unknown GetMediaResponse type %T", resp)) + } + err := mpw.Close() + if err != nil { + log.Err(err).Msg("Failed to close multipart writer") + return + } +} + +func (mp *MediaProxy) addHeaders(w http.ResponseWriter, mimeType, fileName string) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + contentDisposition := "attachment" + switch mimeType { + case "text/css", "text/plain", "text/csv", "application/json", "application/ld+json", "image/jpeg", "image/gif", + "image/png", "image/apng", "image/webp", "image/avif", "video/mp4", "video/webm", "video/ogg", "video/quicktime", + "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", "audio/wav", "audio/x-wav", + "audio/x-pn-wav", "audio/flac", "audio/x-flac", "application/pdf": + contentDisposition = "inline" + } + if fileName != "" { + contentDisposition = mime.FormatMediaType(contentDisposition, map[string]string{ + "filename": fileName, + }) + } + w.Header().Set("Content-Disposition", contentDisposition) + w.Header().Set("Content-Type", mimeType) +} + +func (mp *MediaProxy) DownloadMedia(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := zerolog.Ctx(ctx) + if r.PathValue("serverName") != mp.serverName { + mautrix.MNotFound.WithMessage("This is a media proxy at %q, other media downloads are not available here", mp.serverName).Write(w) + return + } + resp := mp.getMedia(w, r) + if resp == nil { + return + } + + if urlResp, ok := resp.(*GetMediaResponseURL); ok { + w.Header().Set("Location", urlResp.URL) + expirySeconds := (time.Until(urlResp.ExpiresAt) - 5*time.Minute).Seconds() + if urlResp.ExpiresAt.IsZero() { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else if expirySeconds > 0 { + cacheControl := fmt.Sprintf("public, max-age=%d, immutable", int(expirySeconds)) + w.Header().Set("Cache-Control", cacheControl) + } else { + w.Header().Set("Cache-Control", "no-store") + } + w.WriteHeader(http.StatusTemporaryRedirect) + } else if fileResp, ok := resp.(*GetMediaResponseFile); ok { + responseStarted, err := doTempFileDownload(fileResp, func(wt io.WriterTo, size int64, mimeType string) error { + mp.addHeaders(w, mimeType, r.PathValue("fileName")) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.WriteHeader(http.StatusOK) + _, err := wt.WriteTo(w) + return err + }) + if err != nil { + log.Err(err).Msg("Failed to do media proxy with temp file") + if !responseStarted { + var mautrixRespError mautrix.RespError + if errors.As(err, &mautrixRespError) { + mautrixRespError.Write(w) + } else { + mautrix.MUnknown.WithMessage("Internal error proxying media").Write(w) + } + } + } + } else if writerResp, ok := resp.(GetMediaResponseWriter); ok { + if dataResp, ok := writerResp.(*GetMediaResponseData); ok { + defer dataResp.Reader.Close() + } + mp.addHeaders(w, writerResp.GetContentType(), r.PathValue("fileName")) + if writerResp.GetContentLength() != 0 { + w.Header().Set("Content-Length", strconv.FormatInt(writerResp.GetContentLength(), 10)) + } + w.WriteHeader(http.StatusOK) + _, err := writerResp.WriteTo(w) + if err != nil { + log.Err(err).Msg("Failed to write media data") + } + } else { + panic(fmt.Errorf("unknown GetMediaResponse type %T", resp)) + } +} + +func doTempFileDownload( + data *GetMediaResponseFile, + respond func(w io.WriterTo, size int64, mimeType string) error, +) (bool, error) { + tempFile, err := os.CreateTemp("", "mautrix-mediaproxy-*") + if err != nil { + return false, fmt.Errorf("failed to create temp file: %w", err) + } + origTempFile := tempFile + defer func() { + _ = origTempFile.Close() + _ = os.Remove(origTempFile.Name()) + }() + meta, err := data.Callback(tempFile) + if err != nil { + return false, err + } + if meta.ReplacementFile != "" { + tempFile, err = os.Open(meta.ReplacementFile) + if err != nil { + return false, fmt.Errorf("failed to open replacement file: %w", err) + } + defer func() { + _ = tempFile.Close() + _ = os.Remove(origTempFile.Name()) + }() + } else { + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return false, fmt.Errorf("failed to seek to start of temp file: %w", err) + } + } + fileInfo, err := tempFile.Stat() + if err != nil { + return false, fmt.Errorf("failed to stat temp file: %w", err) + } + mimeType := meta.ContentType + if mimeType == "" { + buf := make([]byte, 512) + n, err := tempFile.Read(buf) + if err != nil { + return false, fmt.Errorf("failed to read temp file to detect mime: %w", err) + } + buf = buf[:n] + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return false, fmt.Errorf("failed to seek to start of temp file: %w", err) + } + mimeType = http.DetectContentType(buf) + } + err = respond(tempFile, fileInfo.Size(), mimeType) + if err != nil { + return true, fmt.Errorf("failed to write response: %w", err) + } + return true, nil +} + +var ( + ErrUploadNotSupported = mautrix.MUnrecognized. + WithMessage("This is a media proxy and does not support media uploads."). + WithStatus(http.StatusNotImplemented) + ErrPreviewURLNotSupported = mautrix.MUnrecognized. + WithMessage("This is a media proxy and does not support URL previews."). + WithStatus(http.StatusNotImplemented) +) + +func (mp *MediaProxy) UploadNotSupported(w http.ResponseWriter, r *http.Request) { + ErrUploadNotSupported.Write(w) +} + +func (mp *MediaProxy) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) { + ErrPreviewURLNotSupported.Write(w) +} diff --git a/mautrix-patched/mockserver/mockserver.go b/mautrix-patched/mockserver/mockserver.go new file mode 100644 index 00000000..507c24a5 --- /dev/null +++ b/mautrix-patched/mockserver/mockserver.go @@ -0,0 +1,307 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mockserver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log + "github.com/stretchr/testify/require" + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/random" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func mustDecode(r *http.Request, data any) { + exerrors.PanicIfNotNil(json.NewDecoder(r.Body).Decode(data)) +} + +type userAndDeviceID struct { + UserID id.UserID + DeviceID id.DeviceID +} + +type MockServer struct { + Router *http.ServeMux + Server *httptest.Server + + AccessTokenToUserID map[string]userAndDeviceID + DeviceInbox map[id.UserID]map[id.DeviceID][]event.Event + AccountData map[id.UserID]map[event.Type]json.RawMessage + DeviceKeys map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys + OneTimeKeys map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey + MasterKeys map[id.UserID]mautrix.CrossSigningKeys + SelfSigningKeys map[id.UserID]mautrix.CrossSigningKeys + UserSigningKeys map[id.UserID]mautrix.CrossSigningKeys + + PopOTKs bool + MemoryStore bool +} + +func Create(t testing.TB) *MockServer { + t.Helper() + + server := MockServer{ + AccessTokenToUserID: map[string]userAndDeviceID{}, + DeviceInbox: map[id.UserID]map[id.DeviceID][]event.Event{}, + AccountData: map[id.UserID]map[event.Type]json.RawMessage{}, + DeviceKeys: map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys{}, + OneTimeKeys: map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{}, + MasterKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + SelfSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + PopOTKs: true, + MemoryStore: true, + } + + router := http.NewServeMux() + router.HandleFunc("POST /_matrix/client/v3/login", server.postLogin) + router.HandleFunc("POST /_matrix/client/v3/keys/query", server.postKeysQuery) + router.HandleFunc("POST /_matrix/client/v3/keys/claim", server.postKeysClaim) + router.HandleFunc("PUT /_matrix/client/v3/sendToDevice/{type}/{txn}", server.putSendToDevice) + router.HandleFunc("PUT /_matrix/client/v3/user/{userID}/account_data/{type}", server.putAccountData) + router.HandleFunc("POST /_matrix/client/v3/keys/device_signing/upload", server.postDeviceSigningUpload) + router.HandleFunc("POST /_matrix/client/v3/keys/signatures/upload", server.emptyResp) + router.HandleFunc("POST /_matrix/client/v3/keys/upload", server.postKeysUpload) + server.Router = router + server.Server = httptest.NewServer(router) + t.Cleanup(server.Server.Close) + return &server +} + +func (ms *MockServer) getUserID(r *http.Request) userAndDeviceID { + authHeader := r.Header.Get("Authorization") + authHeader = strings.TrimPrefix(authHeader, "Bearer ") + userID, ok := ms.AccessTokenToUserID[authHeader] + if !ok { + panic("no user ID found for access token " + authHeader) + } + return userID +} + +func (ms *MockServer) emptyResp(w http.ResponseWriter, _ *http.Request) { + exhttp.WriteEmptyJSONResponse(w, http.StatusOK) +} + +func (ms *MockServer) postLogin(w http.ResponseWriter, r *http.Request) { + var loginReq mautrix.ReqLogin + mustDecode(r, &loginReq) + + deviceID := loginReq.DeviceID + if deviceID == "" { + deviceID = id.DeviceID(random.String(10)) + } + + accessToken := random.String(30) + userID := id.UserID(loginReq.Identifier.User) + ms.AccessTokenToUserID[accessToken] = userAndDeviceID{ + UserID: userID, + DeviceID: deviceID, + } + + exhttp.WriteJSONResponse(w, http.StatusOK, &mautrix.RespLogin{ + AccessToken: accessToken, + DeviceID: deviceID, + UserID: userID, + }) +} + +func (ms *MockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { + var req mautrix.ReqSendToDevice + mustDecode(r, &req) + evtType := event.Type{Type: r.PathValue("type"), Class: event.ToDeviceEventType} + + for user, devices := range req.Messages { + for device, content := range devices { + if _, ok := ms.DeviceInbox[user]; !ok { + ms.DeviceInbox[user] = map[id.DeviceID][]event.Event{} + } + content.ParseRaw(evtType) + ms.DeviceInbox[user][device] = append(ms.DeviceInbox[user][device], event.Event{ + Sender: ms.getUserID(r).UserID, + Type: evtType, + Content: *content, + }) + } + } + ms.emptyResp(w, r) +} + +func (ms *MockServer) putAccountData(w http.ResponseWriter, r *http.Request) { + userID := id.UserID(r.PathValue("userID")) + eventType := event.Type{Type: r.PathValue("type"), Class: event.AccountDataEventType} + + jsonData, _ := io.ReadAll(r.Body) + if _, ok := ms.AccountData[userID]; !ok { + ms.AccountData[userID] = map[event.Type]json.RawMessage{} + } + ms.AccountData[userID][eventType] = json.RawMessage(jsonData) + ms.emptyResp(w, r) +} + +func (ms *MockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { + var req mautrix.ReqQueryKeys + mustDecode(r, &req) + resp := mautrix.RespQueryKeys{ + MasterKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + SelfSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, + DeviceKeys: map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys{}, + } + for user := range req.DeviceKeys { + resp.MasterKeys[user] = ms.MasterKeys[user] + resp.UserSigningKeys[user] = ms.UserSigningKeys[user] + resp.SelfSigningKeys[user] = ms.SelfSigningKeys[user] + resp.DeviceKeys[user] = ms.DeviceKeys[user] + } + exhttp.WriteJSONResponse(w, http.StatusOK, &resp) +} + +func (ms *MockServer) postKeysClaim(w http.ResponseWriter, r *http.Request) { + var req mautrix.ReqClaimKeys + mustDecode(r, &req) + resp := mautrix.RespClaimKeys{ + OneTimeKeys: map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{}, + } + for user, devices := range req.OneTimeKeys { + resp.OneTimeKeys[user] = map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{} + for device := range devices { + keys := ms.OneTimeKeys[user][device] + for keyID, key := range keys { + if ms.PopOTKs { + delete(keys, keyID) + } + resp.OneTimeKeys[user][device] = map[id.KeyID]mautrix.OneTimeKey{ + keyID: key, + } + break + } + } + } + exhttp.WriteJSONResponse(w, http.StatusOK, &resp) +} + +func (ms *MockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) { + var req mautrix.ReqUploadKeys + mustDecode(r, &req) + + uid := ms.getUserID(r) + userID := uid.UserID + if _, ok := ms.DeviceKeys[userID]; !ok { + ms.DeviceKeys[userID] = map[id.DeviceID]mautrix.DeviceKeys{} + } + if _, ok := ms.OneTimeKeys[userID]; !ok { + ms.OneTimeKeys[userID] = map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{} + } + + if req.DeviceKeys != nil { + ms.DeviceKeys[userID][uid.DeviceID] = *req.DeviceKeys + } + otks, ok := ms.OneTimeKeys[userID][uid.DeviceID] + if !ok { + otks = map[id.KeyID]mautrix.OneTimeKey{} + ms.OneTimeKeys[userID][uid.DeviceID] = otks + } + if req.OneTimeKeys != nil { + maps.Copy(otks, req.OneTimeKeys) + } + + exhttp.WriteJSONResponse(w, http.StatusOK, &mautrix.RespUploadKeys{ + OneTimeKeyCounts: mautrix.OTKCount{SignedCurve25519: len(otks)}, + }) +} + +func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) { + var req mautrix.UploadCrossSigningKeysReq[any] + mustDecode(r, &req) + + userID := ms.getUserID(r).UserID + ms.MasterKeys[userID] = req.Master + ms.SelfSigningKeys[userID] = req.SelfSigning + ms.UserSigningKeys[userID] = req.UserSigning + + ms.emptyResp(w, r) +} + +func (ms *MockServer) Login(t testing.TB, ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*mautrix.Client, crypto.Store) { + t.Helper() + if ctx == nil { + ctx = context.TODO() + } + client, err := mautrix.NewClient(ms.Server.URL, "", "") + require.NoError(t, err) + client.Client = ms.Server.Client() + + _, err = client.Login(ctx, &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: userID.String(), + }, + DeviceID: deviceID, + Password: "password", + StoreCredentials: true, + }) + require.NoError(t, err) + + var store any + if ms.MemoryStore { + store = crypto.NewMemoryStore(nil) + client.StateStore = mautrix.NewMemoryStateStore() + } else { + store, err = dbutil.NewFromConfig("", dbutil.Config{ + PoolConfig: dbutil.PoolConfig{ + Type: "sqlite3-fk-wal", + URI: fmt.Sprintf("file:%s?mode=memory&cache=shared&_txlock=immediate", random.String(10)), + MaxOpenConns: 5, + MaxIdleConns: 1, + }, + }, nil) + require.NoError(t, err) + } + cryptoHelper, err := cryptohelper.NewCryptoHelper(client, []byte("test"), store) + require.NoError(t, err) + client.Crypto = cryptoHelper + + err = cryptoHelper.Init(ctx) + require.NoError(t, err) + + machineLog := globallog.Logger.With(). + Stringer("my_user_id", userID). + Stringer("my_device_id", deviceID). + Logger() + cryptoHelper.Machine().Log = &machineLog + + err = cryptoHelper.Machine().ShareKeys(ctx, 50) + require.NoError(t, err) + + return client, cryptoHelper.Machine().CryptoStore +} + +func (ms *MockServer) DispatchToDevice(t testing.TB, ctx context.Context, client *mautrix.Client) { + t.Helper() + + for _, evt := range ms.DeviceInbox[client.UserID][client.DeviceID] { + client.Syncer.(*mautrix.DefaultSyncer).Dispatch(ctx, &evt) + ms.DeviceInbox[client.UserID][client.DeviceID] = ms.DeviceInbox[client.UserID][client.DeviceID][1:] + } +} diff --git a/mautrix-patched/pushrules/action.go b/mautrix-patched/pushrules/action.go new file mode 100644 index 00000000..1af85baf --- /dev/null +++ b/mautrix-patched/pushrules/action.go @@ -0,0 +1,123 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules + +import "encoding/json" + +// PushActionType is the type of a PushAction +type PushActionType string + +// The allowed push action types as specified in spec section 11.12.1.4.1. +const ( + ActionNotify PushActionType = "notify" + ActionSetTweak PushActionType = "set_tweak" + // Deprecated: this does nothing. An empty actions array is equivalent + ActionDontNotify PushActionType = "dont_notify" + // Deprecated: this was never implemented and was removed from the Matrix spec + ActionCoalesce PushActionType = "coalesce" +) + +// PushActionTweak is the type of the tweak in SetTweak push actions. +type PushActionTweak string + +// The allowed tweak types as specified in spec section 11.12.1.4.1.1. +const ( + TweakSound PushActionTweak = "sound" + TweakHighlight PushActionTweak = "highlight" +) + +// PushActionArray is an array of PushActions. +type PushActionArray []*PushAction + +// PushActionArrayShould contains the important information parsed from a PushActionArray. +type PushActionArrayShould struct { + // Whether the event in question should trigger a notification. + Notify bool + // Whether the event in question should be highlighted. + Highlight bool + + // Whether the event in question should trigger a sound alert. + PlaySound bool + // The name of the sound to play if PlaySound is true. + SoundName string +} + +var ShouldDoNothing = PushActionArrayShould{} + +// Should parses this push action array and returns the relevant details wrapped in a PushActionArrayShould struct. +func (actions PushActionArray) Should() (should PushActionArrayShould) { + for _, action := range actions { + switch action.Action { + case ActionNotify, ActionCoalesce: + should.Notify = true + case ActionSetTweak: + switch action.Tweak { + case TweakHighlight: + var ok bool + should.Highlight, ok = action.Value.(bool) + if !ok { + // Highlight value not specified, so assume true since the tweak is set. + should.Highlight = true + } + case TweakSound: + should.SoundName = action.Value.(string) + should.PlaySound = len(should.SoundName) > 0 + } + } + } + return +} + +// PushAction is a single action that should be triggered when receiving a message. +type PushAction struct { + Action PushActionType `json:"action"` + Tweak PushActionTweak `json:"set_tweak,omitempty"` + Value any `json:"value,omitempty"` +} + +// UnmarshalJSON parses JSON into this PushAction. +// +// - If the JSON is a single string, the value is stored in the Action field. +// - If the JSON is an object with the set_tweak field, Action will be set to +// "set_tweak", Tweak will be set to the value of the set_tweak field and +// and Value will be set to the value of the value field. +// - In any other case, the function does nothing. +func (action *PushAction) UnmarshalJSON(raw []byte) error { + var data any + + err := json.Unmarshal(raw, &data) + if err != nil { + return err + } + + switch val := data.(type) { + case string: + action.Action = PushActionType(val) + case map[string]any: + if tweak, ok := val["set_tweak"].(string); ok { + action.Action = ActionSetTweak + action.Tweak = PushActionTweak(tweak) + action.Value = val["value"] + } else if actionVal, ok := val["action"].(string); ok { + action.Action = PushActionType(actionVal) + } + } + return nil +} + +// MarshalJSON is the reverse of UnmarshalJSON() +func (action *PushAction) MarshalJSON() (raw []byte, err error) { + if action.Action == ActionSetTweak { + data := map[string]any{ + "set_tweak": action.Tweak, + "value": action.Value, + } + return json.Marshal(&data) + } + data := string(action.Action) + return json.Marshal(&data) +} diff --git a/mautrix-patched/pushrules/action_test.go b/mautrix-patched/pushrules/action_test.go new file mode 100644 index 00000000..9cc98985 --- /dev/null +++ b/mautrix-patched/pushrules/action_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/pushrules" +) + +func TestPushActionArray_Should_EmptyArrayReturnsDefaults(t *testing.T) { + should := pushrules.PushActionArray{}.Should() + assert.False(t, should.Notify) + assert.False(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) +} + +func TestPushActionArray_Should_MixedArrayReturnsExpected1(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, + }.Should() + assert.True(t, should.Notify) + assert.True(t, should.Highlight) + assert.True(t, should.PlaySound) + assert.Equal(t, "ping", should.SoundName) +} + +func TestPushActionArray_Should_MixedArrayReturnsExpected2(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: ""}, + }.Should() + assert.False(t, should.Notify) + assert.False(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) +} + +func TestPushActionArray_Should_NotifySet(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + }.Should() + assert.True(t, should.Notify) + assert.False(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) +} + +func TestPushActionArray_Should_DontNotify(t *testing.T) { + should := pushrules.PushActionArray{}.Should() + assert.False(t, should.Notify) + assert.False(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) + assert.Equal(t, should, pushrules.ShouldDoNothing) +} + +func TestPushActionArray_Should_HighlightBlank(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, + }.Should() + assert.False(t, should.Notify) + assert.True(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) +} + +func TestPushActionArray_Should_HighlightFalse(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + }.Should() + assert.False(t, should.Notify) + assert.False(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) +} + +func TestPushActionArray_Should_SoundName(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, + }.Should() + assert.False(t, should.Notify) + assert.False(t, should.Highlight) + assert.True(t, should.PlaySound) + assert.Equal(t, "ping", should.SoundName) +} + +func TestPushActionArray_Should_SoundNameEmpty(t *testing.T) { + should := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: ""}, + }.Should() + assert.False(t, should.Notify) + assert.False(t, should.Highlight) + assert.False(t, should.PlaySound) + assert.Empty(t, should.SoundName) +} + +func TestPushAction_UnmarshalJSON_InvalidJSONFails(t *testing.T) { + pa := &pushrules.PushAction{} + err := pa.UnmarshalJSON([]byte("Not JSON")) + assert.NotNil(t, err) +} + +func TestPushAction_UnmarshalJSON_InvalidTypeDoesNothing(t *testing.T) { + pa := &pushrules.PushAction{ + Action: pushrules.PushActionType("unchanged"), + Tweak: pushrules.PushActionTweak("unchanged"), + Value: "unchanged", + } + + err := pa.UnmarshalJSON([]byte(`{"foo": "bar"}`)) + assert.NoError(t, err) + err = pa.UnmarshalJSON([]byte(`9001`)) + assert.NoError(t, err) + + assert.Equal(t, pushrules.PushActionType("unchanged"), pa.Action) + assert.Equal(t, pushrules.PushActionTweak("unchanged"), pa.Tweak) + assert.Equal(t, "unchanged", pa.Value) +} + +func TestPushAction_UnmarshalJSON_StringChangesActionType(t *testing.T) { + pa := &pushrules.PushAction{ + Action: pushrules.PushActionType("unchanged"), + Tweak: pushrules.PushActionTweak("unchanged"), + Value: "unchanged", + } + + err := pa.UnmarshalJSON([]byte(`"foo"`)) + assert.NoError(t, err) + + assert.Equal(t, pushrules.PushActionType("foo"), pa.Action) + assert.Equal(t, pushrules.PushActionTweak("unchanged"), pa.Tweak) + assert.Equal(t, "unchanged", pa.Value) +} + +func TestPushAction_UnmarshalJSON_SetTweakChangesTweak(t *testing.T) { + pa := &pushrules.PushAction{ + Action: pushrules.PushActionType("unchanged"), + Tweak: pushrules.PushActionTweak("unchanged"), + Value: "unchanged", + } + + err := pa.UnmarshalJSON([]byte(`{"set_tweak": "foo", "value": 123.0}`)) + assert.NoError(t, err) + + assert.Equal(t, pushrules.ActionSetTweak, pa.Action) + assert.Equal(t, pushrules.PushActionTweak("foo"), pa.Tweak) + assert.Equal(t, 123.0, pa.Value) +} + +func TestPushAction_MarshalJSON_TweakOutputWorks(t *testing.T) { + pa := &pushrules.PushAction{ + Action: pushrules.ActionSetTweak, + Tweak: pushrules.PushActionTweak("foo"), + Value: "bar", + } + data, err := pa.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, []byte(`{"set_tweak":"foo","value":"bar"}`), data) +} + +func TestPushAction_MarshalJSON_OtherOutputWorks(t *testing.T) { + pa := &pushrules.PushAction{ + Action: pushrules.PushActionType("something else"), + Tweak: pushrules.PushActionTweak("foo"), + Value: "bar", + } + data, err := pa.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, []byte(`"something else"`), data) +} diff --git a/mautrix-patched/pushrules/condition.go b/mautrix-patched/pushrules/condition.go new file mode 100644 index 00000000..caa717de --- /dev/null +++ b/mautrix-patched/pushrules/condition.go @@ -0,0 +1,359 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/tidwall/gjson" + "go.mau.fi/util/glob" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Room is an interface with the functions that are needed for processing room-specific push conditions +type Room interface { + GetOwnDisplayname() string + GetMemberCount() int +} + +type PowerLevelfulRoom interface { + Room + GetPowerLevels() *event.PowerLevelsEventContent +} + +// EventfulRoom is an extension of Room to support MSC3664. +type EventfulRoom interface { + Room + GetEvent(id.EventID) *event.Event +} + +// PushCondKind is the type of a push condition. +type PushCondKind string + +// The allowed push condition kinds as specified in https://spec.matrix.org/v1.2/client-server-api/#conditions-1 +const ( + KindEventMatch PushCondKind = "event_match" + KindContainsDisplayName PushCondKind = "contains_display_name" + KindRoomMemberCount PushCondKind = "room_member_count" + KindEventPropertyIs PushCondKind = "event_property_is" + KindEventPropertyContains PushCondKind = "event_property_contains" + KindSenderNotificationPermission PushCondKind = "sender_notification_permission" + + // MSC3664: https://github.com/matrix-org/matrix-spec-proposals/pull/3664 + + KindRelatedEventMatch PushCondKind = "related_event_match" + KindUnstableRelatedEventMatch PushCondKind = "im.nheko.msc3664.related_event_match" +) + +// PushCondition wraps a condition that is required for a specific PushRule to be used. +type PushCondition struct { + // The type of the condition. + Kind PushCondKind `json:"kind"` + // The dot-separated field of the event to match. Only applicable if kind is EventMatch. + Key string `json:"key,omitempty"` + // The glob-style pattern to match the field against. Only applicable if kind is EventMatch. + Pattern string `json:"pattern,omitempty"` + // The exact value to match the field against. Only applicable if kind is EventPropertyIs or EventPropertyContains. + Value any `json:"value,omitempty"` + // The condition that needs to be fulfilled for RoomMemberCount-type conditions. + // A decimal integer optionally prefixed by ==, <, >, >= or <=. Prefix "==" is assumed if no prefix found. + MemberCountCondition string `json:"is,omitempty"` + + // The relation type for related_event_match from MSC3664 + RelType event.RelationType `json:"rel_type,omitempty"` +} + +// MemberCountFilterRegex is the regular expression to parse the MemberCountCondition of PushConditions. +var MemberCountFilterRegex = regexp.MustCompile("^(==|[<>]=?)?([0-9]+)$") + +// Match checks if this condition is fulfilled for the given event in the given room. +func (cond *PushCondition) Match(room Room, evt *event.Event) bool { + switch cond.Kind { + case KindEventMatch, KindEventPropertyIs, KindEventPropertyContains: + return cond.matchValue(evt) + case KindRelatedEventMatch, KindUnstableRelatedEventMatch: + return cond.matchRelatedEvent(room, evt) + case KindContainsDisplayName: + return cond.matchDisplayName(room, evt) + case KindRoomMemberCount: + return cond.matchMemberCount(room) + case KindSenderNotificationPermission: + return cond.matchSenderNotificationPermission(room, evt.Sender, cond.Key) + default: + return false + } +} + +func splitWithEscaping(s string, separator, escape byte) []string { + var token []byte + var tokens []string + for i := 0; i < len(s); i++ { + if s[i] == separator { + tokens = append(tokens, string(token)) + token = token[:0] + } else if s[i] == escape && i+1 < len(s) { + i++ + token = append(token, s[i]) + } else { + token = append(token, s[i]) + } + } + tokens = append(tokens, string(token)) + return tokens +} + +func hackyNestedGet(data map[string]any, path []string) (any, bool) { + val, ok := data[path[0]] + if len(path) == 1 { + // We don't have any more path parts, return the value regardless of whether it exists or not. + return val, ok + } else if ok { + if mapVal, ok := val.(map[string]any); ok { + val, ok = hackyNestedGet(mapVal, path[1:]) + if ok { + return val, true + } + } + } + // If we don't find the key, try to combine the first two parts. + // e.g. if the key is content.m.relates_to.rel_type, we'll first try data["m"], which will fail, + // then combine m and relates_to to get data["m.relates_to"], which should succeed. + path[1] = path[0] + "." + path[1] + return hackyNestedGet(data, path[1:]) +} + +func stringifyForPushCondition(val interface{}) string { + // Implement MSC3862 to allow matching any type of field + // https://github.com/matrix-org/matrix-spec-proposals/pull/3862 + switch typedVal := val.(type) { + case string: + return typedVal + case nil: + return "null" + case float64: + // Floats aren't allowed in Matrix events, but the JSON parser always stores numbers as floats, + // so just handle that and convert to int + return strconv.FormatInt(int64(typedVal), 10) + default: + return fmt.Sprint(val) + } +} + +func (cond *PushCondition) getValue(evt *event.Event) (any, bool) { + key, subkey, _ := strings.Cut(cond.Key, ".") + + switch key { + case "type": + return evt.Type.Type, true + case "sender": + return evt.Sender.String(), true + case "room_id": + return evt.RoomID.String(), true + case "state_key": + if evt.StateKey == nil { + return nil, false + } + return *evt.StateKey, true + case "content": + // Split the match key with escaping to implement https://github.com/matrix-org/matrix-spec-proposals/pull/3873 + splitKey := splitWithEscaping(subkey, '.', '\\') + // Then do a hacky nested get that supports combining parts for the backwards-compat part of MSC3873 + return hackyNestedGet(evt.Content.Raw, splitKey) + default: + return nil, false + } +} + +func numberToInt64(a any) int64 { + switch typed := a.(type) { + case float64: + return int64(typed) + case float32: + return int64(typed) + case int: + return int64(typed) + case int8: + return int64(typed) + case int16: + return int64(typed) + case int32: + return int64(typed) + case int64: + return typed + case uint: + return int64(typed) + case uint8: + return int64(typed) + case uint16: + return int64(typed) + case uint32: + return int64(typed) + case uint64: + return int64(typed) + default: + return 0 + } +} + +func valueEquals(a, b any) bool { + // Convert floats to ints when comparing numbers (the JSON parser generates floats, but Matrix only allows integers) + // Also allow other numeric types in case something generates events manually without json + switch a.(type) { + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + switch b.(type) { + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return numberToInt64(a) == numberToInt64(b) + } + } + return a == b +} + +func (cond *PushCondition) matchValue(evt *event.Event) bool { + val, ok := cond.getValue(evt) + if !ok { + return false + } + + switch cond.Kind { + case KindEventMatch, KindRelatedEventMatch, KindUnstableRelatedEventMatch: + pattern := glob.CompileWithImplicitContains(cond.Pattern) + if pattern == nil { + return false + } + return pattern.Match(stringifyForPushCondition(val)) + case KindEventPropertyIs: + return valueEquals(val, cond.Value) + case KindEventPropertyContains: + valArr, ok := val.([]any) + if !ok { + return false + } + for _, item := range valArr { + if valueEquals(item, cond.Value) { + return true + } + } + return false + default: + panic(fmt.Errorf("matchValue called for unknown condition kind %s", cond.Kind)) + } +} + +func (cond *PushCondition) getRelationEventID(relatesTo *event.RelatesTo) id.EventID { + if relatesTo == nil { + return "" + } + switch cond.RelType { + case "": + return relatesTo.EventID + case "m.in_reply_to": + if relatesTo.IsFallingBack || relatesTo.InReplyTo == nil { + return "" + } + return relatesTo.InReplyTo.EventID + default: + if relatesTo.Type != cond.RelType { + return "" + } + return relatesTo.EventID + } +} + +func (cond *PushCondition) matchRelatedEvent(room Room, evt *event.Event) bool { + var relatesTo *event.RelatesTo + if relatable, ok := evt.Content.Parsed.(event.Relatable); ok { + relatesTo = relatable.OptionalGetRelatesTo() + } else { + res := gjson.GetBytes(evt.Content.VeryRaw, `m\.relates_to`) + if res.Exists() && res.IsObject() { + _ = json.Unmarshal([]byte(res.Raw), &relatesTo) + } + } + if evtID := cond.getRelationEventID(relatesTo); evtID == "" { + return false + } else if eventfulRoom, ok := room.(EventfulRoom); !ok { + return false + } else if evt = eventfulRoom.GetEvent(relatesTo.EventID); evt == nil { + return false + } else { + return cond.matchValue(evt) + } +} + +func (cond *PushCondition) matchDisplayName(room Room, evt *event.Event) bool { + displayname := room.GetOwnDisplayname() + if len(displayname) == 0 { + return false + } + + msg, ok := evt.Content.Raw["body"].(string) + if !ok { + return false + } + + isAcceptable := func(r uint8) bool { + return unicode.IsSpace(rune(r)) || unicode.IsPunct(rune(r)) + } + length := len(displayname) + for index := strings.Index(msg, displayname); index != -1; index = strings.Index(msg, displayname) { + if (index <= 0 || isAcceptable(msg[index-1])) && (index+length >= len(msg) || isAcceptable(msg[index+length])) { + return true + } + msg = msg[index+len(displayname):] + } + return false +} + +func (cond *PushCondition) matchMemberCount(room Room) bool { + group := MemberCountFilterRegex.FindStringSubmatch(cond.MemberCountCondition) + if len(group) != 3 { + return false + } + + operator := group[1] + wantedMemberCount, _ := strconv.Atoi(group[2]) + + memberCount := room.GetMemberCount() + + switch operator { + case "==", "": + return memberCount == wantedMemberCount + case ">": + return memberCount > wantedMemberCount + case ">=": + return memberCount >= wantedMemberCount + case "<": + return memberCount < wantedMemberCount + case "<=": + return memberCount <= wantedMemberCount + default: + // Should be impossible due to regex. + return false + } +} + +func (cond *PushCondition) matchSenderNotificationPermission(room Room, sender id.UserID, key string) bool { + if key != "room" { + return false + } + plRoom, ok := room.(PowerLevelfulRoom) + if !ok { + return false + } + pls := plRoom.GetPowerLevels() + if pls == nil { + return false + } + return pls.GetUserLevel(sender) >= pls.Notifications.Room() +} diff --git a/mautrix-patched/pushrules/condition_displayname_test.go b/mautrix-patched/pushrules/condition_displayname_test.go new file mode 100644 index 00000000..c7057401 --- /dev/null +++ b/mautrix-patched/pushrules/condition_displayname_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "maunium.net/go/mautrix/event" + + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPushCondition_Match_DisplayName(t *testing.T) { + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "tulir: test mention", + }) + evt.Sender = "@someone_else:matrix.org" + assert.True(t, displaynamePushCondition.Match(displaynameTestRoom, evt)) +} + +func TestPushCondition_Match_DisplayName_Fail(t *testing.T) { + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "not a mention", + }) + evt.Sender = "@someone_else:matrix.org" + assert.False(t, displaynamePushCondition.Match(displaynameTestRoom, evt)) +} + +func TestPushCondition_Match_DisplayName_FailsOnEmptyRoom(t *testing.T) { + emptyRoom := newFakeRoom(0) + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "tulir: this room doesn't have the owner Member available, so it fails.", + }) + evt.Sender = "@someone_else:matrix.org" + assert.False(t, displaynamePushCondition.Match(emptyRoom, evt)) +} diff --git a/mautrix-patched/pushrules/condition_eventmatch_test.go b/mautrix-patched/pushrules/condition_eventmatch_test.go new file mode 100644 index 00000000..aab4b205 --- /dev/null +++ b/mautrix-patched/pushrules/condition_eventmatch_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "maunium.net/go/mautrix/event" + + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPushCondition_Match_KindEvent_MsgType(t *testing.T) { + condition := newMatchPushCondition("content.msgtype", "m.emote") + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "tests gomuks pushconditions", + }) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_MsgType_Fail(t *testing.T) { + condition := newMatchPushCondition("content.msgtype", "m.emote") + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "I'm testing gomuks pushconditions", + }) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_EventType(t *testing.T) { + condition := newMatchPushCondition("type", "m.room.foo") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_EventType_IllegalGlob(t *testing.T) { + condition := newMatchPushCondition("type", "m.room.invalid_glo[b") + evt := newFakeEvent(event.NewEventType("m.room.invalid_glob"), &struct{}{}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_Sender_Fail(t *testing.T) { + condition := newMatchPushCondition("sender", "@foo:maunium.net") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_RoomID(t *testing.T) { + condition := newMatchPushCondition("room_id", "!fakeroom:maunium.net") + evt := newFakeEvent(event.NewEventType(""), &struct{}{}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_BlankStateKey(t *testing.T) { + condition := newMatchPushCondition("state_key", "") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + blankString := "" + evt.StateKey = &blankString + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_NonStateStateKey(t *testing.T) { + condition := newMatchPushCondition("state_key", "") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_BlankStateKey_Fail(t *testing.T) { + condition := newMatchPushCondition("state_key", "not blank") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_NonBlankStateKey(t *testing.T) { + condition := newMatchPushCondition("state_key", "*:maunium.net") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + evt.StateKey = (*string)(&evt.Sender) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEvent_UnknownKey(t *testing.T) { + condition := newMatchPushCondition("non-existent key", "doesn't affect anything") + evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} diff --git a/mautrix-patched/pushrules/condition_eventpropertyis_test.go b/mautrix-patched/pushrules/condition_eventpropertyis_test.go new file mode 100644 index 00000000..1d26a9d3 --- /dev/null +++ b/mautrix-patched/pushrules/condition_eventpropertyis_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "maunium.net/go/mautrix/event" + + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPushCondition_Match_KindEventPropertyIs_MsgType(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.msgtype", "m.emote") + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "tests gomuks pushconditions", + }) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_MsgType_Fail(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.msgtype", "m.emote") + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "I'm testing gomuks pushconditions", + }) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Integer(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", 5) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": 5}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Integer_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", 0) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "NaN"}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_String(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", "foo") + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "foo"}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_String_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", "foo") + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "foo!"}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Null(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", nil) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": nil}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Null_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", nil) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "a"}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Bool(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", false) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": false}) + assert.True(t, condition.Match(blankTestRoom, evt)) + condition = newEventPropertyIsPushCondition("content.meow", true) + evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": true}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Bool_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", false) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": true}) + assert.False(t, condition.Match(blankTestRoom, evt)) + condition = newEventPropertyIsPushCondition("content.meow", true) + evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": false}) + assert.False(t, condition.Match(blankTestRoom, evt)) + condition = newEventPropertyIsPushCondition("content.meow", false) + evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": ""}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} diff --git a/mautrix-patched/pushrules/condition_membercount_test.go b/mautrix-patched/pushrules/condition_membercount_test.go new file mode 100644 index 00000000..451714df --- /dev/null +++ b/mautrix-patched/pushrules/condition_membercount_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPushCondition_Match_KindMemberCount_OneToOne_ImplicitPrefix(t *testing.T) { + condition := newCountPushCondition("2") + room := newFakeRoom(2) + assert.True(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_OneToOne_ExplicitPrefix(t *testing.T) { + condition := newCountPushCondition("==2") + room := newFakeRoom(2) + assert.True(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_BigRoom(t *testing.T) { + condition := newCountPushCondition(">200") + room := newFakeRoom(201) + assert.True(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_BigRoom_Fail(t *testing.T) { + condition := newCountPushCondition(">=200") + room := newFakeRoom(199) + assert.False(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_SmallRoom(t *testing.T) { + condition := newCountPushCondition("<10") + room := newFakeRoom(9) + assert.True(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_SmallRoom_Fail(t *testing.T) { + condition := newCountPushCondition("<=10") + room := newFakeRoom(11) + assert.False(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_InvalidPrefix(t *testing.T) { + condition := newCountPushCondition("??10") + room := newFakeRoom(11) + assert.False(t, condition.Match(room, countConditionTestEvent)) +} + +func TestPushCondition_Match_KindMemberCount_InvalidCondition(t *testing.T) { + condition := newCountPushCondition("foobar") + room := newFakeRoom(1) + assert.False(t, condition.Match(room, countConditionTestEvent)) +} diff --git a/mautrix-patched/pushrules/condition_test.go b/mautrix-patched/pushrules/condition_test.go new file mode 100644 index 00000000..37af3e34 --- /dev/null +++ b/mautrix-patched/pushrules/condition_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +var ( + blankTestRoom pushrules.Room + displaynameTestRoom pushrules.Room + + countConditionTestEvent *event.Event + + displaynamePushCondition *pushrules.PushCondition +) + +func init() { + blankTestRoom = newFakeRoom(1) + + countConditionTestEvent = &event.Event{ + Sender: "@tulir:maunium.net", + Type: event.EventMessage, + Timestamp: 1523791120, + ID: "$123:maunium.net", + RoomID: "!fakeroom:maunium.net", + Content: event.Content{ + Raw: map[string]interface{}{ + "msgtype": "m.text", + "body": "test", + }, + Parsed: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "test", + }, + }, + } + + displaynameTestRoom = newFakeRoom(4) + displaynamePushCondition = &pushrules.PushCondition{ + Kind: pushrules.KindContainsDisplayName, + } +} + +func newFakeEvent(evtType event.Type, parsed interface{}) *event.Event { + data, err := json.Marshal(parsed) + if err != nil { + panic(err) + } + var raw map[string]interface{} + err = json.Unmarshal(data, &raw) + if err != nil { + panic(err) + } + content := event.Content{ + VeryRaw: data, + Raw: raw, + Parsed: parsed, + } + return &event.Event{ + Sender: "@tulir:maunium.net", + Type: evtType, + Timestamp: 1523791120, + ID: "$123:maunium.net", + RoomID: "!fakeroom:maunium.net", + Content: content, + } +} + +func newCountPushCondition(condition string) *pushrules.PushCondition { + return &pushrules.PushCondition{ + Kind: pushrules.KindRoomMemberCount, + MemberCountCondition: condition, + } +} + +func newMatchPushCondition(key, pattern string) *pushrules.PushCondition { + return &pushrules.PushCondition{ + Kind: pushrules.KindEventMatch, + Key: key, + Pattern: pattern, + } +} + +func newEventPropertyIsPushCondition(key string, value any) *pushrules.PushCondition { + return &pushrules.PushCondition{ + Kind: pushrules.KindEventPropertyIs, + Key: key, + Value: value, + } +} + +func TestPushCondition_Match_InvalidKind(t *testing.T) { + condition := &pushrules.PushCondition{ + Kind: pushrules.PushCondKind("invalid"), + } + evt := newFakeEvent(event.Type{Type: "m.room.foobar"}, &struct{}{}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +type FakeRoom struct { + members map[string]*event.MemberEventContent + owner string + + events map[id.EventID]*event.Event +} + +func newFakeRoom(memberCount int) *FakeRoom { + room := &FakeRoom{ + owner: "@tulir:maunium.net", + members: make(map[string]*event.MemberEventContent), + events: make(map[id.EventID]*event.Event), + } + + if memberCount >= 1 { + room.members["@tulir:maunium.net"] = &event.MemberEventContent{ + Membership: event.MembershipJoin, + Displayname: "tulir", + } + } + + for i := 0; i < memberCount-1; i++ { + mxid := fmt.Sprintf("@extrauser_%d:matrix.org", i) + room.members[mxid] = &event.MemberEventContent{ + Membership: event.MembershipJoin, + Displayname: fmt.Sprintf("Extra User %d", i), + } + } + + return room +} + +func (fr *FakeRoom) GetMemberCount() int { + return len(fr.members) +} + +func (fr *FakeRoom) GetOwnDisplayname() string { + member, ok := fr.members[fr.owner] + if ok { + return member.Displayname + } + return "" +} + +func (fr *FakeRoom) GetEvent(evtID id.EventID) *event.Event { + return fr.events[evtID] +} diff --git a/mautrix-patched/pushrules/doc.go b/mautrix-patched/pushrules/doc.go new file mode 100644 index 00000000..19cd7745 --- /dev/null +++ b/mautrix-patched/pushrules/doc.go @@ -0,0 +1,2 @@ +// Package pushrules contains utilities to parse push notification rules. +package pushrules diff --git a/mautrix-patched/pushrules/pushgateway/gateway.go b/mautrix-patched/pushrules/pushgateway/gateway.go new file mode 100644 index 00000000..1c7ec0e1 --- /dev/null +++ b/mautrix-patched/pushrules/pushgateway/gateway.go @@ -0,0 +1,195 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushgateway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +type NotificationCounts struct { + MissedCalls int `json:"missed_calls,omitempty"` + Unread int `json:"unread,omitempty"` + + BeeperServerType string `json:"com.beeper.server_type,omitempty"` + BeeperAsOfToken string `json:"com.beeper.as_of_token,omitempty"` +} + +type PushPriority string + +const ( + PushPriorityHigh PushPriority = "high" + PushPriorityLow PushPriority = "low" +) + +type PushFormat string + +const ( + PushFormatDefault PushFormat = "" + PushFormatEventIDOnly PushFormat = "event_id_only" +) + +type PusherData map[string]any + +func (pd PusherData) Format() PushFormat { + val, _ := pd["format"].(string) + return PushFormat(val) +} + +func (pd PusherData) URL() string { + val, _ := pd["url"].(string) + return val +} + +// ConvertToNotificationData returns a copy of the map with the url and format fields removed. +func (pd PusherData) ConvertToNotificationData() PusherData { + pdCopy := make(PusherData, len(pd)-2) + for key, value := range pd { + if key != "format" && key != "url" { + pdCopy[key] = value + } + } + return pdCopy +} + +type PusherKind string + +const ( + PusherKindHTTP PusherKind = "http" + PusherKindEmail PusherKind = "email" +) + +type PusherAppID string + +const ( + PusherAppEmail PusherAppID = "m.email" +) + +type Pusher struct { + AppDisplayName string `json:"app_display_name"` + AppID PusherAppID `json:"app_id"` + Data PusherData `json:"data"` + DeviceDisplayName string `json:"device_display_name"` + Kind *PusherKind `json:"kind"` + Language string `json:"lang"` + ProfileTag string `json:"profile_tag,omitempty"` + PushKey string `json:"pushkey"` +} + +type RespPushers struct { + Pushers []Pusher `json:"pushers"` +} + +type BaseDevice struct { + AppID PusherAppID `json:"app_id"` + PushKey string `json:"pushkey"` + PushKeyTS int64 `json:"pushkey_ts,omitempty"` + Data PusherData `json:"data,omitempty"` +} + +type PushKey struct { + BaseDevice + URL string `json:"url"` +} + +type Device struct { + BaseDevice + Tweaks map[pushrules.PushActionTweak]any `json:"tweaks,omitempty"` +} + +type PushNotification struct { + Devices []Device `json:"devices"` + + Counts *NotificationCounts `json:"counts,omitempty"` + + EventID id.EventID `json:"event_id,omitempty"` + Priority PushPriority `json:"prio,omitempty"` + RoomAlias id.RoomAlias `json:"room_alias,omitempty"` + RoomID id.RoomID `json:"room_id,omitempty"` + RoomName string `json:"room_name,omitempty"` + Sender id.UserID `json:"sender,omitempty"` + SenderDisplayName string `json:"sender_display_name,omitempty"` + Type string `json:"type,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + UserIsTarget bool `json:"user_is_target,omitempty"` + + BeeperTTL *int `json:"com.beeper.ttl,omitempty"` + BeeperUserID id.UserID `json:"com.beeper.user_id,omitempty"` + BeeperLastFullyReadRoomID id.RoomID `json:"com.beeper.last_fully_read_room_id,omitempty"` +} + +func (pk *PushKey) Push(ctx context.Context, data *PushNotification) error { + data.Devices = []Device{{BaseDevice: pk.BaseDevice}} + return data.Push(ctx, pk.URL) +} + +type ReqPush struct { + Notification *PushNotification `json:"notification"` +} + +type RespPush struct { + Rejected []string `json:"rejected"` +} + +var ErrPushRejected error = &RespPush{} + +func (rp *RespPush) Error() string { + return fmt.Sprintf("push gateway rejected keys: %v", rp.Rejected) +} + +func (rp *RespPush) Is(other error) bool { + // Allow using errors.Is(err, ErrPushRejected) as a generic type check + // without caring about the actual rejected keys. + if other == ErrPushRejected { + return true + } + otherRespPush, ok := other.(*RespPush) + if !ok || len(otherRespPush.Rejected) != len(rp.Rejected) { + return false + } + for i, key := range otherRespPush.Rejected { + if key != rp.Rejected[i] { + return false + } + } + return true +} + +func (pn *PushNotification) Push(ctx context.Context, url string) error { + payload, err := json.Marshal(&ReqPush{Notification: pn}) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to prepare push request: %w", err) + } + req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (notification pusher)") + req.Header.Set("Content-Type", "application/json") + var respData RespPush + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send push request: %w", err) + } else if body, err := io.ReadAll(resp.Body); err != nil { + return fmt.Errorf("failed to read push response body (status %d): %v", resp.StatusCode, err) + } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, bytes.ReplaceAll(body, []byte("\n"), []byte("\\n"))) + } else if err = json.Unmarshal(body, &respData); err != nil { + return fmt.Errorf("unexpected non-JSON body in push response (status %d): %s", resp.StatusCode, bytes.ReplaceAll(body, []byte("\n"), []byte("\\n"))) + } else if len(respData.Rejected) > 0 { + return &respData + } + return nil +} diff --git a/mautrix-patched/pushrules/pushrules.go b/mautrix-patched/pushrules/pushrules.go new file mode 100644 index 00000000..f9bdcd2e --- /dev/null +++ b/mautrix-patched/pushrules/pushrules.go @@ -0,0 +1,35 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules + +import ( + "encoding/json" + "reflect" + + "maunium.net/go/mautrix/event" +) + +// EventContent represents the content of a m.push_rules account data event. +// https://spec.matrix.org/v1.2/client-server-api/#mpush_rules +type EventContent struct { + Ruleset *PushRuleset `json:"global"` +} + +func init() { + event.TypeMap[event.AccountDataPushRules] = reflect.TypeOf(EventContent{}) +} + +// EventToPushRules converts a m.push_rules event to a PushRuleset by passing the data through JSON. +func EventToPushRules(evt *event.Event) (*PushRuleset, error) { + content := &EventContent{} + err := json.Unmarshal(evt.Content.VeryRaw, content) + if err != nil { + return nil, err + } + + return content.Ruleset, nil +} diff --git a/mautrix-patched/pushrules/pushrules_test.go b/mautrix-patched/pushrules/pushrules_test.go new file mode 100644 index 00000000..965f41c1 --- /dev/null +++ b/mautrix-patched/pushrules/pushrules_test.go @@ -0,0 +1,239 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/pushrules" +) + +func TestEventToPushRules(t *testing.T) { + evt := &event.Event{ + Type: event.AccountDataPushRules, + Timestamp: 1523380910, + Content: event.Content{ + VeryRaw: json.RawMessage(JSONExamplePushRules), + }, + } + pushRuleset, err := pushrules.EventToPushRules(evt) + assert.NoError(t, err) + assert.NotNil(t, pushRuleset) + + assert.IsType(t, pushRuleset.Override, pushrules.PushRuleArray{}) + assert.IsType(t, pushRuleset.Content, pushrules.PushRuleArray{}) + assert.IsType(t, pushRuleset.Room, pushrules.PushRuleMap{}) + assert.IsType(t, pushRuleset.Sender, pushrules.PushRuleMap{}) + assert.IsType(t, pushRuleset.Underride, pushrules.PushRuleArray{}) + assert.Len(t, pushRuleset.Override, 2) + assert.Len(t, pushRuleset.Content, 1) + assert.Empty(t, pushRuleset.Room.Map) + assert.Empty(t, pushRuleset.Sender.Map) + assert.Len(t, pushRuleset.Underride, 6) + + assert.Len(t, pushRuleset.Content[0].Actions, 3) + assert.True(t, pushRuleset.Content[0].Default) + assert.True(t, pushRuleset.Content[0].Enabled) + assert.Empty(t, pushRuleset.Content[0].Conditions) + assert.Equal(t, "alice", pushRuleset.Content[0].Pattern) + assert.Equal(t, ".m.rule.contains_user_name", pushRuleset.Content[0].RuleID) + + assert.False(t, pushRuleset.Override[0].Actions.Should().Notify) +} + +const JSONExamplePushRules = `{ + "global": { + "content": [ + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight" + } + ], + "default": true, + "enabled": true, + "pattern": "alice", + "rule_id": ".m.rule.contains_user_name" + } + ], + "override": [ + { + "actions": [ + "dont_notify" + ], + "conditions": [], + "default": true, + "enabled": false, + "rule_id": ".m.rule.master" + }, + { + "actions": [ + "dont_notify" + ], + "conditions": [ + { + "key": "content.msgtype", + "kind": "event_match", + "pattern": "m.notice" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.suppress_notices" + } + ], + "room": [], + "sender": [], + "underride": [ + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "ring" + }, + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.call.invite" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.call" + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight" + } + ], + "conditions": [ + { + "kind": "contains_display_name" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.contains_display_name" + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "is": "2", + "kind": "room_member_count" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.room_one_to_one" + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.room.member" + }, + { + "key": "content.membership", + "kind": "event_match", + "pattern": "invite" + }, + { + "key": "state_key", + "kind": "event_match", + "pattern": "@alice:example.com" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.invite_for_me" + }, + { + "actions": [ + "notify", + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.room.member" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.member_event" + }, + { + "actions": [ + "notify", + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.room.message" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.message" + } + ] + } +}` diff --git a/mautrix-patched/pushrules/rule.go b/mautrix-patched/pushrules/rule.go new file mode 100644 index 00000000..9ec4e786 --- /dev/null +++ b/mautrix-patched/pushrules/rule.go @@ -0,0 +1,181 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules + +import ( + "regexp" + "strings" + + "go.mau.fi/util/exerrors" + "go.mau.fi/util/glob" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type PushRuleCollection interface { + GetMatchingRule(room Room, evt *event.Event) *PushRule + GetActions(room Room, evt *event.Event) PushActionArray +} + +type PushRuleArray []*PushRule + +func (rules PushRuleArray) SetType(typ PushRuleType) PushRuleArray { + for _, rule := range rules { + rule.Type = typ + } + return rules +} + +func (rules PushRuleArray) GetMatchingRule(room Room, evt *event.Event) *PushRule { + for _, rule := range rules { + if !rule.Match(room, evt) { + continue + } + return rule + } + return nil +} + +func (rules PushRuleArray) GetActions(room Room, evt *event.Event) PushActionArray { + return rules.GetMatchingRule(room, evt).GetActions() +} + +type PushRuleMap struct { + Map map[string]*PushRule + Type PushRuleType +} + +func (rules PushRuleArray) SetTypeAndMap(typ PushRuleType) PushRuleMap { + data := PushRuleMap{ + Map: make(map[string]*PushRule), + Type: typ, + } + for _, rule := range rules { + rule.Type = typ + data.Map[rule.RuleID] = rule + } + return data +} + +func (ruleMap PushRuleMap) GetMatchingRule(room Room, evt *event.Event) *PushRule { + var rule *PushRule + var found bool + switch ruleMap.Type { + case RoomRule: + rule, found = ruleMap.Map[string(evt.RoomID)] + case SenderRule: + rule, found = ruleMap.Map[string(evt.Sender)] + } + if found && rule.Match(room, evt) { + return rule + } + return nil +} + +func (ruleMap PushRuleMap) GetActions(room Room, evt *event.Event) PushActionArray { + return ruleMap.GetMatchingRule(room, evt).GetActions() +} + +func (ruleMap PushRuleMap) Unmap() PushRuleArray { + array := make(PushRuleArray, len(ruleMap.Map)) + index := 0 + for _, rule := range ruleMap.Map { + array[index] = rule + index++ + } + return array +} + +type PushRuleType string + +const ( + OverrideRule PushRuleType = "override" + ContentRule PushRuleType = "content" + RoomRule PushRuleType = "room" + SenderRule PushRuleType = "sender" + UnderrideRule PushRuleType = "underride" +) + +type PushRule struct { + // The type of this rule. + Type PushRuleType `json:"-"` + // The ID of this rule. + // For room-specific rules and user-specific rules, this is the room or user ID (respectively) + // For other types of rules, this doesn't affect anything. + RuleID string `json:"rule_id"` + // The actions this rule should trigger when matched. + Actions PushActionArray `json:"actions"` + // Whether this is a default rule, or has been set explicitly. + Default bool `json:"default"` + // Whether or not this push rule is enabled. + Enabled bool `json:"enabled"` + // The conditions to match in order to trigger this rule. + // Only applicable to generic underride/override rules. + Conditions []*PushCondition `json:"conditions,omitempty"` + // Pattern for content-specific push rules + Pattern string `json:"pattern,omitempty"` +} + +func (rule *PushRule) GetActions() PushActionArray { + if rule == nil { + return nil + } + return rule.Actions +} + +func (rule *PushRule) Match(room Room, evt *event.Event) bool { + if rule == nil || !rule.Enabled { + return false + } + if rule.RuleID == ".m.rule.contains_display_name" || rule.RuleID == ".m.rule.contains_user_name" || rule.RuleID == ".m.rule.roomnotif" { + if _, containsMentions := evt.Content.Raw["m.mentions"]; containsMentions { + // Disable legacy mention push rules when the event contains the new mentions key + return false + } + } + switch rule.Type { + case OverrideRule, UnderrideRule: + return rule.matchConditions(room, evt) + case ContentRule: + return rule.matchPattern(room, evt) + case RoomRule: + return id.RoomID(rule.RuleID) == evt.RoomID + case SenderRule: + return id.UserID(rule.RuleID) == evt.Sender + default: + return false + } +} + +func (rule *PushRule) matchConditions(room Room, evt *event.Event) bool { + for _, cond := range rule.Conditions { + if !cond.Match(room, evt) { + return false + } + } + return true +} + +func (rule *PushRule) matchPattern(room Room, evt *event.Event) bool { + msg, ok := evt.Content.Raw["body"].(string) + if !ok { + return false + } + var buf strings.Builder + // As per https://spec.matrix.org/unstable/client-server-api/#push-rules, content rules are case-insensitive + // and must match whole words, so wrap the converted glob in (?i) and \b. + buf.WriteString(`(?i)\b`) + // strings.Builder will never return errors + exerrors.PanicIfNotNil(glob.ToRegexPattern(rule.Pattern, &buf)) + buf.WriteString(`\b`) + pattern, err := regexp.Compile(buf.String()) + if err != nil { + return false + } + return pattern.MatchString(msg) +} diff --git a/mautrix-patched/pushrules/rule_array_test.go b/mautrix-patched/pushrules/rule_array_test.go new file mode 100644 index 00000000..ae77d70f --- /dev/null +++ b/mautrix-patched/pushrules/rule_array_test.go @@ -0,0 +1,285 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/pushrules" + + "testing" +) + +func TestPushRuleArray_GetActions_FirstMatchReturns(t *testing.T) { + cond1 := newMatchPushCondition("content.msgtype", "m.emote") + cond2 := newMatchPushCondition("content.body", "no match") + actions1 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, + } + rule1 := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1, cond2}, + Actions: actions1, + } + + actions2 := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, + } + rule2 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!fakeroom:maunium.net", + Actions: actions2, + } + + actions3 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, + } + rule3 := &pushrules.PushRule{ + Type: pushrules.SenderRule, + Enabled: true, + RuleID: "@tulir:maunium.net", + Actions: actions3, + } + + rules := pushrules.PushRuleArray{rule1, rule2, rule3} + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.Equal(t, rules.GetActions(blankTestRoom, evt), actions2) +} + +func TestPushRuleArray_GetActions_NoMatchesIsNil(t *testing.T) { + cond1 := newMatchPushCondition("content.msgtype", "m.emote") + cond2 := newMatchPushCondition("content.body", "no match") + actions1 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, + } + rule1 := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1, cond2}, + Actions: actions1, + } + + actions2 := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, + } + rule2 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!realroom:maunium.net", + Actions: actions2, + } + + actions3 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, + } + rule3 := &pushrules.PushRule{ + Type: pushrules.SenderRule, + Enabled: true, + RuleID: "@otheruser:maunium.net", + Actions: actions3, + } + + rules := pushrules.PushRuleArray{rule1, rule2, rule3} + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.Nil(t, rules.GetActions(blankTestRoom, evt)) +} + +func TestPushRuleMap_GetActions_RoomRuleExists(t *testing.T) { + actions1 := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, + } + rule1 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!realroom:maunium.net", + Actions: actions1, + } + + actions2 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + } + rule2 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!thirdroom:maunium.net", + Actions: actions2, + } + + actions3 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, + } + rule3 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!fakeroom:maunium.net", + Actions: actions3, + } + + rules := pushrules.PushRuleMap{ + Map: map[string]*pushrules.PushRule{ + rule1.RuleID: rule1, + rule2.RuleID: rule2, + rule3.RuleID: rule3, + }, + Type: pushrules.RoomRule, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.Equal(t, rules.GetActions(blankTestRoom, evt), actions3) +} + +func TestPushRuleMap_GetActions_RoomRuleDoesntExist(t *testing.T) { + actions1 := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, + } + rule1 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!realroom:maunium.net", + Actions: actions1, + } + + actions2 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + } + rule2 := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!thirdroom:maunium.net", + Actions: actions2, + } + + rules := pushrules.PushRuleMap{ + Map: map[string]*pushrules.PushRule{ + rule1.RuleID: rule1, + rule2.RuleID: rule2, + }, + Type: pushrules.RoomRule, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.Nil(t, rules.GetActions(blankTestRoom, evt)) +} + +func TestPushRuleMap_GetActions_SenderRuleExists(t *testing.T) { + actions1 := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, + } + rule1 := &pushrules.PushRule{ + Type: pushrules.SenderRule, + Enabled: true, + RuleID: "@tulir:maunium.net", + Actions: actions1, + } + + actions2 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + } + rule2 := &pushrules.PushRule{ + Type: pushrules.SenderRule, + Enabled: true, + RuleID: "@someone:maunium.net", + Actions: actions2, + } + + actions3 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, + } + rule3 := &pushrules.PushRule{ + Type: pushrules.SenderRule, + Enabled: true, + RuleID: "@otheruser:matrix.org", + Actions: actions3, + } + + rules := pushrules.PushRuleMap{ + Map: map[string]*pushrules.PushRule{ + rule1.RuleID: rule1, + rule2.RuleID: rule2, + rule3.RuleID: rule3, + }, + Type: pushrules.SenderRule, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.Equal(t, rules.GetActions(blankTestRoom, evt), actions1) +} + +func TestPushRuleArray_SetTypeAndMap(t *testing.T) { + actions1 := pushrules.PushActionArray{ + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, + } + rule1 := &pushrules.PushRule{ + Enabled: true, + RuleID: "@tulir:maunium.net", + Actions: actions1, + } + + actions2 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + } + rule2 := &pushrules.PushRule{ + Enabled: true, + RuleID: "@someone:maunium.net", + Actions: actions2, + } + + actions3 := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, + } + rule3 := &pushrules.PushRule{ + Enabled: true, + RuleID: "@otheruser:matrix.org", + Actions: actions3, + } + + ruleArray := pushrules.PushRuleArray{rule1, rule2, rule3} + ruleMap := ruleArray.SetTypeAndMap(pushrules.SenderRule) + assert.Equal(t, pushrules.SenderRule, ruleMap.Type) + for _, rule := range ruleArray { + assert.Equal(t, rule, ruleMap.Map[rule.RuleID]) + } + newRuleArray := ruleMap.Unmap() + for _, rule := range ruleArray { + assert.Contains(t, newRuleArray, rule) + } +} diff --git a/mautrix-patched/pushrules/rule_test.go b/mautrix-patched/pushrules/rule_test.go new file mode 100644 index 00000000..7ff839a7 --- /dev/null +++ b/mautrix-patched/pushrules/rule_test.go @@ -0,0 +1,312 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/pushrules" + + "testing" +) + +func TestPushRule_Match_Conditions(t *testing.T) { + cond1 := newMatchPushCondition("content.msgtype", "m.emote") + cond2 := newMatchPushCondition("content.body", "*pushrules") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1, cond2}, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_NestedKey(t *testing.T) { + cond1 := newMatchPushCondition("content.m.relates_to.rel_type", "m.replace") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + RelatesTo: &event.RelatesTo{ + Type: event.RelReplace, + EventID: "$meow", + }, + }) + assert.True(t, rule.Match(blankTestRoom, evt)) + + evt = newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_NestedKey_Boolean(t *testing.T) { + cond1 := newMatchPushCondition("content.fi.mau.will_auto_accept", "true") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.StateMember, &event.MemberEventContent{ + Membership: "invite", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau.will_auto_accept"] = true + assert.True(t, rule.Match(blankTestRoom, evt)) + delete(evt.Content.Raw, "fi.mau.will_auto_accept") + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau"] = map[string]interface{}{ + "will_auto_accept": true, + } + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_EscapedKey(t *testing.T) { + cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.StateMember, &event.MemberEventContent{ + Membership: "invite", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau.will_auto_accept"] = true + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_EscapedKey_NoNesting(t *testing.T) { + cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.StateMember, &event.MemberEventContent{ + Membership: "invite", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau"] = map[string]interface{}{ + "will_auto_accept": true, + } + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_RelatedEvent(t *testing.T) { + cond1 := &pushrules.PushCondition{ + Kind: pushrules.KindRelatedEventMatch, + Key: "sender", + Pattern: "@tulir:maunium.net", + } + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.EventReaction, &event.ReactionEventContent{ + RelatesTo: event.RelatesTo{ + Type: event.RelAnnotation, + EventID: "$meow", + Key: "🐈️", + }, + }) + roomWithEvent := newFakeRoom(1) + assert.False(t, rule.Match(roomWithEvent, evt)) + roomWithEvent.events["$meow"] = newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.True(t, rule.Match(roomWithEvent, evt)) +} + +func TestPushRule_Match_Conditions_Disabled(t *testing.T) { + cond1 := newMatchPushCondition("content.msgtype", "m.emote") + cond2 := newMatchPushCondition("content.body", "*pushrules") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: false, + Conditions: []*pushrules.PushCondition{cond1, cond2}, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_FailIfOneFails(t *testing.T) { + cond1 := newMatchPushCondition("content.msgtype", "m.emote") + cond2 := newMatchPushCondition("content.body", "*pushrules") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1, cond2}, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "I'm testing pushrules", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Content(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.ContentRule, + Enabled: true, + Pattern: "is testing*", + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_WordBoundary(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.ContentRule, + Enabled: true, + Pattern: "test", + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_CaseInsensitive(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.ContentRule, + Enabled: true, + Pattern: "test", + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is TeSt-InG pushrules", + }) + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Content_Fail(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.ContentRule, + Enabled: true, + Pattern: "is testing*", + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is not testing pushrules", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Content_ImplicitGlob(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.ContentRule, + Enabled: true, + Pattern: "testing", + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is not testing pushrules", + }) + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Content_IllegalGlob(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.ContentRule, + Enabled: true, + Pattern: "this is not a valid glo[b", + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "this is not a valid glob", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Room(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!fakeroom:maunium.net", + } + + evt := newFakeEvent(event.EventMessage, &struct{}{}) + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Room_Fail(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "!otherroom:maunium.net", + } + + evt := newFakeEvent(event.EventMessage, &struct{}{}) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Sender(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.SenderRule, + Enabled: true, + RuleID: "@tulir:maunium.net", + } + + evt := newFakeEvent(event.EventMessage, &struct{}{}) + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Sender_Fail(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.RoomRule, + Enabled: true, + RuleID: "@someone:matrix.org", + } + + evt := newFakeEvent(event.EventMessage, &struct{}{}) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_UnknownTypeAlwaysFail(t *testing.T) { + rule := &pushrules.PushRule{ + Type: pushrules.PushRuleType("foobar"), + Enabled: true, + RuleID: "@someone:matrix.org", + } + + evt := newFakeEvent(event.EventMessage, &struct{}{}) + assert.False(t, rule.Match(blankTestRoom, evt)) +} diff --git a/mautrix-patched/pushrules/ruleset.go b/mautrix-patched/pushrules/ruleset.go new file mode 100644 index 00000000..3155d53e --- /dev/null +++ b/mautrix-patched/pushrules/ruleset.go @@ -0,0 +1,100 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules + +import ( + "encoding/json" + + "maunium.net/go/mautrix/event" +) + +type PushRuleset struct { + Override PushRuleArray + Content PushRuleArray + Room PushRuleMap + Sender PushRuleMap + Underride PushRuleArray +} + +type rawPushRuleset struct { + Override PushRuleArray `json:"override"` + Content PushRuleArray `json:"content"` + Room PushRuleArray `json:"room"` + Sender PushRuleArray `json:"sender"` + Underride PushRuleArray `json:"underride"` +} + +// UnmarshalJSON parses JSON into this PushRuleset. +// +// For override, sender and underride push rule arrays, the type is added +// to each PushRule and the array is used as-is. +// +// For room and sender push rule arrays, the type is added to each PushRule +// and the array is converted to a map with the rule ID as the key and the +// PushRule as the value. +func (rs *PushRuleset) UnmarshalJSON(raw []byte) (err error) { + data := rawPushRuleset{} + err = json.Unmarshal(raw, &data) + if err != nil { + return + } + + rs.Override = data.Override.SetType(OverrideRule) + rs.Content = data.Content.SetType(ContentRule) + rs.Room = data.Room.SetTypeAndMap(RoomRule) + rs.Sender = data.Sender.SetTypeAndMap(SenderRule) + rs.Underride = data.Underride.SetType(UnderrideRule) + return +} + +// MarshalJSON is the reverse of UnmarshalJSON() +func (rs *PushRuleset) MarshalJSON() ([]byte, error) { + data := rawPushRuleset{ + Override: rs.Override, + Content: rs.Content, + Room: rs.Room.Unmap(), + Sender: rs.Sender.Unmap(), + Underride: rs.Underride, + } + return json.Marshal(&data) +} + +// DefaultPushActions is the value returned if none of the rule +// collections in a Ruleset match the event given to GetActions() +var DefaultPushActions = PushActionArray{} + +func (rs *PushRuleset) GetMatchingRule(room Room, evt *event.Event) (rule *PushRule) { + if rs == nil { + return nil + } + // Add push rule collections to array in priority order + arrays := []PushRuleCollection{rs.Override, rs.Content, rs.Room, rs.Sender, rs.Underride} + // Loop until one of the push rule collections matches the room/event combo. + for _, pra := range arrays { + if pra == nil { + continue + } + if rule = pra.GetMatchingRule(room, evt); rule != nil { + // Match found, return it. + return + } + } + // No match found + return nil +} + +// GetActions matches the given event against all of the push rule +// collections in this push ruleset in the order of priority as +// specified in spec section 11.12.1.4. +func (rs *PushRuleset) GetActions(room Room, evt *event.Event) (match PushActionArray) { + actions := rs.GetMatchingRule(room, evt).GetActions() + if actions == nil { + // No match found, return default actions. + return DefaultPushActions + } + return actions +} diff --git a/mautrix-patched/requests.go b/mautrix-patched/requests.go new file mode 100644 index 00000000..fd63cc04 --- /dev/null +++ b/mautrix-patched/requests.go @@ -0,0 +1,711 @@ +package mautrix + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + "maunium.net/go/mautrix/crypto/signatures" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +type AuthType string + +const ( + AuthTypePassword AuthType = "m.login.password" + AuthTypeReCAPTCHA AuthType = "m.login.recaptcha" + AuthTypeOAuth2 AuthType = "m.login.oauth2" + AuthTypeSSO AuthType = "m.login.sso" + AuthTypeEmail AuthType = "m.login.email.identity" + AuthTypeMSISDN AuthType = "m.login.msisdn" + AuthTypeToken AuthType = "m.login.token" + AuthTypeDummy AuthType = "m.login.dummy" + AuthTypeAppservice AuthType = "m.login.application_service" + + AuthTypeSynapseJWT AuthType = "org.matrix.login.jwt" + + AuthTypeDevtureSharedSecret AuthType = "com.devture.shared_secret_auth" +) + +type IdentifierType string + +const ( + IdentifierTypeUser = "m.id.user" + IdentifierTypeThirdParty = "m.id.thirdparty" + IdentifierTypePhone = "m.id.phone" +) + +type Direction rune + +func (d Direction) MarshalJSON() ([]byte, error) { + return json.Marshal(string(d)) +} + +func (d *Direction) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + switch str { + case "f": + *d = DirectionForward + case "b": + *d = DirectionBackward + default: + return fmt.Errorf("invalid direction %q, must be 'f' or 'b'", str) + } + return nil +} + +const ( + DirectionForward Direction = 'f' + DirectionBackward Direction = 'b' +) + +// ReqRegister is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register +type ReqRegister[UIAType any] struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + DeviceID id.DeviceID `json:"device_id,omitempty"` + InitialDeviceDisplayName string `json:"initial_device_display_name,omitempty"` + InhibitLogin bool `json:"inhibit_login,omitempty"` + RefreshToken bool `json:"refresh_token,omitempty"` + Auth UIAType `json:"auth,omitempty"` + + // Type for registration, only used for appservice user registrations + // https://spec.matrix.org/v1.2/application-service-api/#server-admin-style-permissions + Type AuthType `json:"type,omitempty"` +} + +type BaseAuthData struct { + Type AuthType `json:"type"` + Session string `json:"session,omitempty"` +} + +type UserIdentifier struct { + Type IdentifierType `json:"type"` + + User string `json:"user,omitempty"` + + Medium string `json:"medium,omitempty"` + Address string `json:"address,omitempty"` + + Country string `json:"country,omitempty"` + Phone string `json:"phone,omitempty"` +} + +// ReqLogin is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login +type ReqLogin struct { + Type AuthType `json:"type"` + Identifier UserIdentifier `json:"identifier"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` + DeviceID id.DeviceID `json:"device_id,omitempty"` + InitialDeviceDisplayName string `json:"initial_device_display_name,omitempty"` + RefreshToken bool `json:"refresh_token,omitempty"` + + // Whether or not the returned credentials should be stored in the Client + StoreCredentials bool `json:"-"` + // Whether or not the returned .well-known data should update the homeserver URL in the Client + StoreHomeserverURL bool `json:"-"` +} + +type ReqPutDevice struct { + DisplayName string `json:"display_name,omitempty"` +} + +type ReqUIAuthFallback struct { + Session string `json:"session"` + User string `json:"user"` +} + +type ReqUIAuthLogin struct { + BaseAuthData + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` +} + +// ReqCreateRoom is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom +type ReqCreateRoom struct { + Visibility string `json:"visibility,omitempty"` + RoomAliasName string `json:"room_alias_name,omitempty"` + Name string `json:"name,omitempty"` + Topic string `json:"topic,omitempty"` + Invite []id.UserID `json:"invite,omitempty"` + Invite3PID []ReqInvite3PID `json:"invite_3pid,omitempty"` + CreationContent map[string]interface{} `json:"creation_content,omitempty"` + InitialState []*event.Event `json:"initial_state,omitempty"` + Preset string `json:"preset,omitempty"` + IsDirect bool `json:"is_direct,omitempty"` + RoomVersion id.RoomVersion `json:"room_version,omitempty"` + + PowerLevelOverride *event.PowerLevelsEventContent `json:"power_level_content_override,omitempty"` + + MeowRoomID id.RoomID `json:"fi.mau.room_id,omitempty"` + MeowCreateTS int64 `json:"fi.mau.origin_server_ts,omitempty"` + BeeperInitialMembers []id.UserID `json:"com.beeper.initial_members,omitempty"` + BeeperAutoJoinInvites bool `json:"com.beeper.auto_join_invites,omitempty"` + BeeperLocalRoomID id.RoomID `json:"com.beeper.local_room_id,omitempty"` + BeeperBridgeName string `json:"com.beeper.bridge_name,omitempty"` + BeeperBridgeAccountID string `json:"com.beeper.bridge_account_id,omitempty"` +} + +// ReqRedact is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidredacteventidtxnid +type ReqRedact struct { + Reason string + TxnID string + Extra map[string]interface{} +} + +type ReqRedactUser struct { + Reason string `json:"reason"` + Limit int `json:"-"` +} + +type ReqMembers struct { + At string `json:"at"` + Membership event.Membership `json:"membership,omitempty"` + NotMembership event.Membership `json:"not_membership,omitempty"` +} + +type ReqJoinRoom struct { + Via []string `json:"-"` + Reason string `json:"reason,omitempty"` + ThirdPartySigned any `json:"third_party_signed,omitempty"` +} + +type ReqKnockRoom struct { + Via []string `json:"-"` + Reason string `json:"reason,omitempty"` +} + +type ReqSearchUserDirectory struct { + SearchTerm string `json:"search_term"` + Limit int `json:"limit,omitempty"` +} + +type ReqMutualRooms struct { + From string `json:"-"` +} + +// ReqInvite3PID is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite-1 +// It is also a JSON object used in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom +type ReqInvite3PID struct { + IDServer string `json:"id_server"` + Medium string `json:"medium"` + Address string `json:"address"` +} + +type ReqLeave struct { + Reason string `json:"reason,omitempty"` +} + +// ReqInviteUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite +type ReqInviteUser struct { + Reason string `json:"reason,omitempty"` + UserID id.UserID `json:"user_id"` +} + +// ReqKickUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick +type ReqKickUser struct { + Reason string `json:"reason,omitempty"` + UserID id.UserID `json:"user_id"` +} + +// ReqBanUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban +type ReqBanUser struct { + Reason string `json:"reason,omitempty"` + UserID id.UserID `json:"user_id"` + + MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` +} + +// ReqUnbanUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban +type ReqUnbanUser struct { + Reason string `json:"reason,omitempty"` + UserID id.UserID `json:"user_id"` +} + +// ReqTyping is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid +type ReqTyping struct { + Typing bool `json:"typing"` + Timeout int64 `json:"timeout,omitempty"` +} + +type ReqPresence struct { + Presence event.Presence `json:"presence"` + StatusMsg string `json:"status_msg,omitempty"` +} + +type ReqAliasCreate struct { + RoomID id.RoomID `json:"room_id"` +} + +type OneTimeKey struct { + Key id.Curve25519 `json:"key"` + Fallback bool `json:"fallback,omitempty"` + Signatures signatures.Signatures `json:"signatures,omitempty"` + Unsigned map[string]any `json:"unsigned,omitempty"` + IsSigned bool `json:"-"` + + // Raw data in the one-time key. This must be used for signature verification to ensure unrecognized fields + // aren't thrown away (because that would invalidate the signature). + RawData json.RawMessage `json:"-"` +} + +type serializableOTK OneTimeKey + +func (otk *OneTimeKey) UnmarshalJSON(data []byte) (err error) { + if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' { + err = json.Unmarshal(data, &otk.Key) + otk.Signatures = nil + otk.Unsigned = nil + otk.IsSigned = false + } else { + err = json.Unmarshal(data, (*serializableOTK)(otk)) + otk.RawData = data + otk.IsSigned = true + } + return err +} + +func (otk *OneTimeKey) MarshalJSON() ([]byte, error) { + if !otk.IsSigned { + return json.Marshal(otk.Key) + } else { + return json.Marshal((*serializableOTK)(otk)) + } +} + +type ReqUploadKeys struct { + DeviceKeys *DeviceKeys `json:"device_keys,omitempty"` + OneTimeKeys map[id.KeyID]OneTimeKey `json:"one_time_keys,omitempty"` +} + +type ReqKeysSignatures struct { + UserID id.UserID `json:"user_id"` + DeviceID id.DeviceID `json:"device_id,omitempty"` + Algorithms []id.Algorithm `json:"algorithms,omitempty"` + Usage []id.CrossSigningUsage `json:"usage,omitempty"` + Keys map[id.KeyID]string `json:"keys"` + Signatures signatures.Signatures `json:"signatures"` +} + +type ReqUploadSignatures map[id.UserID]map[string]ReqKeysSignatures + +type DeviceKeys struct { + UserID id.UserID `json:"user_id"` + DeviceID id.DeviceID `json:"device_id"` + Algorithms []id.Algorithm `json:"algorithms"` + Keys KeyMap `json:"keys"` + Signatures signatures.Signatures `json:"signatures"` + Dehydrated bool `json:"dehydrated,omitempty"` + Unsigned map[string]any `json:"unsigned,omitempty"` + Extra map[string]any `json:"-"` +} + +type serializableDeviceKeys DeviceKeys + +func (dk *DeviceKeys) deleteStandardExtraFields() { + if len(dk.Extra) == 0 { + return + } + delete(dk.Extra, "user_id") + delete(dk.Extra, "device_id") + delete(dk.Extra, "algorithms") + delete(dk.Extra, "keys") + delete(dk.Extra, "signatures") + delete(dk.Extra, "dehydrated") + delete(dk.Extra, "unsigned") +} + +func (dk *DeviceKeys) MarshalJSON() ([]byte, error) { + dk.deleteStandardExtraFields() + return event.MarshalMerge((*serializableDeviceKeys)(dk), dk.Extra) +} + +func (dk *DeviceKeys) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, (*serializableDeviceKeys)(dk)); err != nil { + return err + } + if err := json.Unmarshal(data, &dk.Extra); err != nil { + return err + } + dk.deleteStandardExtraFields() + return nil +} + +type CrossSigningKeys struct { + UserID id.UserID `json:"user_id"` + Usage []id.CrossSigningUsage `json:"usage"` + Keys map[id.KeyID]id.Ed25519 `json:"keys"` + Signatures signatures.Signatures `json:"signatures,omitempty"` +} + +func (csk *CrossSigningKeys) FirstKey() id.Ed25519 { + for _, key := range csk.Keys { + return key + } + return "" +} + +type UploadCrossSigningKeysReq[UIAType any] struct { + Master CrossSigningKeys `json:"master_key"` + SelfSigning CrossSigningKeys `json:"self_signing_key"` + UserSigning CrossSigningKeys `json:"user_signing_key"` + Auth UIAType `json:"auth,omitempty"` +} + +type KeyMap map[id.DeviceKeyID]string + +func (km KeyMap) GetEd25519(deviceID id.DeviceID) id.Ed25519 { + val, ok := km[id.NewDeviceKeyID(id.KeyAlgorithmEd25519, deviceID)] + if !ok { + return "" + } + return id.Ed25519(val) +} + +func (km KeyMap) GetCurve25519(deviceID id.DeviceID) id.Curve25519 { + val, ok := km[id.NewDeviceKeyID(id.KeyAlgorithmCurve25519, deviceID)] + if !ok { + return "" + } + return id.Curve25519(val) +} + +type ReqQueryKeys struct { + DeviceKeys DeviceKeysRequest `json:"device_keys"` + Timeout int64 `json:"timeout,omitempty"` +} + +type DeviceKeysRequest map[id.UserID]DeviceIDList + +type DeviceIDList []id.DeviceID + +type ReqClaimKeys struct { + OneTimeKeys OneTimeKeysRequest `json:"one_time_keys"` + + Timeout int64 `json:"timeout,omitempty"` +} + +type OneTimeKeysRequest map[id.UserID]map[id.DeviceID]id.KeyAlgorithm + +type ReqSendToDevice struct { + Messages map[id.UserID]map[id.DeviceID]*event.Content `json:"messages"` +} + +type ReqSendEvent struct { + Timestamp int64 + TransactionID string + UnstableDelay time.Duration + UnstableStickyDuration time.Duration + DontEncrypt bool + MeowEventID id.EventID +} + +type ReqDelayedEvents struct { + DelayID id.DelayID `json:"-"` + Status event.DelayStatus `json:"-"` + NextBatch string `json:"-"` +} + +type ReqUpdateDelayedEvent struct { + DelayID id.DelayID `json:"-"` + Action event.DelayAction `json:"action"` +} + +// ReqDeviceInfo is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3devicesdeviceid +type ReqDeviceInfo struct { + DisplayName string `json:"display_name,omitempty"` +} + +// ReqDeleteDevice is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#delete_matrixclientv3devicesdeviceid +type ReqDeleteDevice[UIAType any] struct { + Auth UIAType `json:"auth,omitempty"` +} + +// ReqDeleteDevices is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3delete_devices +type ReqDeleteDevices[UIAType any] struct { + Devices []id.DeviceID `json:"devices"` + Auth UIAType `json:"auth,omitempty"` +} + +type ReqPutPushRule struct { + Before string `json:"-"` + After string `json:"-"` + + Actions []*pushrules.PushAction `json:"actions"` + Conditions []*pushrules.PushCondition `json:"conditions,omitempty"` + Pattern string `json:"pattern,omitempty"` +} + +type ReqBeeperBatchSend struct { + // ForwardIfNoMessages should be set to true if the batch should be forward + // backfilled if there are no messages currently in the room. + ForwardIfNoMessages bool `json:"forward_if_no_messages"` + Forward bool `json:"forward"` + SendNotification bool `json:"send_notification"` + MarkReadBy id.UserID `json:"mark_read_by,omitempty"` + Events []*event.Event `json:"events"` +} + +type ReqSetReadMarkers struct { + Read id.EventID `json:"m.read,omitempty"` + ReadPrivate id.EventID `json:"m.read.private,omitempty"` + FullyRead id.EventID `json:"m.fully_read,omitempty"` + // Allow moving m.fully_read backwards via MSC4446. + AllowBackward bool `json:"com.beeper.allow_backward,omitempty"` + + BeeperReadExtra interface{} `json:"com.beeper.read.extra,omitempty"` + BeeperReadPrivateExtra interface{} `json:"com.beeper.read.private.extra,omitempty"` + BeeperFullyReadExtra interface{} `json:"com.beeper.fully_read.extra,omitempty"` +} + +type BeeperInboxDone struct { + Delta int64 `json:"at_delta"` + AtOrder int64 `json:"at_order"` +} + +type ReqSetBeeperInboxState struct { + MarkedUnread *bool `json:"marked_unread,omitempty"` + Done *BeeperInboxDone `json:"done,omitempty"` + ReadMarkers *ReqSetReadMarkers `json:"read_markers,omitempty"` +} + +type ReqSendReceipt struct { + ThreadID string `json:"thread_id,omitempty"` + // Allow moving m.fully_read backwards via MSC4446. + AllowBackward bool `json:"com.beeper.allow_backward,omitempty"` +} + +type ReqPublicRooms struct { + IncludeAllNetworks bool + Limit int + Since string + ThirdPartyInstanceID string + Server string +} + +func (req *ReqPublicRooms) Query() map[string]string { + query := map[string]string{} + if req == nil { + return query + } + if req.IncludeAllNetworks { + query["include_all_networks"] = "true" + } + if req.Limit > 0 { + query["limit"] = strconv.Itoa(req.Limit) + } + if req.Since != "" { + query["since"] = req.Since + } + if req.ThirdPartyInstanceID != "" { + query["third_party_instance_id"] = req.ThirdPartyInstanceID + } + if req.Server != "" { + query["server"] = req.Server + } + return query +} + +// ReqHierarchy contains the parameters for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy +// +// As it's a GET method, there is no JSON body, so this is only query parameters. +type ReqHierarchy struct { + // A pagination token from a previous Hierarchy call. + // If specified, max_depth and suggested_only cannot be changed from the first request. + From string + // Limit for the maximum number of rooms to include per response. + // The server will apply a default value if a limit isn't provided. + Limit int + // Limit for how far to go into the space. When reached, no further child rooms will be returned. + // The server will apply a default value if a max depth isn't provided. + MaxDepth *int + // Flag to indicate whether the server should only consider suggested rooms. + // Suggested rooms are annotated in their m.space.child event contents. + SuggestedOnly bool +} + +func (req *ReqHierarchy) Query() map[string]string { + query := map[string]string{} + if req == nil { + return query + } + if req.From != "" { + query["from"] = req.From + } + if req.Limit > 0 { + query["limit"] = strconv.Itoa(req.Limit) + } + if req.MaxDepth != nil { + query["max_depth"] = strconv.Itoa(*req.MaxDepth) + } + if req.SuggestedOnly { + query["suggested_only"] = "true" + } + return query +} + +type ReqAppservicePing struct { + TxnID string `json:"transaction_id,omitempty"` +} + +type ReqBeeperMergeRoom struct { + NewRoom ReqCreateRoom `json:"create"` + Key string `json:"key"` + Rooms []id.RoomID `json:"rooms"` + User id.UserID `json:"user_id"` +} + +type BeeperSplitRoomPart struct { + UserID id.UserID `json:"user_id"` + Values []string `json:"values"` + NewRoom ReqCreateRoom `json:"create"` +} + +type ReqBeeperSplitRoom struct { + RoomID id.RoomID `json:"-"` + + Key string `json:"key"` + Parts []BeeperSplitRoomPart `json:"parts"` +} + +type ReqRoomKeysVersionCreate[A any] struct { + Algorithm id.KeyBackupAlgorithm `json:"algorithm"` + AuthData A `json:"auth_data"` +} + +type ReqRoomKeysVersionUpdate[A any] struct { + Algorithm id.KeyBackupAlgorithm `json:"algorithm"` + AuthData A `json:"auth_data"` + Version id.KeyBackupVersion `json:"version,omitempty"` +} + +type ReqKeyBackup struct { + Rooms map[id.RoomID]ReqRoomKeyBackup `json:"rooms"` +} + +type ReqRoomKeyBackup struct { + Sessions map[id.SessionID]ReqKeyBackupData `json:"sessions"` +} + +type ReqKeyBackupData struct { + FirstMessageIndex int `json:"first_message_index"` + ForwardedCount int `json:"forwarded_count"` + IsVerified bool `json:"is_verified"` + SessionData json.RawMessage `json:"session_data"` +} + +type ReqReport struct { + Reason string `json:"reason,omitempty"` + Score int `json:"score,omitempty"` +} + +type ReqGetRelations struct { + RelationType event.RelationType + EventType event.Type + + Dir Direction + From string + To string + Limit int + Recurse bool +} + +func (rgr *ReqGetRelations) PathSuffix() ClientURLPath { + if rgr.RelationType != "" { + if rgr.EventType.Type != "" { + return ClientURLPath{rgr.RelationType, rgr.EventType.Type} + } + return ClientURLPath{rgr.RelationType} + } + return ClientURLPath{} +} + +func (rgr *ReqGetRelations) Query() map[string]string { + query := map[string]string{} + if rgr.Dir != 0 { + query["dir"] = string(rgr.Dir) + } + if rgr.From != "" { + query["from"] = rgr.From + } + if rgr.To != "" { + query["to"] = rgr.To + } + if rgr.Limit > 0 { + query["limit"] = strconv.Itoa(rgr.Limit) + } + if rgr.Recurse { + query["recurse"] = "true" + } + return query +} + +// ReqSuspend is the request body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type ReqSuspend struct { + Suspended bool `json:"suspended"` +} + +// ReqLocked is the request body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type ReqLocked struct { + Locked bool `json:"locked"` +} + +type ReqSearchWrapper struct { + SearchCategories ReqSearchCategoryWrapper `json:"search_categories"` +} + +type ReqSearchCategoryWrapper struct { + RoomEvents *ReqSearch `json:"room_events"` +} + +type ReqSearch struct { + // This is a query param + NextBatch string `json:"-"` + + SearchTerm string `json:"search_term"` + Filter *FilterPart `json:"filter,omitempty"` + Keys []string `json:"keys,omitempty"` + IncludeState bool `json:"include_state,omitempty"` + OrderBy string `json:"order_by,omitempty"` + + EventContext SearchEventContext `json:"event_context,omitzero"` + Groupings SearchGroupings `json:"groupings,omitzero"` +} + +func (rs *ReqSearch) Query() map[string]string { + query := map[string]string{} + if rs.NextBatch != "" { + query["next_batch"] = rs.NextBatch + } + return query +} + +type SearchEventContext struct { + BeforeLimit int `json:"before_limit,omitempty"` + AfterLimit int `json:"after_limit,omitempty"` + IncludeProfile bool `json:"include_profile,omitempty"` +} + +func (sec SearchEventContext) IsZero() bool { + return sec.BeforeLimit == 0 && sec.AfterLimit == 0 && !sec.IncludeProfile +} + +type SearchGroupings struct { + GroupBy []SearchGroup `json:"group_by,omitempty"` +} + +func (sg SearchGroupings) IsZero() bool { + return len(sg.GroupBy) == 0 +} + +type SearchGroup struct { + Key string `json:"key"` +} diff --git a/mautrix-patched/responses.go b/mautrix-patched/responses.go new file mode 100644 index 00000000..a7e50590 --- /dev/null +++ b/mautrix-patched/responses.go @@ -0,0 +1,810 @@ +package mautrix + +import ( + "bytes" + "encoding/json" + "maps" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// RespWhoami is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3accountwhoami +type RespWhoami struct { + UserID id.UserID `json:"user_id"` + DeviceID id.DeviceID `json:"device_id"` +} + +// RespCreateFilter is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridfilter +type RespCreateFilter struct { + FilterID string `json:"filter_id"` +} + +// RespJoinRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidjoin +type RespJoinRoom struct { + RoomID id.RoomID `json:"room_id"` +} + +// RespKnockRoom is the JSON response for https://spec.matrix.org/v1.13/client-server-api/#post_matrixclientv3knockroomidoralias +type RespKnockRoom struct { + RoomID id.RoomID `json:"room_id"` +} + +// RespLeaveRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidleave +type RespLeaveRoom struct{} + +// RespForgetRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidforget +type RespForgetRoom struct{} + +// RespInviteUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite +type RespInviteUser struct{} + +// RespKickUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick +type RespKickUser struct{} + +// RespBanUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban +type RespBanUser struct{} + +// RespUnbanUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban +type RespUnbanUser struct{} + +// RespTyping is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid +type RespTyping struct{} + +// RespPresence is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus +type RespPresence struct { + Presence event.Presence `json:"presence"` + LastActiveAgo int `json:"last_active_ago"` + StatusMsg string `json:"status_msg"` + CurrentlyActive bool `json:"currently_active"` +} + +// RespJoinedRooms is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3joined_rooms +type RespJoinedRooms struct { + JoinedRooms []id.RoomID `json:"joined_rooms"` +} + +// RespJoinedMembers is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidjoined_members +type RespJoinedMembers struct { + Joined map[id.UserID]JoinedMember `json:"joined"` +} + +type JoinedMember struct { + DisplayName string `json:"display_name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// RespMessages is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidmessages +type RespMessages struct { + Start string `json:"start"` + Chunk []*event.Event `json:"chunk"` + State []*event.Event `json:"state"` + End string `json:"end,omitempty"` +} + +// RespContext is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidcontexteventid +type RespContext struct { + End string `json:"end"` + Event *event.Event `json:"event"` + EventsAfter []*event.Event `json:"events_after"` + EventsBefore []*event.Event `json:"events_before"` + Start string `json:"start"` + State []*event.Event `json:"state"` +} + +// RespSendEvent is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid +type RespSendEvent struct { + EventID id.EventID `json:"event_id"` + + UnstableDelayID id.DelayID `json:"delay_id,omitempty"` +} + +type RespUpdateDelayedEvent struct{} + +type RespDelayedEvents struct { + Scheduled []*event.ScheduledDelayedEvent `json:"scheduled,omitempty"` + Finalised []*event.FinalisedDelayedEvent `json:"finalised,omitempty"` + NextBatch string `json:"next_batch,omitempty"` + + // Deprecated: Synapse implementation still returns this + DelayedEvents []*event.ScheduledDelayedEvent `json:"delayed_events,omitempty"` + // Deprecated: Synapse implementation still returns this + FinalisedEvents []*event.FinalisedDelayedEvent `json:"finalised_events,omitempty"` +} + +type RespRedactUserEvents struct { + IsMoreEvents bool `json:"is_more_events"` + RedactedEvents struct { + Total int `json:"total"` + SoftFailed int `json:"soft_failed"` + } `json:"redacted_events"` +} + +// RespMediaConfig is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixmediav3config +type RespMediaConfig struct { + UploadSize int64 `json:"m.upload.size,omitempty"` +} + +// RespMediaUpload is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixmediav3upload +type RespMediaUpload struct { + ContentURI id.ContentURI `json:"content_uri"` +} + +// RespCreateMXC is the JSON response for https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create +type RespCreateMXC struct { + ContentURI id.ContentURI `json:"content_uri"` + UnusedExpiresAt jsontime.UnixMilli `json:"unused_expires_at,omitempty"` + + UnstableUploadURL string `json:"com.beeper.msc3870.upload_url,omitempty"` + + // Beeper extensions for uploading unique media only once + BeeperUniqueID string `json:"com.beeper.unique_id,omitempty"` + BeeperCompletedAt jsontime.UnixMilli `json:"com.beeper.completed_at,omitempty"` +} + +// RespPreviewURL is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixmediav3preview_url +type RespPreviewURL = event.LinkPreview + +// RespUserInteractive is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#user-interactive-authentication-api +type RespUserInteractive struct { + Flows []UIAFlow `json:"flows,omitempty"` + Params map[AuthType]interface{} `json:"params,omitempty"` + Session string `json:"session,omitempty"` + Completed []string `json:"completed,omitempty"` + + ErrCode string `json:"errcode,omitempty"` + Error string `json:"error,omitempty"` +} + +type UIAFlow struct { + Stages []AuthType `json:"stages,omitempty"` +} + +// HasSingleStageFlow returns true if there exists at least 1 Flow with a single stage of stageName. +func (r RespUserInteractive) HasSingleStageFlow(stageName AuthType) bool { + for _, f := range r.Flows { + if len(f.Stages) == 1 && f.Stages[0] == stageName { + return true + } + } + return false +} + +// RespUserDisplayName is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname +type RespUserDisplayName struct { + DisplayName string `json:"displayname"` +} + +type RespUserProfile struct { + DisplayName string `json:"displayname,omitempty"` + AvatarURL id.ContentURI `json:"avatar_url,omitempty"` + Extra map[string]any `json:"-"` +} + +type marshalableUserProfile RespUserProfile + +func (r *RespUserProfile) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, &r.Extra) + if err != nil { + return err + } + r.DisplayName, _ = r.Extra["displayname"].(string) + avatarURL, _ := r.Extra["avatar_url"].(string) + if avatarURL != "" { + r.AvatarURL, _ = id.ParseContentURI(avatarURL) + } + delete(r.Extra, "displayname") + delete(r.Extra, "avatar_url") + return nil +} + +func (r *RespUserProfile) MarshalJSON() ([]byte, error) { + if len(r.Extra) == 0 { + return json.Marshal((*marshalableUserProfile)(r)) + } + marshalMap := maps.Clone(r.Extra) + if r.DisplayName != "" { + marshalMap["displayname"] = r.DisplayName + } else { + delete(marshalMap, "displayname") + } + if !r.AvatarURL.IsEmpty() { + marshalMap["avatar_url"] = r.AvatarURL.String() + } else { + delete(marshalMap, "avatar_url") + } + return json.Marshal(marshalMap) +} + +type RespSearchUserDirectory struct { + Limited bool `json:"limited"` + Results []*UserDirectoryEntry `json:"results"` +} + +type UserDirectoryEntry struct { + RespUserProfile + UserID id.UserID `json:"user_id"` +} + +func (r *UserDirectoryEntry) UnmarshalJSON(data []byte) error { + err := r.RespUserProfile.UnmarshalJSON(data) + if err != nil { + return err + } + userIDStr, _ := r.Extra["user_id"].(string) + r.UserID = id.UserID(userIDStr) + delete(r.Extra, "user_id") + return nil +} + +func (r *UserDirectoryEntry) MarshalJSON() ([]byte, error) { + if r.Extra == nil { + r.Extra = make(map[string]any) + } + r.Extra["user_id"] = r.UserID.String() + return r.RespUserProfile.MarshalJSON() +} + +type RespMutualRooms struct { + Joined []id.RoomID `json:"joined"` + NextBatch string `json:"next_batch,omitempty"` + Count int `json:"count,omitempty"` +} + +type RespRoomSummary struct { + PublicRoomInfo + + Membership event.Membership `json:"membership,omitempty"` + + UnstableRoomVersion id.RoomVersion `json:"im.nheko.summary.room_version,omitempty"` + UnstableRoomVersionOld id.RoomVersion `json:"im.nheko.summary.version,omitempty"` + UnstableEncryption id.Algorithm `json:"im.nheko.summary.encryption,omitempty"` +} + +// RespRegisterAvailable is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv3registeravailable +type RespRegisterAvailable struct { + Available bool `json:"available"` +} + +// RespRegister is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register +type RespRegister struct { + AccessToken string `json:"access_token,omitempty"` + DeviceID id.DeviceID `json:"device_id,omitempty"` + UserID id.UserID `json:"user_id"` + + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresInMS int64 `json:"expires_in_ms,omitempty"` + + // Deprecated: homeserver should be parsed from the user ID + HomeServer string `json:"home_server,omitempty"` +} + +type LoginFlow struct { + Type AuthType `json:"type"` +} + +// RespLoginFlows is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3login +type RespLoginFlows struct { + Flows []LoginFlow `json:"flows"` +} + +func (rlf *RespLoginFlows) FirstFlowOfType(flowTypes ...AuthType) *LoginFlow { + for _, flow := range rlf.Flows { + for _, flowType := range flowTypes { + if flow.Type == flowType { + return &flow + } + } + } + return nil +} + +func (rlf *RespLoginFlows) HasFlow(flowType ...AuthType) bool { + return rlf.FirstFlowOfType(flowType...) != nil +} + +// RespLogin is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login +type RespLogin struct { + AccessToken string `json:"access_token"` + DeviceID id.DeviceID `json:"device_id"` + UserID id.UserID `json:"user_id"` + WellKnown *ClientWellKnown `json:"well_known,omitempty"` + + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresInMS int64 `json:"expires_in_ms,omitempty"` +} + +// RespLogout is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logout +type RespLogout struct{} + +// RespCreateRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom +type RespCreateRoom struct { + RoomID id.RoomID `json:"room_id"` +} + +type RespMembers struct { + Chunk []*event.Event `json:"chunk"` +} + +type LazyLoadSummary struct { + Heroes []id.UserID `json:"m.heroes,omitempty"` + JoinedMemberCount *int `json:"m.joined_member_count,omitempty"` + InvitedMemberCount *int `json:"m.invited_member_count,omitempty"` +} + +func (lls *LazyLoadSummary) IsZero() bool { + return lls == nil || (len(lls.Heroes) == 0 && ptr.Val(lls.JoinedMemberCount) == 0 && ptr.Val(lls.InvitedMemberCount) == 0) +} + +func (lls *LazyLoadSummary) MemberCount() int { + if lls == nil { + return 0 + } + return ptr.Val(lls.JoinedMemberCount) + ptr.Val(lls.InvitedMemberCount) +} + +func (lls *LazyLoadSummary) Equal(other *LazyLoadSummary) bool { + if lls == other { + return true + } else if lls == nil || other == nil { + return false + } + return ptr.Val(lls.JoinedMemberCount) == ptr.Val(other.JoinedMemberCount) && + ptr.Val(lls.InvitedMemberCount) == ptr.Val(other.InvitedMemberCount) && + slices.Equal(lls.Heroes, other.Heroes) +} + +type SyncEventsList struct { + Events []*event.Event `json:"events,omitempty"` +} + +func (sel SyncEventsList) IsZero() bool { + return len(sel.Events) == 0 +} + +type SyncTimeline struct { + SyncEventsList + Limited bool `json:"limited,omitempty"` + PrevBatch string `json:"prev_batch,omitempty"` +} + +func (st SyncTimeline) IsZero() bool { + return len(st.Events) == 0 && !st.Limited && st.PrevBatch == "" +} + +// RespSync is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync +type RespSync struct { + NextBatch string `json:"next_batch"` + + AccountData SyncEventsList `json:"account_data,omitzero"` + Presence SyncEventsList `json:"presence,omitzero"` + ToDevice SyncEventsList `json:"to_device,omitzero"` + + DeviceLists DeviceLists `json:"device_lists,omitzero"` + DeviceOTKCount OTKCount `json:"device_one_time_keys_count,omitzero"` + FallbackKeys []id.KeyAlgorithm `json:"device_unused_fallback_key_types"` + + Rooms RespSyncRooms `json:"rooms,omitzero"` +} + +type RespSyncRooms struct { + Leave map[id.RoomID]*SyncLeftRoom `json:"leave,omitempty"` + Join map[id.RoomID]*SyncJoinedRoom `json:"join,omitempty"` + Invite map[id.RoomID]*SyncInvitedRoom `json:"invite,omitempty"` + Knock map[id.RoomID]*SyncKnockedRoom `json:"knock,omitempty"` +} + +func (rsr RespSyncRooms) IsZero() bool { + return len(rsr.Leave) == 0 && len(rsr.Join) == 0 && len(rsr.Invite) == 0 && len(rsr.Knock) == 0 +} + +type DeviceLists struct { + Changed []id.UserID `json:"changed,omitempty"` + Left []id.UserID `json:"left,omitempty"` +} + +func (dl DeviceLists) IsZero() bool { + return len(dl.Changed) == 0 && len(dl.Left) == 0 +} + +type OTKCount struct { + Curve25519 int `json:"curve25519,omitempty"` + SignedCurve25519 int `json:"signed_curve25519,omitempty"` + + // For appservice OTK counts only: the user ID in question + UserID id.UserID `json:"-"` + DeviceID id.DeviceID `json:"-"` +} + +func (oc OTKCount) IsZero() bool { + return oc.Curve25519 == 0 && oc.SignedCurve25519 == 0 +} + +type SyncLeftRoom struct { + Summary LazyLoadSummary `json:"summary,omitzero"` + State SyncEventsList `json:"state,omitzero"` + StateAfter *SyncEventsList `json:"state_after,omitempty"` + Timeline SyncTimeline `json:"timeline,omitzero"` +} + +type BeeperInboxPreviewEvent struct { + EventID id.EventID `json:"event_id"` + Timestamp jsontime.UnixMilli `json:"origin_server_ts"` + Event *event.Event `json:"event,omitempty"` +} + +type SyncJoinedRoom struct { + Summary LazyLoadSummary `json:"summary,omitzero"` + State SyncEventsList `json:"state,omitzero"` + StateAfter *SyncEventsList `json:"state_after,omitempty"` + Timeline SyncTimeline `json:"timeline,omitzero"` + Ephemeral SyncEventsList `json:"ephemeral,omitzero"` + AccountData SyncEventsList `json:"account_data,omitzero"` + Sticky SyncEventsList `json:"msc4354_sticky,omitzero"` + + UnreadNotifications *UnreadNotificationCounts `json:"unread_notifications,omitempty"` + // https://github.com/matrix-org/matrix-spec-proposals/pull/2654 + MSC2654UnreadCount *int `json:"org.matrix.msc2654.unread_count,omitempty"` + // Beeper extension + BeeperInboxPreview *BeeperInboxPreviewEvent `json:"com.beeper.inbox.preview,omitempty"` +} + +type UnreadNotificationCounts struct { + HighlightCount int `json:"highlight_count"` + NotificationCount int `json:"notification_count"` +} + +type SyncInvitedRoom struct { + State SyncEventsList `json:"invite_state"` +} + +type SyncKnockedRoom struct { + State SyncEventsList `json:"knock_state"` +} + +type RespTurnServer struct { + Username string `json:"username"` + Password string `json:"password"` + TTL int `json:"ttl"` + URIs []string `json:"uris"` +} + +type RespAliasCreate struct{} +type RespAliasDelete struct{} +type RespAliasResolve struct { + RoomID id.RoomID `json:"room_id"` + Servers []string `json:"servers"` +} +type RespAliasList struct { + Aliases []id.RoomAlias `json:"aliases"` +} + +type RespUploadKeys struct { + OneTimeKeyCounts OTKCount `json:"one_time_key_counts"` +} + +type RespQueryKeys struct { + Failures map[string]interface{} `json:"failures,omitempty"` + DeviceKeys map[id.UserID]map[id.DeviceID]DeviceKeys `json:"device_keys"` + MasterKeys map[id.UserID]CrossSigningKeys `json:"master_keys"` + SelfSigningKeys map[id.UserID]CrossSigningKeys `json:"self_signing_keys"` + UserSigningKeys map[id.UserID]CrossSigningKeys `json:"user_signing_keys"` +} + +type RespClaimKeys struct { + Failures map[string]interface{} `json:"failures,omitempty"` + OneTimeKeys map[id.UserID]map[id.DeviceID]map[id.KeyID]OneTimeKey `json:"one_time_keys"` +} + +type RespUploadSignatures struct { + Failures map[string]interface{} `json:"failures,omitempty"` +} + +type RespKeyChanges struct { + Changed []id.UserID `json:"changed"` + Left []id.UserID `json:"left"` +} + +type RespSendToDevice struct{} + +// RespDevicesInfo is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3devices +type RespDevicesInfo struct { + Devices []RespDeviceInfo `json:"devices"` +} + +// RespDeviceInfo is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3devicesdeviceid +type RespDeviceInfo struct { + DeviceID id.DeviceID `json:"device_id"` + DisplayName string `json:"display_name"` + LastSeenIP string `json:"last_seen_ip"` + LastSeenTS int64 `json:"last_seen_ts"` +} + +type RespBeeperBatchSend struct { + EventIDs []id.EventID `json:"event_ids"` +} + +// RespCapabilities is the JSON response for https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3capabilities +type RespCapabilities struct { + RoomVersions *CapRoomVersions `json:"m.room_versions,omitempty"` + ChangePassword *CapBooleanTrue `json:"m.change_password,omitempty"` + SetDisplayname *CapBooleanTrue `json:"m.set_displayname,omitempty"` + SetAvatarURL *CapBooleanTrue `json:"m.set_avatar_url,omitempty"` + ThreePIDChanges *CapBooleanTrue `json:"m.3pid_changes,omitempty"` + GetLoginToken *CapBooleanTrue `json:"m.get_login_token,omitempty"` + UnstableAccountModeration *CapUnstableAccountModeration `json:"uk.timedout.msc4323,omitempty"` + + Custom map[string]interface{} `json:"-"` +} + +type serializableRespCapabilities RespCapabilities + +func (rc *RespCapabilities) UnmarshalJSON(data []byte) error { + res := gjson.GetBytes(data, "capabilities") + if !res.Exists() || !res.IsObject() { + return nil + } + if res.Index > 0 { + data = data[res.Index : res.Index+len(res.Raw)] + } else { + data = []byte(res.Raw) + } + err := json.Unmarshal(data, (*serializableRespCapabilities)(rc)) + if err != nil { + return err + } + err = json.Unmarshal(data, &rc.Custom) + if err != nil { + return err + } + // Remove non-custom capabilities from the custom map so that they don't get overridden when serializing back + for _, field := range reflect.VisibleFields(reflect.TypeOf(rc).Elem()) { + jsonTag := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonTag != "-" && jsonTag != "" { + delete(rc.Custom, jsonTag) + } + } + return nil +} + +func (rc *RespCapabilities) MarshalJSON() ([]byte, error) { + marshalableCopy := make(map[string]interface{}, len(rc.Custom)) + val := reflect.ValueOf(rc).Elem() + for _, field := range reflect.VisibleFields(val.Type()) { + jsonTag := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonTag != "-" && jsonTag != "" { + fieldVal := val.FieldByIndex(field.Index) + if !fieldVal.IsNil() { + marshalableCopy[jsonTag] = fieldVal.Interface() + } + } + } + if rc.Custom != nil { + for key, value := range rc.Custom { + marshalableCopy[key] = value + } + } + var buf bytes.Buffer + buf.WriteString(`{"capabilities":`) + err := json.NewEncoder(&buf).Encode(marshalableCopy) + if err != nil { + return nil, err + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +type CapBoolean struct { + Enabled bool `json:"enabled"` +} + +type CapBooleanTrue CapBoolean + +// IsEnabled returns true if the capability is either enabled explicitly or not specified (nil) +func (cb *CapBooleanTrue) IsEnabled() bool { + // Default to true when + return cb == nil || cb.Enabled +} + +type CapBooleanFalse CapBoolean + +// IsEnabled returns true if the capability is enabled explicitly. If it's not specified, this returns false. +func (cb *CapBooleanFalse) IsEnabled() bool { + return cb != nil && cb.Enabled +} + +type CapRoomVersionStability string + +const ( + CapRoomVersionStable CapRoomVersionStability = "stable" + CapRoomVersionUnstable CapRoomVersionStability = "unstable" +) + +type CapRoomVersions struct { + Default string `json:"default"` + Available map[string]CapRoomVersionStability `json:"available"` +} + +func (vers *CapRoomVersions) IsStable(version string) bool { + if vers == nil || vers.Available == nil { + val, err := strconv.Atoi(version) + return err == nil && val > 0 + } + return vers.Available[version] == CapRoomVersionStable +} + +func (vers *CapRoomVersions) IsAvailable(version string) bool { + if vers == nil || vers.Available == nil { + return false + } + _, available := vers.Available[version] + return available +} + +type CapUnstableAccountModeration struct { + Suspend bool `json:"suspend"` + Lock bool `json:"lock"` +} + +type RespPublicRooms struct { + Chunk []*PublicRoomInfo `json:"chunk"` + NextBatch string `json:"next_batch,omitempty"` + PrevBatch string `json:"prev_batch,omitempty"` + TotalRoomCountEstimate int `json:"total_room_count_estimate"` +} + +type PublicRoomInfo struct { + RoomID id.RoomID `json:"room_id"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + CanonicalAlias id.RoomAlias `json:"canonical_alias,omitempty"` + GuestCanJoin bool `json:"guest_can_join"` + JoinRule event.JoinRule `json:"join_rule,omitempty"` + Name string `json:"name,omitempty"` + NumJoinedMembers int `json:"num_joined_members"` + RoomType event.RoomType `json:"room_type"` + Topic string `json:"topic,omitempty"` + WorldReadable bool `json:"world_readable"` + + RoomVersion id.RoomVersion `json:"room_version,omitempty"` + Encryption id.Algorithm `json:"encryption,omitempty"` + AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` +} + +// RespHierarchy is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy +type RespHierarchy struct { + NextBatch string `json:"next_batch,omitempty"` + Rooms []*ChildRoomsChunk `json:"rooms"` +} + +type ChildRoomsChunk struct { + PublicRoomInfo + ChildrenState []*event.Event `json:"children_state"` +} + +type RespAppservicePing struct { + DurationMS int64 `json:"duration_ms"` +} + +type RespBeeperMergeRoom RespCreateRoom + +type RespBeeperSplitRoom struct { + RoomIDs map[string]id.RoomID `json:"room_ids"` +} + +type RespTimestampToEvent struct { + EventID id.EventID `json:"event_id"` + Timestamp jsontime.UnixMilli `json:"origin_server_ts"` +} + +type RespRoomKeysVersionCreate struct { + Version id.KeyBackupVersion `json:"version"` +} + +type RespRoomKeysVersion[A any] struct { + Algorithm id.KeyBackupAlgorithm `json:"algorithm"` + AuthData A `json:"auth_data"` + Count int `json:"count"` + ETag string `json:"etag"` + Version id.KeyBackupVersion `json:"version"` +} + +type RespRoomKeys[S any] struct { + Rooms map[id.RoomID]RespRoomKeyBackup[S] `json:"rooms"` +} + +type RespRoomKeyBackup[S any] struct { + Sessions map[id.SessionID]RespKeyBackupData[S] `json:"sessions"` +} + +type RespKeyBackupData[S any] struct { + FirstMessageIndex int `json:"first_message_index"` + ForwardedCount int `json:"forwarded_count"` + IsVerified bool `json:"is_verified"` + SessionData S `json:"session_data"` +} + +type RespRoomKeysUpdate struct { + Count int `json:"count"` + ETag string `json:"etag"` +} + +type RespOpenIDToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + MatrixServerName string `json:"matrix_server_name"` + TokenType string `json:"token_type"` // Always "Bearer" +} + +type RespGetRelations struct { + Chunk []*event.Event `json:"chunk"` + NextBatch string `json:"next_batch,omitempty"` + PrevBatch string `json:"prev_batch,omitempty"` + RecursionDepth int `json:"recursion_depth,omitempty"` +} + +// RespSuspended is the response body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type RespSuspended struct { + Suspended bool `json:"suspended"` +} + +// RespLocked is the response body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 +type RespLocked struct { + Locked bool `json:"locked"` +} + +type ConnectionInfo struct { + IP string `json:"ip,omitempty"` + LastSeen jsontime.UnixMilli `json:"last_seen,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +type SessionInfo struct { + Connections []ConnectionInfo `json:"connections,omitempty"` +} + +type DeviceInfo struct { + Sessions []SessionInfo `json:"sessions,omitempty"` +} + +// RespWhoIs is the response body for https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid +type RespWhoIs struct { + UserID id.UserID `json:"user_id,omitempty"` + Devices map[id.DeviceID]DeviceInfo `json:"devices,omitempty"` +} + +type RespSearchWrapper struct { + SearchCategories RespSearchCategoryWrapper `json:"search_categories"` +} + +type RespSearchCategoryWrapper struct { + RoomEvents *RespSearch `json:"room_events"` +} + +type RespSearch struct { + Count int `json:"count"` + Highlights []string `json:"highlights,omitempty"` + NextBatch string `json:"next_batch,omitempty"` + Groups map[string]map[string]SearchResultGroup `json:"groups,omitempty"` + Results []*SearchResult `json:"results,omitempty"` + State map[id.RoomID][]*event.Event `json:"state,omitempty"` +} + +type SearchResultGroup struct { + NextBatch string `json:"next_batch,omitempty"` + Order int `json:"order"` + Results []string `json:"results"` +} + +type SearchResult struct { + Rank float64 `json:"rank"` + Event *event.Event `json:"result"` + Context *RespContext `json:"context,omitempty"` +} diff --git a/mautrix-patched/responses_test.go b/mautrix-patched/responses_test.go new file mode 100644 index 00000000..c2f64123 --- /dev/null +++ b/mautrix-patched/responses_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/canonicaljson" +) + +const sampleData = `{ + "capabilities": { + "m.room_versions": { + "default": "9", + "available": { + "1": "stable", + "2": "stable", + "3": "stable", + "4": "stable", + "5": "stable", + "6": "stable", + "org.matrix.msc2176": "unstable", + "7": "stable", + "8": "stable", + "9": "stable", + "org.matrix.msc2716v3": "unstable", + "org.matrix.msc3787": "unstable", + "10": "stable" + } + }, + "m.change_password": { + "enabled": true + }, + "m.set_displayname": { + "enabled": true + }, + "m.3pid_changes": { + "enabled": false + }, + "fi.mau.custom_field": { + "🐈️": true + } + } +}` + +var sampleObject = mautrix.RespCapabilities{ + RoomVersions: &mautrix.CapRoomVersions{ + Default: "9", + Available: map[string]mautrix.CapRoomVersionStability{ + "1": "stable", + "2": "stable", + "3": "stable", + "4": "stable", + "5": "stable", + "6": "stable", + "org.matrix.msc2176": "unstable", + "7": "stable", + "8": "stable", + "9": "stable", + "org.matrix.msc2716v3": "unstable", + "org.matrix.msc3787": "unstable", + "10": "stable", + }, + }, + ChangePassword: &mautrix.CapBooleanTrue{Enabled: true}, + SetDisplayname: &mautrix.CapBooleanTrue{Enabled: true}, + ThreePIDChanges: &mautrix.CapBooleanTrue{Enabled: false}, + Custom: map[string]interface{}{ + "fi.mau.custom_field": map[string]interface{}{ + "🐈️": true, + }, + }, +} + +func TestRespCapabilities_UnmarshalJSON(t *testing.T) { + var caps mautrix.RespCapabilities + err := json.Unmarshal([]byte(sampleData), &caps) + require.NoError(t, err) + + require.NotNil(t, caps.RoomVersions) + assert.Equal(t, "9", caps.RoomVersions.Default) + assert.True(t, caps.RoomVersions.IsStable("10")) + + // Omitted capabilities still support IsEnabled(), and this one defaults to true + assert.Nil(t, caps.SetAvatarURL) + assert.True(t, caps.SetAvatarURL.IsEnabled()) + + assert.True(t, caps.SetDisplayname.IsEnabled()) + assert.False(t, caps.ThreePIDChanges.IsEnabled()) + + assert.Contains(t, caps.Custom, "fi.mau.custom_field") + assert.NotContains(t, caps.Custom, "m.room_versions") +} + +func TestRespCapabilities_MarshalJSON(t *testing.T) { + marshaled, err := canonicaljson.Marshal(&sampleObject) + require.NoError(t, err) + origData := json.RawMessage(sampleData) + require.NoError(t, canonicaljson.Canonicalize(&origData)) + assert.Equal(t, string(marshaled), string(origData)) + assert.Len(t, sampleObject.Custom, 1) +} diff --git a/mautrix-patched/room.go b/mautrix-patched/room.go new file mode 100644 index 00000000..4292bff5 --- /dev/null +++ b/mautrix-patched/room.go @@ -0,0 +1,52 @@ +package mautrix + +import ( + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Room represents a single Matrix room. +type Room struct { + ID id.RoomID + State RoomStateMap +} + +// UpdateState updates the room's current state with the given Event. This will clobber events based +// on the type/state_key combination. +func (room Room) UpdateState(evt *event.Event) { + _, exists := room.State[evt.Type] + if !exists { + room.State[evt.Type] = make(map[string]*event.Event) + } + room.State[evt.Type][*evt.StateKey] = evt +} + +// GetStateEvent returns the state event for the given type/state_key combo, or nil. +func (room Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event { + stateEventMap := room.State[eventType] + evt := stateEventMap[stateKey] + return evt +} + +// GetMembershipState returns the membership state of the given user ID in this room. If there is +// no entry for this member, 'leave' is returned for consistency with left users. +func (room Room) GetMembershipState(userID id.UserID) event.Membership { + state := event.MembershipLeave + evt := room.GetStateEvent(event.StateMember, string(userID)) + if evt != nil { + membership, ok := evt.Content.Raw["membership"].(string) + if ok { + state = event.Membership(membership) + } + } + return state +} + +// NewRoom creates a new Room with the given ID +func NewRoom(roomID id.RoomID) *Room { + // Init the State map and return a pointer to the Room + return &Room{ + ID: roomID, + State: make(RoomStateMap), + } +} diff --git a/mautrix-patched/sqlstatestore/statestore.go b/mautrix-patched/sqlstatestore/statestore.go new file mode 100644 index 00000000..11957dfa --- /dev/null +++ b/mautrix-patched/sqlstatestore/statestore.go @@ -0,0 +1,495 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package sqlstatestore + +import ( + "context" + "database/sql" + "embed" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/rs/zerolog" + "go.mau.fi/util/confusable" + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exslices" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +//go:embed *.sql +var rawUpgrades embed.FS + +var UpgradeTable dbutil.UpgradeTable + +func init() { + UpgradeTable.RegisterFS(rawUpgrades) +} + +const VersionTableName = "mx_version" + +type SQLStateStore struct { + *dbutil.Database + IsBridge bool + + DisableNameDisambiguation bool +} + +func NewSQLStateStore(db *dbutil.Database, log dbutil.DatabaseLogger, isBridge bool) *SQLStateStore { + return &SQLStateStore{ + Database: db.Child(VersionTableName, UpgradeTable, log), + IsBridge: isBridge, + } +} + +func (store *SQLStateStore) IsRegistered(ctx context.Context, userID id.UserID) (bool, error) { + var isRegistered bool + err := store. + QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM mx_registrations WHERE user_id=$1)", userID). + Scan(&isRegistered) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return isRegistered, err +} + +func (store *SQLStateStore) MarkRegistered(ctx context.Context, userID id.UserID) error { + if userID == "" { + return fmt.Errorf("user ID is empty") + } + _, err := store.Exec(ctx, "INSERT INTO mx_registrations (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING", userID) + return err +} + +type Member struct { + id.UserID + event.MemberEventContent + NameSkeleton [32]byte +} + +func (store *SQLStateStore) GetRoomMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) (map[id.UserID]*event.MemberEventContent, error) { + args := make([]any, len(memberships)+1) + args[0] = roomID + query := "SELECT user_id, membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1" + if len(memberships) > 0 { + placeholders := make([]string, len(memberships)) + for i, membership := range memberships { + args[i+1] = string(membership) + placeholders[i] = fmt.Sprintf("$%d", i+2) + } + query = fmt.Sprintf("%s AND membership IN (%s)", query, strings.Join(placeholders, ",")) + } + rows, err := store.Query(ctx, query, args...) + members := make(map[id.UserID]*event.MemberEventContent) + return members, dbutil.NewRowIterWithError(rows, func(row dbutil.Scannable) (ret Member, err error) { + err = row.Scan(&ret.UserID, &ret.Membership, &ret.Displayname, &ret.AvatarURL) + return + }, err).Iter(func(m Member) (bool, error) { + members[m.UserID] = &m.MemberEventContent + return true, nil + }) +} + +func (store *SQLStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) (members []id.UserID, err error) { + var memberMap map[id.UserID]*event.MemberEventContent + memberMap, err = store.GetRoomMembers(ctx, roomID, event.MembershipJoin, event.MembershipInvite) + if err != nil { + return + } + members = make([]id.UserID, len(memberMap)) + i := 0 + for userID := range memberMap { + members[i] = userID + i++ + } + return +} + +func (store *SQLStateStore) GetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID) (membership event.Membership, err error) { + err = store. + QueryRow(ctx, "SELECT membership FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID). + Scan(&membership) + if errors.Is(err, sql.ErrNoRows) { + membership = event.MembershipLeave + err = nil + } + return +} + +func (store *SQLStateStore) GetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { + member, err := store.TryGetMember(ctx, roomID, userID) + if member == nil && err == nil { + member = &event.MemberEventContent{Membership: event.MembershipLeave} + } + return member, err +} + +func (store *SQLStateStore) TryGetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { + var member event.MemberEventContent + err := store. + QueryRow(ctx, "SELECT membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID). + Scan(&member.Membership, &member.Displayname, &member.AvatarURL) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + return &member, nil +} + +func (store *SQLStateStore) FindSharedRooms(ctx context.Context, userID id.UserID) ([]id.RoomID, error) { + query := ` + SELECT room_id FROM mx_user_profile + LEFT JOIN portal ON portal.mxid=mx_user_profile.room_id + WHERE mx_user_profile.user_id=$1 AND portal.encrypted=true + ` + if !store.IsBridge { + query = ` + SELECT mx_user_profile.room_id FROM mx_user_profile + LEFT JOIN mx_room_state ON mx_room_state.room_id=mx_user_profile.room_id + WHERE mx_user_profile.user_id=$1 AND mx_room_state.encryption IS NOT NULL + ` + } + rows, err := store.Query(ctx, query, userID) + return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.RoomID], err).AsList() +} + +func (store *SQLStateStore) IsInRoom(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { + return store.IsMembership(ctx, roomID, userID, "join") +} + +func (store *SQLStateStore) IsInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { + return store.IsMembership(ctx, roomID, userID, "join", "invite") +} + +func (store *SQLStateStore) IsMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool { + membership, err := store.GetMembership(ctx, roomID, userID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get membership") + return false + } + for _, allowedMembership := range allowedMemberships { + if allowedMembership == membership { + return true + } + } + return false +} + +func (store *SQLStateStore) SetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } else if userID == "" { + return fmt.Errorf("user ID is empty") + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, '', '') + ON CONFLICT (room_id, user_id) DO UPDATE SET membership=excluded.membership + `, roomID, userID, membership) + return err +} + +const insertUserProfileQuery = ` + INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url, name_skeleton) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (room_id, user_id) DO UPDATE + SET membership=excluded.membership, + displayname=excluded.displayname, + avatar_url=excluded.avatar_url, + name_skeleton=excluded.name_skeleton +` + +type userProfileRow struct { + UserID id.UserID + Membership event.Membership + Displayname string + AvatarURL id.ContentURIString + NameSkeleton []byte +} + +func (u *userProfileRow) GetMassInsertValues() [5]any { + return [5]any{u.UserID, u.Membership, u.Displayname, u.AvatarURL, u.NameSkeleton} +} + +var userProfileMassInserter = dbutil.NewMassInsertBuilder[*userProfileRow, [1]any](insertUserProfileQuery, "($1, $%d, $%d, $%d, $%d, $%d)") + +func (store *SQLStateStore) SetMember(ctx context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } else if userID == "" { + return fmt.Errorf("user ID is empty") + } + var nameSkeleton []byte + if !store.DisableNameDisambiguation && len(member.Displayname) > 0 { + nameSkeletonArr := confusable.SkeletonHash(member.Displayname) + nameSkeleton = nameSkeletonArr[:] + } + _, err := store.Exec(ctx, insertUserProfileQuery, roomID, userID, member.Membership, member.Displayname, member.AvatarURL, nameSkeleton) + return err +} + +func (store *SQLStateStore) IsConfusableName(ctx context.Context, roomID id.RoomID, currentUser id.UserID, name string) ([]id.UserID, error) { + if store.DisableNameDisambiguation { + return nil, nil + } + skeleton := confusable.SkeletonHash(name) + rows, err := store.Query(ctx, "SELECT user_id FROM mx_user_profile WHERE room_id=$1 AND name_skeleton=$2 AND user_id<>$3", roomID, skeleton[:], currentUser) + return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() +} + +const userProfileMassInsertBatchSize = 500 + +func (store *SQLStateStore) ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } + return store.DoTxn(ctx, nil, func(ctx context.Context) error { + err := store.ClearCachedMembers(ctx, roomID, onlyMemberships...) + if err != nil { + return fmt.Errorf("failed to clear cached members: %w", err) + } + rows := make([]*userProfileRow, min(len(evts), userProfileMassInsertBatchSize)) + for _, evtsChunk := range exslices.Chunk(evts, userProfileMassInsertBatchSize) { + rows = rows[:0] + for _, evt := range evtsChunk { + content, ok := evt.Content.Parsed.(*event.MemberEventContent) + if !ok { + continue + } + row := &userProfileRow{ + UserID: id.UserID(*evt.StateKey), + Membership: content.Membership, + Displayname: content.Displayname, + AvatarURL: content.AvatarURL, + } + if !store.DisableNameDisambiguation && len(content.Displayname) > 0 { + nameSkeletonArr := confusable.SkeletonHash(content.Displayname) + row.NameSkeleton = nameSkeletonArr[:] + } + rows = append(rows, row) + } + query, args := userProfileMassInserter.Build([1]any{roomID}, rows) + _, err = store.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to insert members: %w", err) + } + } + if len(onlyMemberships) == 0 { + err = store.MarkMembersFetched(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to mark members as fetched: %w", err) + } + } + return nil + }) +} + +func (store *SQLStateStore) ClearCachedMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) error { + query := "DELETE FROM mx_user_profile WHERE room_id=$1" + params := make([]any, len(memberships)+1) + params[0] = roomID + if len(memberships) > 0 { + placeholders := make([]string, len(memberships)) + for i, membership := range memberships { + placeholders[i] = "$" + strconv.Itoa(i+2) + params[i+1] = string(membership) + } + query += fmt.Sprintf(" AND membership IN (%s)", strings.Join(placeholders, ",")) + } + _, err := store.Exec(ctx, query, params...) + if err != nil { + return err + } + _, err = store.Exec(ctx, "UPDATE mx_room_state SET members_fetched=false WHERE room_id=$1", roomID) + return err +} + +func (store *SQLStateStore) HasFetchedMembers(ctx context.Context, roomID id.RoomID) (fetched bool, err error) { + err = store.QueryRow(ctx, "SELECT COALESCE(members_fetched, false) FROM mx_room_state WHERE room_id=$1", roomID).Scan(&fetched) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return +} + +func (store *SQLStateStore) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, members_fetched) VALUES ($1, true) + ON CONFLICT (room_id) DO UPDATE SET members_fetched=true + `, roomID) + return err +} + +type userAndMembership struct { + UserID id.UserID + event.MemberEventContent +} + +func (store *SQLStateStore) GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + rows, err := store.Query(ctx, "SELECT user_id, membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1", roomID) + if err != nil { + return nil, err + } + output := make(map[id.UserID]*event.MemberEventContent) + err = dbutil.NewRowIterWithError(rows, func(row dbutil.Scannable) (res userAndMembership, err error) { + err = row.Scan(&res.UserID, &res.Membership, &res.Displayname, &res.AvatarURL) + return + }, err).Iter(func(member userAndMembership) (bool, error) { + output[member.UserID] = &member.MemberEventContent + return true, nil + }) + return output, err +} + +func (store *SQLStateStore) SetEncryptionEvent(ctx context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } + contentBytes, err := json.Marshal(content) + if err != nil { + return fmt.Errorf("failed to marshal content JSON: %w", err) + } + _, err = store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, encryption) VALUES ($1, $2) + ON CONFLICT (room_id) DO UPDATE SET encryption=excluded.encryption + `, roomID, contentBytes) + return err +} + +func (store *SQLStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { + var data []byte + err := store. + QueryRow(ctx, "SELECT encryption FROM mx_room_state WHERE room_id=$1 AND encryption IS NOT NULL", roomID). + Scan(&data) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } else if data == nil { + return nil, nil + } + var content event.EncryptionEventContent + err = json.Unmarshal(data, &content) + if err != nil { + return nil, fmt.Errorf("failed to parse content JSON: %w", err) + } + return &content, nil +} + +func (store *SQLStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { + cfg, err := store.GetEncryptionEvent(ctx, roomID) + return cfg != nil && cfg.Algorithm == id.AlgorithmMegolmV1, err +} + +func (store *SQLStateStore) SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, power_levels) VALUES ($1, $2) + ON CONFLICT (room_id) DO UPDATE SET power_levels=excluded.power_levels + `, roomID, dbutil.JSON{Data: levels}) + return err +} + +func (store *SQLStateStore) GetPowerLevels(ctx context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { + levels = &event.PowerLevelsEventContent{} + err = store. + QueryRow(ctx, "SELECT power_levels, create_event FROM mx_room_state WHERE room_id=$1 AND power_levels IS NOT NULL", roomID). + Scan(&dbutil.JSON{Data: &levels}, &dbutil.JSON{Data: &levels.CreateEvent}) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + if levels.CreateEvent != nil { + err = levels.CreateEvent.Content.ParseRaw(event.StateCreate) + } + return +} + +func (store *SQLStateStore) GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) { + levels, err := store.GetPowerLevels(ctx, roomID) + if err != nil { + return 0, err + } + return levels.GetUserLevel(userID), nil +} + +func (store *SQLStateStore) GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) { + levels, err := store.GetPowerLevels(ctx, roomID) + if err != nil { + return 0, err + } + return levels.GetEventLevel(eventType), nil +} + +func (store *SQLStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) { + levels, err := store.GetPowerLevels(ctx, roomID) + if err != nil { + return false, err + } + return levels.GetUserLevel(userID) >= levels.GetEventLevel(eventType), nil +} + +func (store *SQLStateStore) SetCreate(ctx context.Context, evt *event.Event) error { + if evt.Type != event.StateCreate { + return fmt.Errorf("invalid event type for create event: %s", evt.Type) + } else if evt.RoomID == "" { + return fmt.Errorf("room ID is empty") + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, create_event) VALUES ($1, $2) + ON CONFLICT (room_id) DO UPDATE SET create_event=excluded.create_event + `, evt.RoomID, dbutil.JSON{Data: evt}) + return err +} + +func (store *SQLStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (evt *event.Event, err error) { + err = store. + QueryRow(ctx, "SELECT create_event FROM mx_room_state WHERE room_id=$1 AND create_event IS NOT NULL", roomID). + Scan(&dbutil.JSON{Data: &evt}) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + if evt != nil { + err = evt.Content.ParseRaw(event.StateCreate) + } + return +} + +func (store *SQLStateStore) SetJoinRules(ctx context.Context, roomID id.RoomID, rules *event.JoinRulesEventContent) error { + if roomID == "" { + return fmt.Errorf("room ID is empty") + } + _, err := store.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, join_rules) VALUES ($1, $2) + ON CONFLICT (room_id) DO UPDATE SET join_rules=excluded.join_rules + `, roomID, dbutil.JSON{Data: rules}) + return err +} + +func (store *SQLStateStore) GetJoinRules(ctx context.Context, roomID id.RoomID) (levels *event.JoinRulesEventContent, err error) { + levels = &event.JoinRulesEventContent{} + err = store. + QueryRow(ctx, "SELECT join_rules FROM mx_room_state WHERE room_id=$1 AND join_rules IS NOT NULL", roomID). + Scan(&dbutil.JSON{Data: &levels}) + if errors.Is(err, sql.ErrNoRows) { + levels = nil + err = nil + } + return +} diff --git a/mautrix-patched/sqlstatestore/v00-latest-revision.sql b/mautrix-patched/sqlstatestore/v00-latest-revision.sql new file mode 100644 index 00000000..4679f1c6 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v00-latest-revision.sql @@ -0,0 +1,32 @@ +-- v0 -> v10 (compatible with v3+): Latest revision + +CREATE TABLE mx_registrations ( + user_id TEXT PRIMARY KEY +); + +-- only: postgres +CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock'); + +CREATE TABLE mx_user_profile ( + room_id TEXT, + user_id TEXT, + membership membership NOT NULL, + displayname TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + + name_skeleton bytea, + + PRIMARY KEY (room_id, user_id) +); + +CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership); +CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton); + +CREATE TABLE mx_room_state ( + room_id TEXT PRIMARY KEY, + power_levels jsonb, + encryption jsonb, + create_event jsonb, + join_rules jsonb, + members_fetched BOOLEAN NOT NULL DEFAULT false +); diff --git a/mautrix-patched/sqlstatestore/v02-membership-enum.sql b/mautrix-patched/sqlstatestore/v02-membership-enum.sql new file mode 100644 index 00000000..cee3e693 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v02-membership-enum.sql @@ -0,0 +1,6 @@ +-- v2: Use enum for membership field on Postgres +-- only: postgres + +CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock'); +UPDATE mx_user_profile SET membership='leave' WHERE LOWER(membership) NOT IN ('join', 'leave', 'invite', 'ban', 'knock'); +ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership USING LOWER(membership)::membership; diff --git a/mautrix-patched/sqlstatestore/v03-no-null.sql b/mautrix-patched/sqlstatestore/v03-no-null.sql new file mode 100644 index 00000000..60600099 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v03-no-null.sql @@ -0,0 +1,10 @@ +-- v3: Disable nulls in mx_user_profile + +UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL; +UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL; + +-- only: postgres for next 4 lines +ALTER TABLE mx_user_profile ALTER COLUMN displayname SET DEFAULT ''; +ALTER TABLE mx_user_profile ALTER COLUMN displayname SET NOT NULL; +ALTER TABLE mx_user_profile ALTER COLUMN avatar_url SET DEFAULT ''; +ALTER TABLE mx_user_profile ALTER COLUMN avatar_url SET NOT NULL; diff --git a/mautrix-patched/sqlstatestore/v04-encryption-info.sql b/mautrix-patched/sqlstatestore/v04-encryption-info.sql new file mode 100644 index 00000000..dc46bf3e --- /dev/null +++ b/mautrix-patched/sqlstatestore/v04-encryption-info.sql @@ -0,0 +1,2 @@ +-- v4: Store room encryption configuration +ALTER TABLE mx_room_state ADD COLUMN encryption jsonb; diff --git a/mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go b/mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go new file mode 100644 index 00000000..b7f2b1c2 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go @@ -0,0 +1,30 @@ +package sqlstatestore + +import ( + "context" + "fmt" + + "go.mau.fi/util/dbutil" +) + +func init() { + UpgradeTable.Register(-1, 5, 0, "Mark rooms that need crypto state event resynced", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) error { + portalExists, err := db.TableExists(ctx, "portal") + if err != nil { + return fmt.Errorf("failed to check if portal table exists") + } + if portalExists { + _, err = db.Exec(ctx, ` + INSERT INTO mx_room_state (room_id, encryption) + SELECT portal.mxid, '{"resync":true}' FROM portal WHERE portal.encrypted=true AND portal.mxid IS NOT NULL + ON CONFLICT (room_id) DO UPDATE + SET encryption=excluded.encryption + WHERE mx_room_state.encryption IS NULL + `) + if err != nil { + return err + } + } + return nil + }) +} diff --git a/mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go b/mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go new file mode 100644 index 00000000..d0d1d502 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go @@ -0,0 +1,55 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package sqlstatestore + +import ( + "context" + + "go.mau.fi/util/confusable" + "go.mau.fi/util/dbutil" + + "maunium.net/go/mautrix/id" +) + +type roomUserName struct { + RoomID id.RoomID + UserID id.UserID + Name string +} + +func init() { + UpgradeTable.Register(-1, 6, 3, "Add disambiguation column for user profiles", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) error { + _, err := db.Exec(ctx, ` + ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea; + CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership); + CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton); + `) + if err != nil { + return err + } + const ChunkSize = 1000 + const GetEntriesChunkQuery = "SELECT room_id, user_id, displayname FROM mx_user_profile WHERE displayname<>'' LIMIT $1 OFFSET $2" + const SetSkeletonHashQuery = `UPDATE mx_user_profile SET name_skeleton = $3 WHERE room_id = $1 AND user_id = $2` + for offset := 0; ; offset += ChunkSize { + entries, err := dbutil.NewSimpleReflectRowIter[roomUserName](db.Query(ctx, GetEntriesChunkQuery, ChunkSize, offset)).AsList() + if err != nil { + return err + } + for _, entry := range entries { + skel := confusable.SkeletonHash(entry.Name) + _, err = db.Exec(ctx, SetSkeletonHashQuery, entry.RoomID, entry.UserID, skel[:]) + if err != nil { + return err + } + } + if len(entries) < ChunkSize { + break + } + } + return nil + }) +} diff --git a/mautrix-patched/sqlstatestore/v07-full-member-flag.sql b/mautrix-patched/sqlstatestore/v07-full-member-flag.sql new file mode 100644 index 00000000..32f2ef6c --- /dev/null +++ b/mautrix-patched/sqlstatestore/v07-full-member-flag.sql @@ -0,0 +1,2 @@ +-- v7 (compatible with v3+): Add flag for whether the full member list has been fetched +ALTER TABLE mx_room_state ADD COLUMN members_fetched BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/sqlstatestore/v08-create-event.sql b/mautrix-patched/sqlstatestore/v08-create-event.sql new file mode 100644 index 00000000..9f1b55c9 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v08-create-event.sql @@ -0,0 +1,2 @@ +-- v8 (compatible with v3+): Add create event to room state table +ALTER TABLE mx_room_state ADD COLUMN create_event jsonb; diff --git a/mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql b/mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql new file mode 100644 index 00000000..ca951068 --- /dev/null +++ b/mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql @@ -0,0 +1,3 @@ +-- v9 (compatible with v3+): Clear invalid rows +DELETE FROM mx_room_state WHERE room_id=''; +DELETE FROM mx_user_profile WHERE room_id='' OR user_id=''; diff --git a/mautrix-patched/sqlstatestore/v10-join-rules.sql b/mautrix-patched/sqlstatestore/v10-join-rules.sql new file mode 100644 index 00000000..3074c46a --- /dev/null +++ b/mautrix-patched/sqlstatestore/v10-join-rules.sql @@ -0,0 +1,2 @@ +-- v10 (compatible with v3+): Add join rules to room state table +ALTER TABLE mx_room_state ADD COLUMN join_rules jsonb; diff --git a/mautrix-patched/statestore.go b/mautrix-patched/statestore.go new file mode 100644 index 00000000..2c3fa9f8 --- /dev/null +++ b/mautrix-patched/statestore.go @@ -0,0 +1,392 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix + +import ( + "context" + "maps" + "sync" + + "github.com/rs/zerolog" + "go.mau.fi/util/exerrors" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// StateStore is an interface for storing basic room state information. +type StateStore interface { + IsInRoom(ctx context.Context, roomID id.RoomID, userID id.UserID) bool + IsInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) bool + IsMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool + GetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) + TryGetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) + SetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error + SetMember(ctx context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error + IsConfusableName(ctx context.Context, roomID id.RoomID, currentUser id.UserID, name string) ([]id.UserID, error) + ClearCachedMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) error + ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error + + SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error + GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) + + SetCreate(ctx context.Context, evt *event.Event) error + GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) + + GetJoinRules(ctx context.Context, roomID id.RoomID) (*event.JoinRulesEventContent, error) + SetJoinRules(ctx context.Context, roomID id.RoomID, content *event.JoinRulesEventContent) error + + HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error) + MarkMembersFetched(ctx context.Context, roomID id.RoomID) error + GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) + + SetEncryptionEvent(ctx context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error + IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) + + GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) +} + +type StateStoreUpdater interface { + UpdateState(ctx context.Context, evt *event.Event) +} + +func UpdateStateStore(ctx context.Context, store StateStore, evt *event.Event) { + if store == nil || evt == nil || evt.StateKey == nil { + return + } + if directUpdater, ok := store.(StateStoreUpdater); ok { + directUpdater.UpdateState(ctx, evt) + return + } + // We only care about events without a state key (power levels, encryption) or member events with state key + if evt.Type != event.StateMember && evt.GetStateKey() != "" { + return + } + var err error + switch content := evt.Content.Parsed.(type) { + case *event.MemberEventContent: + err = store.SetMember(ctx, evt.RoomID, id.UserID(evt.GetStateKey()), content) + case *event.PowerLevelsEventContent: + err = store.SetPowerLevels(ctx, evt.RoomID, content) + case *event.EncryptionEventContent: + err = store.SetEncryptionEvent(ctx, evt.RoomID, content) + case *event.CreateEventContent: + err = store.SetCreate(ctx, evt) + case *event.JoinRulesEventContent: + err = store.SetJoinRules(ctx, evt.RoomID, content) + default: + switch evt.Type { + case event.StateMember, event.StatePowerLevels, event.StateEncryption, event.StateCreate, event.StateJoinRules: + zerolog.Ctx(ctx).Warn(). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.Type). + Type("content_type", evt.Content.Parsed). + Msg("Got known event type with unknown content type in UpdateStateStore") + } + } + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.Type). + Msg("Failed to update state store") + } +} + +// StateStoreSyncHandler can be added as an event handler in the syncer to update the state store automatically. +// +// client.Syncer.(mautrix.ExtensibleSyncer).OnEvent(client.StateStoreSyncHandler) +// +// DefaultSyncer.ParseEventContent must also be true for this to work (which it is by default). +func (cli *Client) StateStoreSyncHandler(ctx context.Context, evt *event.Event) { + UpdateStateStore(ctx, cli.StateStore, evt) +} + +type MemoryStateStore struct { + Registrations map[id.UserID]bool `json:"registrations"` + Members map[id.RoomID]map[id.UserID]*event.MemberEventContent `json:"memberships"` + MembersFetched map[id.RoomID]bool `json:"members_fetched"` + PowerLevels map[id.RoomID]*event.PowerLevelsEventContent `json:"power_levels"` + Encryption map[id.RoomID]*event.EncryptionEventContent `json:"encryption"` + Create map[id.RoomID]*event.Event `json:"create"` + JoinRules map[id.RoomID]*event.JoinRulesEventContent `json:"join_rules"` + + registrationsLock sync.RWMutex + membersLock sync.RWMutex + powerLevelsLock sync.RWMutex + encryptionLock sync.RWMutex + joinRulesLock sync.RWMutex +} + +func NewMemoryStateStore() StateStore { + return &MemoryStateStore{ + Registrations: make(map[id.UserID]bool), + Members: make(map[id.RoomID]map[id.UserID]*event.MemberEventContent), + MembersFetched: make(map[id.RoomID]bool), + PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent), + Encryption: make(map[id.RoomID]*event.EncryptionEventContent), + Create: make(map[id.RoomID]*event.Event), + JoinRules: make(map[id.RoomID]*event.JoinRulesEventContent), + } +} + +func (store *MemoryStateStore) IsRegistered(_ context.Context, userID id.UserID) (bool, error) { + store.registrationsLock.RLock() + defer store.registrationsLock.RUnlock() + registered, ok := store.Registrations[userID] + return ok && registered, nil +} + +func (store *MemoryStateStore) MarkRegistered(_ context.Context, userID id.UserID) error { + store.registrationsLock.Lock() + defer store.registrationsLock.Unlock() + store.Registrations[userID] = true + return nil +} + +func (store *MemoryStateStore) GetRoomMembers(_ context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + store.membersLock.RLock() + members, ok := store.Members[roomID] + store.membersLock.RUnlock() + if !ok { + members = make(map[id.UserID]*event.MemberEventContent) + store.membersLock.Lock() + store.Members[roomID] = members + store.membersLock.Unlock() + } + return members, nil +} + +func (store *MemoryStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) { + members, err := store.GetRoomMembers(ctx, roomID) + if err != nil { + return nil, err + } + ids := make([]id.UserID, 0, len(members)) + for id := range members { + ids = append(ids, id) + } + return ids, nil +} + +func (store *MemoryStateStore) GetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID) (event.Membership, error) { + return exerrors.Must(store.GetMember(ctx, roomID, userID)).Membership, nil +} + +func (store *MemoryStateStore) GetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { + member, err := store.TryGetMember(ctx, roomID, userID) + if member == nil && err == nil { + member = &event.MemberEventContent{Membership: event.MembershipLeave} + } + return member, err +} + +func (store *MemoryStateStore) IsConfusableName(ctx context.Context, roomID id.RoomID, currentUser id.UserID, name string) ([]id.UserID, error) { + // TODO implement? + return nil, nil +} + +func (store *MemoryStateStore) TryGetMember(_ context.Context, roomID id.RoomID, userID id.UserID) (member *event.MemberEventContent, err error) { + store.membersLock.RLock() + defer store.membersLock.RUnlock() + members, membersOk := store.Members[roomID] + if !membersOk { + return + } + member = members[userID] + return +} + +func (store *MemoryStateStore) IsInRoom(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { + return store.IsMembership(ctx, roomID, userID, "join") +} + +func (store *MemoryStateStore) IsInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { + return store.IsMembership(ctx, roomID, userID, "join", "invite") +} + +func (store *MemoryStateStore) IsMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool { + membership := exerrors.Must(store.GetMembership(ctx, roomID, userID)) + for _, allowedMembership := range allowedMemberships { + if allowedMembership == membership { + return true + } + } + return false +} + +func (store *MemoryStateStore) SetMembership(_ context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error { + store.membersLock.Lock() + members, ok := store.Members[roomID] + if !ok { + members = map[id.UserID]*event.MemberEventContent{ + userID: {Membership: membership}, + } + } else { + member, ok := members[userID] + if !ok { + members[userID] = &event.MemberEventContent{Membership: membership} + } else { + member.Membership = membership + members[userID] = member + } + } + store.Members[roomID] = members + store.membersLock.Unlock() + return nil +} + +func (store *MemoryStateStore) SetMember(_ context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error { + store.membersLock.Lock() + members, ok := store.Members[roomID] + if !ok { + members = map[id.UserID]*event.MemberEventContent{ + userID: member, + } + } else { + members[userID] = member + } + store.Members[roomID] = members + store.membersLock.Unlock() + return nil +} + +func (store *MemoryStateStore) ClearCachedMembers(_ context.Context, roomID id.RoomID, memberships ...event.Membership) error { + store.membersLock.Lock() + defer store.membersLock.Unlock() + members, ok := store.Members[roomID] + if !ok { + return nil + } + for userID, member := range members { + for _, membership := range memberships { + if membership == member.Membership { + delete(members, userID) + break + } + } + } + store.MembersFetched[roomID] = false + return nil +} + +func (store *MemoryStateStore) HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error) { + store.membersLock.RLock() + defer store.membersLock.RUnlock() + return store.MembersFetched[roomID], nil +} + +func (store *MemoryStateStore) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error { + store.membersLock.Lock() + defer store.membersLock.Unlock() + store.MembersFetched[roomID] = true + return nil +} + +func (store *MemoryStateStore) ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error { + _ = store.ClearCachedMembers(ctx, roomID, onlyMemberships...) + for _, evt := range evts { + UpdateStateStore(ctx, store, evt) + } + if len(onlyMemberships) == 0 { + _ = store.MarkMembersFetched(ctx, roomID) + } + return nil +} + +func (store *MemoryStateStore) GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + store.membersLock.RLock() + defer store.membersLock.RUnlock() + return maps.Clone(store.Members[roomID]), nil +} + +func (store *MemoryStateStore) SetPowerLevels(_ context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error { + store.powerLevelsLock.Lock() + store.PowerLevels[roomID] = levels + store.powerLevelsLock.Unlock() + return nil +} + +func (store *MemoryStateStore) GetPowerLevels(_ context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { + store.powerLevelsLock.RLock() + levels = store.PowerLevels[roomID] + if levels != nil && levels.CreateEvent == nil { + levels.CreateEvent = store.Create[roomID] + } + store.powerLevelsLock.RUnlock() + return +} + +func (store *MemoryStateStore) GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) { + return exerrors.Must(store.GetPowerLevels(ctx, roomID)).GetUserLevel(userID), nil +} + +func (store *MemoryStateStore) GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) { + return exerrors.Must(store.GetPowerLevels(ctx, roomID)).GetEventLevel(eventType), nil +} + +func (store *MemoryStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) { + return exerrors.Must(store.GetPowerLevel(ctx, roomID, userID)) >= exerrors.Must(store.GetPowerLevelRequirement(ctx, roomID, eventType)), nil +} + +func (store *MemoryStateStore) SetCreate(ctx context.Context, evt *event.Event) error { + store.powerLevelsLock.Lock() + store.Create[evt.RoomID] = evt + if pls, ok := store.PowerLevels[evt.RoomID]; ok && pls.CreateEvent == nil { + pls.CreateEvent = evt + } + store.powerLevelsLock.Unlock() + return nil +} + +func (store *MemoryStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) { + store.powerLevelsLock.RLock() + evt := store.Create[roomID] + store.powerLevelsLock.RUnlock() + return evt, nil +} + +func (store *MemoryStateStore) SetEncryptionEvent(_ context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error { + store.encryptionLock.Lock() + store.Encryption[roomID] = content + store.encryptionLock.Unlock() + return nil +} + +func (store *MemoryStateStore) GetEncryptionEvent(_ context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { + store.encryptionLock.RLock() + defer store.encryptionLock.RUnlock() + return store.Encryption[roomID], nil +} + +func (store *MemoryStateStore) SetJoinRules(ctx context.Context, roomID id.RoomID, content *event.JoinRulesEventContent) error { + store.joinRulesLock.Lock() + store.JoinRules[roomID] = content + store.joinRulesLock.Unlock() + return nil +} + +func (store *MemoryStateStore) GetJoinRules(ctx context.Context, roomID id.RoomID) (*event.JoinRulesEventContent, error) { + store.joinRulesLock.RLock() + defer store.joinRulesLock.RUnlock() + return store.JoinRules[roomID], nil +} + +func (store *MemoryStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { + cfg, err := store.GetEncryptionEvent(ctx, roomID) + return cfg != nil && cfg.Algorithm == id.AlgorithmMegolmV1, err +} + +func (store *MemoryStateStore) FindSharedRooms(ctx context.Context, userID id.UserID) (rooms []id.RoomID, err error) { + store.membersLock.RLock() + defer store.membersLock.RUnlock() + for roomID, members := range store.Members { + if _, ok := members[userID]; ok { + rooms = append(rooms, roomID) + } + } + return rooms, nil +} diff --git a/mautrix-patched/synapseadmin/client.go b/mautrix-patched/synapseadmin/client.go new file mode 100644 index 00000000..6925ca7d --- /dev/null +++ b/mautrix-patched/synapseadmin/client.go @@ -0,0 +1,22 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package synapseadmin + +import ( + "maunium.net/go/mautrix" +) + +// Client is a wrapper for the mautrix.Client struct that includes methods for accessing the Synapse admin API. +// +// https://matrix-org.github.io/synapse/latest/usage/administration/admin_api/index.html +type Client struct { + Client *mautrix.Client +} + +func (cli *Client) BuildAdminURL(path ...any) string { + return cli.Client.BuildURL(mautrix.SynapseAdminURLPath(path)) +} diff --git a/mautrix-patched/synapseadmin/register.go b/mautrix-patched/synapseadmin/register.go new file mode 100644 index 00000000..05e0729a --- /dev/null +++ b/mautrix-patched/synapseadmin/register.go @@ -0,0 +1,101 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package synapseadmin + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "fmt" + "net/http" + + "maunium.net/go/mautrix" +) + +type respGetRegisterNonce struct { + Nonce string `json:"nonce"` +} + +// ReqSharedSecretRegister is the request content for Client.SharedSecretRegister. +type ReqSharedSecretRegister struct { + // The username to register. Required. + Username string `json:"username"` + // The new password for the user. Required. + Password string `json:"password"` + + // Initial displayname for the user. By default, the server will use the username as the displayname. + Displayname string `json:"displayname,omitempty"` + // The type of user to register. Defaults to empty (normal user). + // Officially allowed values are "support" or "bot". + // + // Currently, the only effect is that synapse skips consent requirements for those two user types, + // other than that the user type does absolutely nothing. + UserType string `json:"user_type,omitempty"` + // Whether the created user should be a server admin. + Admin bool `json:"admin"` + // Disable generating a new device along with the registration. + // If true, the access_token and device_id fields in the response will be empty. + InhibitLogin bool `json:"inhibit_login,omitempty"` + + // A single-use nonce from GetRegisterNonce. This is automatically filled by Client.SharedSecretRegister if left empty. + Nonce string `json:"nonce"` + // The checksum for the request. This is automatically generated by Client.SharedSecretRegister using Sign. + SHA1Checksum string `json:"mac"` +} + +func (req *ReqSharedSecretRegister) Sign(secret string) string { + signer := hmac.New(sha1.New, []byte(secret)) + signer.Write([]byte(req.Nonce)) + signer.Write([]byte{0}) + signer.Write([]byte(req.Username)) + signer.Write([]byte{0}) + signer.Write([]byte(req.Password)) + signer.Write([]byte{0}) + if req.Admin { + signer.Write([]byte("admin")) + } else { + signer.Write([]byte("notadmin")) + } + if req.UserType != "" { + signer.Write([]byte{0}) + signer.Write([]byte(req.UserType)) + } + return hex.EncodeToString(signer.Sum(nil)) +} + +// GetRegisterNonce gets a nonce that can be used for SharedSecretRegister. +// +// This does not need to be called manually as SharedSecretRegister will automatically call this if no nonce is provided. +func (cli *Client) GetRegisterNonce(ctx context.Context) (string, error) { + var resp respGetRegisterNonce + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "register"), nil, &resp) + if err != nil { + return "", err + } + return resp.Nonce, nil +} + +// SharedSecretRegister creates a new account using a shared secret. +// +// https://matrix-org.github.io/synapse/latest/admin_api/register_api.html +func (cli *Client) SharedSecretRegister(ctx context.Context, sharedSecret string, req ReqSharedSecretRegister) (*mautrix.RespRegister, error) { + var err error + if req.Nonce == "" { + req.Nonce, err = cli.GetRegisterNonce(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get nonce: %w", err) + } + } + req.SHA1Checksum = req.Sign(sharedSecret) + var resp mautrix.RespRegister + _, err = cli.Client.MakeRequest(ctx, http.MethodPost, cli.BuildAdminURL("v1", "register"), &req, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/mautrix-patched/synapseadmin/roomapi.go b/mautrix-patched/synapseadmin/roomapi.go new file mode 100644 index 00000000..0925b748 --- /dev/null +++ b/mautrix-patched/synapseadmin/roomapi.go @@ -0,0 +1,250 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package synapseadmin + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type ReqListRoom struct { + SearchTerm string + OrderBy string + Direction mautrix.Direction + From int + Limit int +} + +func (req *ReqListRoom) BuildQuery() map[string]string { + query := map[string]string{ + "from": strconv.Itoa(req.From), + } + if req.SearchTerm != "" { + query["search_term"] = req.SearchTerm + } + if req.OrderBy != "" { + query["order_by"] = req.OrderBy + } + if req.Direction != 0 { + query["dir"] = string(req.Direction) + } + if req.Limit != 0 { + query["limit"] = strconv.Itoa(req.Limit) + } + return query +} + +type RoomInfo struct { + RoomID id.RoomID `json:"room_id"` + Name string `json:"name"` + CanonicalAlias id.RoomAlias `json:"canonical_alias"` + JoinedMembers int `json:"joined_members"` + JoinedLocalMembers int `json:"joined_local_members"` + Version string `json:"version"` + Creator id.UserID `json:"creator"` + Encryption id.Algorithm `json:"encryption"` + Federatable bool `json:"federatable"` + Public bool `json:"public"` + JoinRules event.JoinRule `json:"join_rules"` + GuestAccess event.GuestAccess `json:"guest_access"` + HistoryVisibility event.HistoryVisibility `json:"history_visibility"` + StateEvents int `json:"state_events"` + RoomType event.RoomType `json:"room_type"` +} + +type RespListRooms struct { + Rooms []RoomInfo `json:"rooms"` + Offset int `json:"offset"` + TotalRooms int `json:"total_rooms"` + NextBatch int `json:"next_batch"` + PrevBatch int `json:"prev_batch"` +} + +// ListRooms returns a list of rooms on the server. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#list-room-api +func (cli *Client) ListRooms(ctx context.Context, req ReqListRoom) (RespListRooms, error) { + var resp RespListRooms + reqURL := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms"}, req.BuildQuery()) + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return resp, err +} + +func (cli *Client) RoomInfo(ctx context.Context, roomID id.RoomID) (resp *RoomInfo, err error) { + reqURL := cli.BuildAdminURL("v1", "rooms", roomID) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return +} + +type RespRoomMessages = mautrix.RespMessages + +// RoomMessages returns a list of messages in a room. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#room-messages-api +func (cli *Client) RoomMessages(ctx context.Context, roomID id.RoomID, from, to string, dir mautrix.Direction, filter *mautrix.FilterPart, limit int) (resp *RespRoomMessages, err error) { + query := map[string]string{ + "from": from, + "dir": string(dir), + } + if filter != nil { + filterJSON, err := json.Marshal(filter) + if err != nil { + return nil, err + } + query["filter"] = string(filterJSON) + } + if to != "" { + query["to"] = to + } + if limit != 0 { + query["limit"] = strconv.Itoa(limit) + } + urlPath := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms", roomID, "messages"}, query) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) + return resp, err +} + +type ReqDeleteRoom struct { + Purge bool `json:"purge,omitempty"` + ForcePurge bool `json:"force_purge,omitempty"` + Block bool `json:"block,omitempty"` + Message string `json:"message,omitempty"` + RoomName string `json:"room_name,omitempty"` + NewRoomUserID id.UserID `json:"new_room_user_id,omitempty"` +} + +type RespDeleteRoom struct { + DeleteID string `json:"delete_id"` +} + +type RespDeleteRoomResult struct { + KickedUsers []id.UserID `json:"kicked_users,omitempty"` + FailedToKickUsers []id.UserID `json:"failed_to_kick_users,omitempty"` + LocalAliases []id.RoomAlias `json:"local_aliases,omitempty"` + NewRoomID id.RoomID `json:"new_room_id,omitempty"` +} + +type RespDeleteRoomStatus struct { + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + ShutdownRoom RespDeleteRoomResult `json:"shutdown_room,omitempty"` +} + +// DeleteRoom deletes a room from the server, optionally blocking it and/or purging all data from the database. +// +// This calls the async version of the endpoint, which will return immediately and delete the room in the background. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#version-2-new-version +func (cli *Client) DeleteRoom(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (RespDeleteRoom, error) { + reqURL := cli.BuildAdminURL("v2", "rooms", roomID) + var resp RespDeleteRoom + _, err := cli.Client.MakeRequest(ctx, http.MethodDelete, reqURL, &req, &resp) + return resp, err +} + +func (cli *Client) DeleteRoomStatus(ctx context.Context, deleteID string) (resp RespDeleteRoomStatus, err error) { + reqURL := cli.BuildAdminURL("v2", "rooms", "delete_status", deleteID) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return +} + +// DeleteRoomSync deletes a room from the server, optionally blocking it and/or purging all data from the database. +// +// This calls the synchronous version of the endpoint, which will block until the room is deleted. +// +// https://element-hq.github.io/synapse/latest/admin_api/rooms.html#version-1-old-version +func (cli *Client) DeleteRoomSync(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (resp RespDeleteRoomResult, err error) { + reqURL := cli.BuildAdminURL("v1", "rooms", roomID) + httpClient := &http.Client{} + _, err = cli.Client.MakeFullRequest(ctx, mautrix.FullRequest{ + Method: http.MethodDelete, + URL: reqURL, + RequestJSON: &req, + ResponseJSON: &resp, + MaxAttempts: 1, + // Use a fresh HTTP client without timeouts + Client: httpClient, + }) + httpClient.CloseIdleConnections() + return +} + +type RespRoomsMembers struct { + Members []id.UserID `json:"members"` + Total int `json:"total"` +} + +// RoomMembers gets the full list of members in a room. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#room-members-api +func (cli *Client) RoomMembers(ctx context.Context, roomID id.RoomID) (RespRoomsMembers, error) { + reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "members") + var resp RespRoomsMembers + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return resp, err +} + +type ReqMakeRoomAdmin struct { + UserID id.UserID `json:"user_id"` +} + +// MakeRoomAdmin promotes a user to admin in a room. This requires that a local user has permission to promote users in the room. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#make-room-admin-api +func (cli *Client) MakeRoomAdmin(ctx context.Context, roomIDOrAlias string, req ReqMakeRoomAdmin) error { + reqURL := cli.BuildAdminURL("v1", "rooms", roomIDOrAlias, "make_room_admin") + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + return err +} + +type ReqJoinUserToRoom struct { + UserID id.UserID `json:"user_id"` +} + +// JoinUserToRoom makes a local user join the given room. +// +// https://matrix-org.github.io/synapse/latest/admin_api/room_membership.html +func (cli *Client) JoinUserToRoom(ctx context.Context, roomID id.RoomID, req ReqJoinUserToRoom) error { + reqURL := cli.BuildAdminURL("v1", "join", roomID) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + return err +} + +type ReqBlockRoom struct { + Block bool `json:"block"` +} + +// BlockRoom blocks or unblocks a room. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#block-room-api +func (cli *Client) BlockRoom(ctx context.Context, roomID id.RoomID, req ReqBlockRoom) error { + reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "block") + _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + return err +} + +// RoomsBlockResponse represents the response containing wether a room is blocked or not +type RoomsBlockResponse struct { + Block bool `json:"block"` + UserID id.UserID `json:"user_id"` +} + +// GetRoomBlockStatus gets whether a room is currently blocked. +// +// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#get-block-status +func (cli *Client) GetRoomBlockStatus(ctx context.Context, roomID id.RoomID) (RoomsBlockResponse, error) { + var resp RoomsBlockResponse + reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "block") + _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) + return resp, err +} diff --git a/mautrix-patched/synapseadmin/userapi.go b/mautrix-patched/synapseadmin/userapi.go new file mode 100644 index 00000000..8c525239 --- /dev/null +++ b/mautrix-patched/synapseadmin/userapi.go @@ -0,0 +1,214 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package synapseadmin + +import ( + "context" + "fmt" + "net/http" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// ReqResetPassword is the request content for Client.ResetPassword. +type ReqResetPassword struct { + // The user whose password to reset. + UserID id.UserID `json:"-"` + // The new password for the user. Required. + NewPassword string `json:"new_password"` + // Whether all the user's existing devices should be logged out after the password change. + LogoutDevices bool `json:"logout_devices"` +} + +// ResetPassword changes the password of another user using +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#reset-password +func (cli *Client) ResetPassword(ctx context.Context, req ReqResetPassword) error { + reqURL := cli.BuildAdminURL("v1", "reset_password", req.UserID) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + return err +} + +// UsernameAvailable checks if a username is valid and available for registration on the server using the admin API. +// +// The response format is the same as mautrix.Client.RegisterAvailable, +// but it works even if registration is disabled on the server. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#check-username-availability +func (cli *Client) UsernameAvailable(ctx context.Context, username string) (resp *mautrix.RespRegisterAvailable, err error) { + u := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "username_available"}, map[string]string{"username": username}) + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, u, nil, &resp) + if err == nil && !resp.Available { + err = fmt.Errorf(`request returned OK status without "available": true`) + } + return +} + +type DeviceInfo struct { + mautrix.RespDeviceInfo + LastSeenUserAgent string `json:"last_seen_user_agent"` +} + +type RespListDevices struct { + Devices []DeviceInfo `json:"devices"` + Total int `json:"total"` +} + +// ListDevices gets information about all the devices of a specific user. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#list-all-devices +func (cli *Client) ListDevices(ctx context.Context, userID id.UserID) (resp *RespListDevices, err error) { + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID, "devices"), nil, &resp) + return +} + +type RespUserInfo struct { + UserID id.UserID `json:"name"` + DisplayName string `json:"displayname"` + AvatarURL id.ContentURIString `json:"avatar_url"` + Guest bool `json:"is_guest"` + Admin bool `json:"admin"` + Deactivated bool `json:"deactivated"` + Erased bool `json:"erased"` + ShadowBanned bool `json:"shadow_banned"` + CreationTS jsontime.Unix `json:"creation_ts"` + AppserviceID string `json:"appservice_id"` + UserType string `json:"user_type"` + + // TODO: consent fields, threepids, external IDs +} + +// GetUserInfo gets information about a specific user account. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#query-user-account +func (cli *Client) GetUserInfo(ctx context.Context, userID id.UserID) (resp *RespUserInfo, err error) { + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID), nil, &resp) + return +} + +type ReqDeleteUser struct { + Erase bool `json:"erase"` +} + +// DeactivateAccount deactivates a specific local user account. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account +func (cli *Client) DeactivateAccount(ctx context.Context, userID id.UserID, req ReqDeleteUser) error { + reqURL := cli.BuildAdminURL("v1", "deactivate", userID) + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + return err +} + +type ReqSuspendUser struct { + Suspend bool `json:"suspend"` +} + +// SuspendAccount suspends or unsuspends a specific local user account. +// +// https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#suspendunsuspend-account +func (cli *Client) SuspendAccount(ctx context.Context, userID id.UserID, req ReqSuspendUser) error { + reqURL := cli.BuildAdminURL("v1", "suspend", userID) + _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + return err +} + +type ReqCreateOrModifyAccount struct { + Password string `json:"password,omitempty"` + LogoutDevices *bool `json:"logout_devices,omitempty"` + + Deactivated *bool `json:"deactivated,omitempty"` + Admin *bool `json:"admin,omitempty"` + Locked *bool `json:"locked,omitempty"` + + Displayname string `json:"displayname,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + UserType string `json:"user_type,omitempty"` +} + +// CreateOrModifyAccount creates or modifies an account on the server. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#create-or-modify-account +func (cli *Client) CreateOrModifyAccount(ctx context.Context, userID id.UserID, req ReqCreateOrModifyAccount) error { + reqURL := cli.BuildAdminURL("v2", "users", userID) + _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) + return err +} + +type RatelimitOverride struct { + MessagesPerSecond int `json:"messages_per_second"` + BurstCount int `json:"burst_count"` +} + +type ReqSetRatelimit = RatelimitOverride + +// SetUserRatelimit overrides the message sending ratelimit for a specific user. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#set-ratelimit +func (cli *Client) SetUserRatelimit(ctx context.Context, userID id.UserID, req ReqSetRatelimit) error { + reqURL := cli.BuildAdminURL("v1", "users", userID, "override_ratelimit") + _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) + return err +} + +type RespUserRatelimit = RatelimitOverride + +// GetUserRatelimit gets the ratelimit override for the given user. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#get-status-of-ratelimit +func (cli *Client) GetUserRatelimit(ctx context.Context, userID id.UserID) (resp RespUserRatelimit, err error) { + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, &resp) + return +} + +// DeleteUserRatelimit deletes the ratelimit override for the given user, returning them to the default ratelimits. +// +// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#delete-ratelimit +func (cli *Client) DeleteUserRatelimit(ctx context.Context, userID id.UserID) (err error) { + _, err = cli.Client.MakeRequest(ctx, http.MethodDelete, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, nil) + return +} + +type ReqRedactUser struct { + // A list of rooms to redact the user’s events in. + // If an empty list is provided all events in all rooms the user is a member of will be redacted. + Rooms []id.RoomID `json:"rooms"` + // Reason the redaction is being requested, ie “spam”, “abuse”, etc. This will be included in each redaction event. + Reason string `json:"reason,omitempty"` + // a limit on the number of the user’s events to search for ones that can be redacted (events are redacted newest to + // oldest) in each room, defaults to 1000 if not provided. + Limit int `json:"limit,omitempty"` + // If set to true, the admin user is used to issue the redactions, rather than puppeting the user + UseAdmin bool `json:"use_admin,omitempty"` +} + +type RespRedactUser struct { + RedactID string `json:"redact_id"` +} + +// RedactUser puppets the target user to redact their events. +// If the supplied room list is empty, all rooms the user is a member of are used. +// If the user is a remote user, the invoking admin user will be used to issue redactions. +func (cli *Client) RedactUser(ctx context.Context, userID id.UserID, req ReqRedactUser) (resp RespRedactUser, err error) { + if req.Rooms == nil { + req.Rooms = []id.RoomID{} + } + _, err = cli.Client.MakeRequest(ctx, http.MethodPost, cli.BuildAdminURL("v1", "user", userID, "redact"), &req, &resp) + return +} + +type RespRedactUserStatus struct { + Status string `json:"status"` + FailedRedactions map[id.EventID]string `json:"failed_redactions"` +} + +func (cli *Client) RedactUserStatus(ctx context.Context, redactID string) (resp RespRedactUserStatus, err error) { + _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "user", "redact_status", redactID), nil, &resp) + return +} diff --git a/mautrix-patched/sync.go b/mautrix-patched/sync.go new file mode 100644 index 00000000..5b19b9ad --- /dev/null +++ b/mautrix-patched/sync.go @@ -0,0 +1,287 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + "time" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// EventHandler handles a single event from a sync response. +type EventHandler = func(ctx context.Context, evt *event.Event) + +// SyncHandler handles a whole sync response. If the return value is false, handling will be stopped completely. +type SyncHandler func(ctx context.Context, resp *RespSync, since string) bool + +// Syncer is an interface that must be satisfied in order to do /sync requests on a client. +type Syncer interface { + // ProcessResponse processes the /sync response. The since parameter is the since= value that was used to produce the response. + // This is useful for detecting the very first sync (since=""). If an error is return, Syncing will be stopped permanently. + ProcessResponse(ctx context.Context, resp *RespSync, since string) error + // OnFailedSync returns either the time to wait before retrying or an error to stop syncing permanently. + OnFailedSync(res *RespSync, err error) (time.Duration, error) + // GetFilterJSON for the given user ID. NOT the filter ID. + GetFilterJSON(userID id.UserID) *Filter +} + +type ExtensibleSyncer interface { + OnSync(callback SyncHandler) + OnEvent(callback EventHandler) + OnEventType(eventType event.Type, callback EventHandler) +} + +type DispatchableSyncer interface { + Dispatch(ctx context.Context, evt *event.Event) +} + +// DefaultSyncer is the default syncing implementation. You can either write your own syncer, or selectively +// replace parts of this default syncer (e.g. the ProcessResponse method). The default syncer uses the observer +// pattern to notify callers about incoming events. See DefaultSyncer.OnEventType for more information. +type DefaultSyncer struct { + // syncListeners want the whole sync response, e.g. the crypto machine + syncListeners []SyncHandler + // globalListeners want all events + globalListeners []EventHandler + // listeners want a specific event type + listeners map[event.Type][]EventHandler + // ParseEventContent determines whether or not event content should be parsed before passing to handlers. + ParseEventContent bool + // ParseErrorHandler is called when event.Content.ParseRaw returns an error. + // If it returns false, the event will not be forwarded to listeners. + ParseErrorHandler func(evt *event.Event, err error) bool + // FilterJSON is used when the client starts syncing and doesn't get an existing filter ID from SyncStore's LoadFilterID. + FilterJSON *Filter +} + +var _ Syncer = (*DefaultSyncer)(nil) +var _ ExtensibleSyncer = (*DefaultSyncer)(nil) + +// NewDefaultSyncer returns an instantiated DefaultSyncer +func NewDefaultSyncer() *DefaultSyncer { + return &DefaultSyncer{ + listeners: make(map[event.Type][]EventHandler), + syncListeners: []SyncHandler{}, + globalListeners: []EventHandler{}, + ParseEventContent: true, + ParseErrorHandler: func(evt *event.Event, err error) bool { + // By default, drop known events that can't be parsed, but let unknown events through + return errors.Is(err, event.ErrUnsupportedContentType) || + // Also allow events that had their content already parsed by some other function + errors.Is(err, event.ErrContentAlreadyParsed) + }, + } +} + +// ProcessResponse processes the /sync response in a way suitable for bots. "Suitable for bots" means a stream of +// unrepeating events. Returns a fatal error if a listener panics. +func (s *DefaultSyncer) ProcessResponse(ctx context.Context, res *RespSync, since string) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("ProcessResponse panicked! since=%s panic=%s\n%s", since, r, debug.Stack()) + } + }() + ctx = context.WithValue(ctx, SyncTokenContextKey, since) + + for _, listener := range s.syncListeners { + if !listener(ctx, res, since) { + return + } + } + + s.processSyncEvents(ctx, "", res.ToDevice.Events, event.SourceToDevice, false) + s.processSyncEvents(ctx, "", res.Presence.Events, event.SourcePresence, false) + s.processSyncEvents(ctx, "", res.AccountData.Events, event.SourceAccountData, false) + + for roomID, roomData := range res.Rooms.Join { + s.processSyncEvents(ctx, roomID, roomData.Sticky.Events, event.SourceJoin|event.SourceSticky, false) + if roomData.StateAfter == nil { + s.processSyncEvents(ctx, roomID, roomData.State.Events, event.SourceJoin|event.SourceState, false) + s.processSyncEvents(ctx, roomID, roomData.Timeline.Events, event.SourceJoin|event.SourceTimeline, false) + } else { + s.processSyncEvents(ctx, roomID, roomData.Timeline.Events, event.SourceJoin|event.SourceTimeline, true) + s.processSyncEvents(ctx, roomID, roomData.StateAfter.Events, event.SourceJoin|event.SourceState, false) + } + s.processSyncEvents(ctx, roomID, roomData.Ephemeral.Events, event.SourceJoin|event.SourceEphemeral, false) + s.processSyncEvents(ctx, roomID, roomData.AccountData.Events, event.SourceJoin|event.SourceAccountData, false) + } + for roomID, roomData := range res.Rooms.Invite { + s.processSyncEvents(ctx, roomID, roomData.State.Events, event.SourceInvite|event.SourceState, false) + } + for roomID, roomData := range res.Rooms.Leave { + s.processSyncEvents(ctx, roomID, roomData.State.Events, event.SourceLeave|event.SourceState, false) + s.processSyncEvents(ctx, roomID, roomData.Timeline.Events, event.SourceLeave|event.SourceTimeline, false) + } + return +} + +func (s *DefaultSyncer) processSyncEvents(ctx context.Context, roomID id.RoomID, events []*event.Event, source event.Source, ignoreState bool) { + for _, evt := range events { + s.processSyncEvent(ctx, roomID, evt, source, ignoreState) + } +} + +func (s *DefaultSyncer) processSyncEvent(ctx context.Context, roomID id.RoomID, evt *event.Event, source event.Source, ignoreState bool) { + evt.RoomID = roomID + + // Ensure the type class is correct. It's safe to mutate the class since the event type is not a pointer. + // Listeners are keyed by type structs, which means only the correct class will pass. + switch { + case evt.StateKey != nil: + evt.Type.Class = event.StateEventType + case source == event.SourcePresence, source&event.SourceEphemeral != 0: + evt.Type.Class = event.EphemeralEventType + case source&event.SourceAccountData != 0: + evt.Type.Class = event.AccountDataEventType + case source == event.SourceToDevice: + evt.Type.Class = event.ToDeviceEventType + default: + evt.Type.Class = event.MessageEventType + } + + if s.ParseEventContent { + err := evt.Content.ParseRaw(evt.Type) + if err != nil && !s.ParseErrorHandler(evt, err) { + return + } + } + + evt.Mautrix.EventSource = source + evt.Mautrix.IgnoreState = ignoreState + s.Dispatch(ctx, evt) +} + +func (s *DefaultSyncer) Dispatch(ctx context.Context, evt *event.Event) { + for _, fn := range s.globalListeners { + fn(ctx, evt) + } + listeners, exists := s.listeners[evt.Type] + if exists { + for _, fn := range listeners { + fn(ctx, evt) + } + } +} + +// OnEventType allows callers to be notified when there are new events for the given event type. +// There are no duplicate checks. +func (s *DefaultSyncer) OnEventType(eventType event.Type, callback EventHandler) { + _, exists := s.listeners[eventType] + if !exists { + s.listeners[eventType] = []EventHandler{} + } + s.listeners[eventType] = append(s.listeners[eventType], callback) +} + +func (s *DefaultSyncer) OnSync(callback SyncHandler) { + s.syncListeners = append(s.syncListeners, callback) +} + +func (s *DefaultSyncer) OnEvent(callback EventHandler) { + s.globalListeners = append(s.globalListeners, callback) +} + +// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. +func (s *DefaultSyncer) OnFailedSync(res *RespSync, err error) (time.Duration, error) { + if errors.Is(err, MUnknownToken) { + return 0, err + } + return 10 * time.Second, nil +} + +var defaultFilter = Filter{ + Room: &RoomFilter{ + Timeline: &FilterPart{ + Limit: 50, + }, + }, +} + +// GetFilterJSON returns a filter with a timeline limit of 50. +func (s *DefaultSyncer) GetFilterJSON(userID id.UserID) *Filter { + if s.FilterJSON == nil { + defaultFilterCopy := defaultFilter + s.FilterJSON = &defaultFilterCopy + } + return s.FilterJSON +} + +// DontProcessOldEvents is a sync handler that removes rooms that the user just joined. +// It's meant for bots to ignore events from before the bot joined the room. +// +// To use it, register it with your Syncer, e.g.: +// +// cli.Syncer.(mautrix.ExtensibleSyncer).OnSync(cli.DontProcessOldEvents) +func (cli *Client) DontProcessOldEvents(_ context.Context, resp *RespSync, since string) bool { + return dontProcessOldEvents(cli.UserID, resp, since) +} + +var _ SyncHandler = (*Client)(nil).DontProcessOldEvents + +func dontProcessOldEvents(userID id.UserID, resp *RespSync, since string) bool { + if since == "" { + return false + } + // This is a horrible hack because /sync will return the most recent messages for a room + // as soon as you /join it. We do NOT want to process those events in that particular room + // because they may have already been processed (if you toggle the bot in/out of the room). + // + // Work around this by inspecting each room's timeline and seeing if an m.room.member event for us + // exists and is "join" and then discard processing that room entirely if so. + // TODO: We probably want to process messages from after the last join event in the timeline. + for roomID, roomData := range resp.Rooms.Join { + for i := len(roomData.Timeline.Events) - 1; i >= 0; i-- { + evt := roomData.Timeline.Events[i] + if evt.Type == event.StateMember && evt.GetStateKey() == string(userID) { + membership, _ := evt.Content.Raw["membership"].(string) + if membership == "join" { + _, ok := resp.Rooms.Join[roomID] + if !ok { + continue + } + delete(resp.Rooms.Join, roomID) // don't re-process messages + delete(resp.Rooms.Invite, roomID) // don't re-process invites + break + } + } + } + } + return true +} + +// MoveInviteState is a sync handler that moves events from the state event list to the InviteRoomState in the invite event. +// +// To use it, register it with your Syncer, e.g.: +// +// cli.Syncer.(mautrix.ExtensibleSyncer).OnSync(cli.MoveInviteState) +func (cli *Client) MoveInviteState(ctx context.Context, resp *RespSync, _ string) bool { + for _, meta := range resp.Rooms.Invite { + var inviteState []*event.Event + var inviteEvt *event.Event + for _, evt := range meta.State.Events { + if evt.Type == event.StateMember && evt.GetStateKey() == cli.UserID.String() { + inviteEvt = evt + } else { + evt.Type.Class = event.StateEventType + _ = evt.Content.ParseRaw(evt.Type) + inviteState = append(inviteState, evt) + } + } + if inviteEvt != nil { + inviteEvt.Unsigned.InviteRoomState = inviteState + meta.State.Events = []*event.Event{inviteEvt} + } + } + return true +} + +var _ SyncHandler = (*Client)(nil).MoveInviteState diff --git a/mautrix-patched/syncstore.go b/mautrix-patched/syncstore.go new file mode 100644 index 00000000..6c7fc9ee --- /dev/null +++ b/mautrix-patched/syncstore.go @@ -0,0 +1,175 @@ +package mautrix + +import ( + "context" + "errors" + "fmt" + + "maunium.net/go/mautrix/id" +) + +var _ SyncStore = (*MemorySyncStore)(nil) +var _ SyncStore = (*AccountDataStore)(nil) + +// SyncStore is an interface which must be satisfied to store client data. +// +// You can either write a struct which persists this data to disk, or you can use the +// provided "MemorySyncStore" which just keeps data around in-memory which is lost on +// restarts. +type SyncStore interface { + SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error + LoadFilterID(ctx context.Context, userID id.UserID) (string, error) + SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error + LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) +} + +// Deprecated: renamed to SyncStore +type Storer = SyncStore + +// MemorySyncStore implements the Storer interface. +// +// Everything is persisted in-memory as maps. It is not safe to load/save filter IDs +// or next batch tokens on any goroutine other than the syncing goroutine: the one +// which called Client.Sync(). +type MemorySyncStore struct { + Filters map[id.UserID]string + NextBatch map[id.UserID]string +} + +// SaveFilterID to memory. +func (s *MemorySyncStore) SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error { + s.Filters[userID] = filterID + return nil +} + +// LoadFilterID from memory. +func (s *MemorySyncStore) LoadFilterID(ctx context.Context, userID id.UserID) (string, error) { + return s.Filters[userID], nil +} + +// SaveNextBatch to memory. +func (s *MemorySyncStore) SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error { + s.NextBatch[userID] = nextBatchToken + return nil +} + +// LoadNextBatch from memory. +func (s *MemorySyncStore) LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) { + return s.NextBatch[userID], nil +} + +// NewMemorySyncStore constructs a new MemorySyncStore. +func NewMemorySyncStore() *MemorySyncStore { + return &MemorySyncStore{ + Filters: make(map[id.UserID]string), + NextBatch: make(map[id.UserID]string), + } +} + +// AccountDataStore uses account data to store the next batch token, and stores the filter ID in memory +// (as filters can be safely recreated every startup). +type AccountDataStore struct { + FilterID string + EventType string + client *Client + nextBatch string +} + +type accountData struct { + NextBatch string `json:"next_batch"` +} + +func (s *AccountDataStore) SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error { + if userID.String() != s.client.UserID.String() { + panic("AccountDataStore must only be used with a single account") + } + s.FilterID = filterID + return nil +} + +func (s *AccountDataStore) LoadFilterID(ctx context.Context, userID id.UserID) (string, error) { + if userID.String() != s.client.UserID.String() { + panic("AccountDataStore must only be used with a single account") + } + return s.FilterID, nil +} + +func (s *AccountDataStore) SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error { + if userID.String() != s.client.UserID.String() { + panic("AccountDataStore must only be used with a single account") + } else if nextBatchToken == s.nextBatch { + return nil + } + + data := accountData{ + NextBatch: nextBatchToken, + } + + err := s.client.SetAccountData(ctx, s.EventType, data) + if err != nil { + return fmt.Errorf("failed to save next batch token to account data: %w", err) + } else { + s.client.Log.Debug(). + Str("old_token", s.nextBatch). + Str("new_token", nextBatchToken). + Msg("Saved next batch token") + s.nextBatch = nextBatchToken + } + return nil +} + +func (s *AccountDataStore) LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) { + if userID.String() != s.client.UserID.String() { + panic("AccountDataStore must only be used with a single account") + } + + data := &accountData{} + + err := s.client.GetAccountData(ctx, s.EventType, data) + if err != nil { + if errors.Is(err, MNotFound) { + s.client.Log.Debug().Msg("No next batch token found in account data") + return "", nil + } else { + return "", fmt.Errorf("failed to load next batch token from account data: %w", err) + } + } + s.nextBatch = data.NextBatch + s.client.Log.Debug().Str("next_batch", data.NextBatch).Msg("Loaded next batch token from account data") + + return s.nextBatch, nil +} + +// NewAccountDataStore returns a new AccountDataStore, which stores +// the next_batch token as a custom event in account data in the +// homeserver. +// +// AccountDataStore is only appropriate for bots, not appservices. +// +// The event type should be a reversed DNS name like tld.domain.sub.internal and +// must be unique for a client. The data stored in it is considered internal +// and must not be modified through outside means. You should also add a filter +// for account data changes of this event type, to avoid ending up in a sync +// loop: +// +// filter := mautrix.Filter{ +// AccountData: mautrix.FilterPart{ +// Limit: 20, +// NotTypes: []event.Type{ +// event.NewEventType(eventType), +// }, +// }, +// } +// // If you use a custom Syncer, set the filter there, not like this +// client.Syncer.(*mautrix.DefaultSyncer).FilterJSON = &filter +// client.Store = mautrix.NewAccountDataStore("com.example.mybot.store", client) +// go func() { +// err := client.Sync() +// // don't forget to check err +// }() +func NewAccountDataStore(eventType string, client *Client) *AccountDataStore { + return &AccountDataStore{ + EventType: eventType, + client: client, + } +} diff --git a/mautrix-patched/url.go b/mautrix-patched/url.go new file mode 100644 index 00000000..91b3d49d --- /dev/null +++ b/mautrix-patched/url.go @@ -0,0 +1,127 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +func ParseAndNormalizeBaseURL(homeserverURL string) (*url.URL, error) { + hsURL, err := url.Parse(homeserverURL) + if err != nil { + return nil, err + } + if hsURL.Scheme == "" { + hsURL.Scheme = "https" + fixedURL := hsURL.String() + hsURL, err = url.Parse(fixedURL) + if err != nil { + return nil, fmt.Errorf("failed to parse fixed URL '%s': %v", fixedURL, err) + } + } + hsURL.RawPath = hsURL.EscapedPath() + return hsURL, nil +} + +// BuildURL builds a URL with the given path parts +func BuildURL(baseURL *url.URL, path ...any) *url.URL { + createdURL := *baseURL + rawParts := make([]string, len(path)+1) + rawParts[0] = strings.TrimSuffix(createdURL.RawPath, "/") + parts := make([]string, len(path)+1) + parts[0] = strings.TrimSuffix(createdURL.Path, "/") + for i, part := range path { + switch casted := part.(type) { + case string: + parts[i+1] = casted + case int: + parts[i+1] = strconv.Itoa(casted) + case fmt.Stringer: + parts[i+1] = casted.String() + default: + parts[i+1] = fmt.Sprint(casted) + } + rawParts[i+1] = url.PathEscape(parts[i+1]) + } + createdURL.Path = strings.Join(parts, "/") + createdURL.RawPath = strings.Join(rawParts, "/") + return &createdURL +} + +// BuildURL builds a URL with the Client's homeserver and appservice user ID set already. +func (cli *Client) BuildURL(urlPath PrefixableURLPath) string { + return cli.BuildURLWithFullQuery(urlPath, nil) +} + +// BuildClientURL builds a URL with the Client's homeserver and appservice user ID set already. +// This method also automatically prepends the client API prefix (/_matrix/client). +func (cli *Client) BuildClientURL(urlPath ...any) string { + return cli.BuildURLWithFullQuery(ClientURLPath(urlPath), nil) +} + +type PrefixableURLPath interface { + FullPath() []any +} + +type BaseURLPath []any + +func (bup BaseURLPath) FullPath() []any { + return bup +} + +type ClientURLPath []any + +func (cup ClientURLPath) FullPath() []any { + return append([]any{"_matrix", "client"}, []any(cup)...) +} + +type MediaURLPath []any + +func (mup MediaURLPath) FullPath() []any { + return append([]any{"_matrix", "media"}, []any(mup)...) +} + +type SynapseAdminURLPath []any + +func (saup SynapseAdminURLPath) FullPath() []any { + return append([]any{"_synapse", "admin"}, []any(saup)...) +} + +// BuildURLWithQuery builds a URL with query parameters in addition to the Client's homeserver +// and appservice user ID set already. +func (cli *Client) BuildURLWithQuery(urlPath PrefixableURLPath, urlQuery map[string]string) string { + return cli.BuildURLWithFullQuery(urlPath, func(q url.Values) { + for k, v := range urlQuery { + q.Set(k, v) + } + }) +} + +// BuildURLWithQuery builds a URL with query parameters in addition to the Client's homeserver +// and appservice user ID set already. +func (cli *Client) BuildURLWithFullQuery(urlPath PrefixableURLPath, fn func(q url.Values)) string { + if cli == nil { + return "client is nil" + } + hsURL := *BuildURL(cli.HomeserverURL, urlPath.FullPath()...) + query := hsURL.Query() + if cli.SetAppServiceUserID { + query.Set("user_id", string(cli.UserID)) + } + if cli.SetAppServiceDeviceID && cli.DeviceID != "" { + query.Set("device_id", string(cli.DeviceID)) + query.Set("org.matrix.msc3202.device_id", string(cli.DeviceID)) + } + if fn != nil { + fn(query) + } + hsURL.RawQuery = query.Encode() + return hsURL.String() +} diff --git a/mautrix-patched/url_test.go b/mautrix-patched/url_test.go new file mode 100644 index 00000000..5ce3548c --- /dev/null +++ b/mautrix-patched/url_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix" +) + +func TestClient_BuildURL(t *testing.T) { + cli, err := mautrix.NewClient("https://example.com", "", "") + assert.NoError(t, err) + assert.Equal(t, cli.HomeserverURL.Scheme, "https") + assert.Equal(t, cli.HomeserverURL.Host, "example.com") + assert.Equal(t, cli.HomeserverURL.Path, "") + built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") + assert.Equal(t, "https://example.com/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) +} + +func TestClient_BuildURL_HTTP(t *testing.T) { + cli, err := mautrix.NewClient("http://example.com", "", "") + assert.NoError(t, err) + assert.Equal(t, cli.HomeserverURL.Scheme, "http") + assert.Equal(t, cli.HomeserverURL.Host, "example.com") + assert.Equal(t, cli.HomeserverURL.Path, "") + built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") + assert.Equal(t, "http://example.com/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) +} + +func TestClient_BuildURL_MissingScheme(t *testing.T) { + cli, err := mautrix.NewClient("example.com", "", "") + assert.NoError(t, err) + assert.Equal(t, cli.HomeserverURL.Scheme, "https") + assert.Equal(t, cli.HomeserverURL.Host, "example.com") + assert.Equal(t, cli.HomeserverURL.Path, "") + built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") + assert.Equal(t, "https://example.com/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) +} + +func TestClient_BuildURL_WithPath(t *testing.T) { + cli, err := mautrix.NewClient("https://example.com/base", "", "") + assert.NoError(t, err) + assert.Equal(t, cli.HomeserverURL.Scheme, "https") + assert.Equal(t, cli.HomeserverURL.Host, "example.com") + assert.Equal(t, cli.HomeserverURL.Path, "/base") + built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") + assert.Equal(t, "https://example.com/base/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) +} + +func TestClient_BuildURL_MissingSchemeWithPath(t *testing.T) { + cli, err := mautrix.NewClient("example.com/base", "", "") + assert.NoError(t, err) + assert.Equal(t, cli.HomeserverURL.Scheme, "https") + assert.Equal(t, cli.HomeserverURL.Host, "example.com") + assert.Equal(t, cli.HomeserverURL.Path, "/base") + built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") + assert.Equal(t, "https://example.com/base/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) +} diff --git a/mautrix-patched/version.go b/mautrix-patched/version.go new file mode 100644 index 00000000..844558bb --- /dev/null +++ b/mautrix-patched/version.go @@ -0,0 +1,41 @@ +package mautrix + +import ( + "fmt" + "regexp" + "runtime" + "runtime/debug" + "strings" +) + +const Version = "v0.28.1" + +var GoModVersion = "" +var Commit = "" +var VersionWithCommit = Version + +var DefaultUserAgent = "mautrix-go/" + Version + " go/" + strings.TrimPrefix(runtime.Version(), "go") + +func init() { + if GoModVersion == "" { + info, _ := debug.ReadBuildInfo() + if info != nil { + for _, mod := range info.Deps { + if mod.Path == "maunium.net/go/mautrix" { + GoModVersion = mod.Version + break + } + } + } + } + if GoModVersion != "" { + match := regexp.MustCompile(`v.+\d{14}-([0-9a-f]{12})`).FindStringSubmatch(GoModVersion) + if match != nil { + Commit = match[1] + } + } + if Commit != "" { + VersionWithCommit = fmt.Sprintf("%s+dev.%s", Version, Commit[:8]) + DefaultUserAgent = strings.Replace(DefaultUserAgent, "mautrix-go/"+Version, "mautrix-go/"+VersionWithCommit, 1) + } +} diff --git a/mautrix-patched/versions.go b/mautrix-patched/versions.go new file mode 100644 index 00000000..ecc63c8c --- /dev/null +++ b/mautrix-patched/versions.go @@ -0,0 +1,216 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix + +import ( + "fmt" + "regexp" + "strconv" +) + +// RespVersions is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientversions +type RespVersions struct { + Versions []SpecVersion `json:"versions"` + UnstableFeatures map[string]bool `json:"unstable_features"` +} + +func (versions *RespVersions) ContainsFunc(match func(found SpecVersion) bool) bool { + if versions == nil { + return false + } + for _, found := range versions.Versions { + if match(found) { + return true + } + } + return false +} + +func (versions *RespVersions) Contains(version SpecVersion) bool { + return versions.ContainsFunc(func(found SpecVersion) bool { + return found == version + }) +} + +func (versions *RespVersions) ContainsGreaterOrEqual(version SpecVersion) bool { + return versions.ContainsFunc(func(found SpecVersion) bool { + return found.GreaterThan(version) || found == version + }) +} + +func (versions *RespVersions) GetLatest() (latest SpecVersion) { + if versions == nil { + return + } + for _, ver := range versions.Versions { + if ver.GreaterThan(latest) { + latest = ver + } + } + return +} + +type UnstableFeature struct { + UnstableFlag string + SpecVersion SpecVersion +} + +var ( + FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} + FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} + FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} + FeatureUnstableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} + FeatureStableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms.stable" /*, SpecVersion: SpecV119*/} + FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} + FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} + FeatureUnstableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} + FeatureStableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323.stable", SpecVersion: SpecV118} + FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} + FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} + FeatureRedactSendAsEvent = UnstableFeature{UnstableFlag: "com.beeper.msc4169"} + FeatureUnstableReplaceProfile = UnstableFeature{UnstableFlag: "com.beeper.msc4437"} + FeatureStableReplaceProfile = UnstableFeature{UnstableFlag: "com.beeper.msc4437.stable"} + FeatureFullyReadBackward = UnstableFeature{UnstableFlag: "com.beeper.msc4446"} + + BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} + BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} + BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"} + BeeperFeatureAutojoinInvites = UnstableFeature{UnstableFlag: "com.beeper.room_create_autojoin_invites"} + BeeperFeatureArbitraryProfileMeta = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_profile_meta"} + BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} + BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} + BeeperFeatureArbitraryMemberChange = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_member_change"} +) + +func (versions *RespVersions) Supports(feature UnstableFeature) bool { + if versions == nil { + return false + } + return versions.UnstableFeatures[feature.UnstableFlag] || + (!feature.SpecVersion.IsEmpty() && versions.ContainsGreaterOrEqual(feature.SpecVersion)) +} + +type SpecVersionFormat int + +const ( + SpecVersionFormatUnknown SpecVersionFormat = iota + SpecVersionFormatR + SpecVersionFormatV +) + +var ( + SpecR000 = MustParseSpecVersion("r0.0.0") + SpecR001 = MustParseSpecVersion("r0.0.1") + SpecR010 = MustParseSpecVersion("r0.1.0") + SpecR020 = MustParseSpecVersion("r0.2.0") + SpecR030 = MustParseSpecVersion("r0.3.0") + SpecR040 = MustParseSpecVersion("r0.4.0") + SpecR050 = MustParseSpecVersion("r0.5.0") + SpecR060 = MustParseSpecVersion("r0.6.0") + SpecR061 = MustParseSpecVersion("r0.6.1") + SpecV11 = MustParseSpecVersion("v1.1") + SpecV12 = MustParseSpecVersion("v1.2") + SpecV13 = MustParseSpecVersion("v1.3") + SpecV14 = MustParseSpecVersion("v1.4") + SpecV15 = MustParseSpecVersion("v1.5") + SpecV16 = MustParseSpecVersion("v1.6") + SpecV17 = MustParseSpecVersion("v1.7") + SpecV18 = MustParseSpecVersion("v1.8") + SpecV19 = MustParseSpecVersion("v1.9") + SpecV110 = MustParseSpecVersion("v1.10") + SpecV111 = MustParseSpecVersion("v1.11") + SpecV112 = MustParseSpecVersion("v1.12") + SpecV113 = MustParseSpecVersion("v1.13") + SpecV114 = MustParseSpecVersion("v1.14") + SpecV115 = MustParseSpecVersion("v1.15") + SpecV116 = MustParseSpecVersion("v1.16") + SpecV117 = MustParseSpecVersion("v1.17") + SpecV118 = MustParseSpecVersion("v1.18") +) + +func (svf SpecVersionFormat) String() string { + switch svf { + case SpecVersionFormatR: + return "r" + case SpecVersionFormatV: + return "v" + default: + return "" + } +} + +type SpecVersion struct { + Format SpecVersionFormat + Major int + Minor int + Patch int + + Raw string +} + +var legacyVersionRegex = regexp.MustCompile(`^r(\d+)\.(\d+)\.(\d+)$`) +var modernVersionRegex = regexp.MustCompile(`^v(\d+)\.(\d+)$`) + +func MustParseSpecVersion(version string) SpecVersion { + sv, err := ParseSpecVersion(version) + if err != nil { + panic(err) + } + return sv +} + +func ParseSpecVersion(version string) (sv SpecVersion, err error) { + sv.Raw = version + if parts := modernVersionRegex.FindStringSubmatch(version); parts != nil { + sv.Major, _ = strconv.Atoi(parts[1]) + sv.Minor, _ = strconv.Atoi(parts[2]) + sv.Format = SpecVersionFormatV + } else if parts = legacyVersionRegex.FindStringSubmatch(version); parts != nil { + sv.Major, _ = strconv.Atoi(parts[1]) + sv.Minor, _ = strconv.Atoi(parts[2]) + sv.Patch, _ = strconv.Atoi(parts[3]) + sv.Format = SpecVersionFormatR + } else { + err = fmt.Errorf("version '%s' doesn't match either known syntax", version) + } + return +} + +func (sv *SpecVersion) UnmarshalText(version []byte) error { + *sv, _ = ParseSpecVersion(string(version)) + return nil +} + +func (sv *SpecVersion) MarshalText() ([]byte, error) { + return []byte(sv.String()), nil +} + +func (sv SpecVersion) String() string { + switch sv.Format { + case SpecVersionFormatR: + return fmt.Sprintf("r%d.%d.%d", sv.Major, sv.Minor, sv.Patch) + case SpecVersionFormatV: + return fmt.Sprintf("v%d.%d", sv.Major, sv.Minor) + default: + return sv.Raw + } +} + +func (sv SpecVersion) IsEmpty() bool { + return sv.Format == SpecVersionFormatUnknown && sv.Raw == "" +} + +func (sv SpecVersion) LessThan(other SpecVersion) bool { + return sv != other && !sv.GreaterThan(other) +} + +func (sv SpecVersion) GreaterThan(other SpecVersion) bool { + return sv.Format > other.Format || + (sv.Format == other.Format && sv.Major > other.Major) || + (sv.Format == other.Format && sv.Major == other.Major && sv.Minor > other.Minor) || + (sv.Format == other.Format && sv.Major == other.Major && sv.Minor == other.Minor && sv.Patch > other.Patch) +} diff --git a/mautrix-patched/versions_test.go b/mautrix-patched/versions_test.go new file mode 100644 index 00000000..fbcced9f --- /dev/null +++ b/mautrix-patched/versions_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2022 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package mautrix_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "maunium.net/go/mautrix" +) + +const sampleVersions = `{ + "versions": [ + "r0.0.1", + "r0.1.0", + "r0.2.0", + "r0.3.0", + "r0.4.0", + "r0.5.0", + "r0.6.0", + "r0.6.1", + "v1.1", + "v1.2" + ], + "unstable_features": { + "org.matrix.label_based_filtering": true, + "org.matrix.e2e_cross_signing": true, + "org.matrix.msc2432": true, + "uk.half-shot.msc2666.mutual_rooms": true, + "io.element.e2ee_forced.public": false, + "io.element.e2ee_forced.private": false, + "io.element.e2ee_forced.trusted_private": false, + "org.matrix.msc3026.busy_presence": false, + "org.matrix.msc2285": true, + "org.matrix.msc2716": false, + "org.matrix.msc3030": false, + "org.matrix.msc3440.stable": true, + "fi.mau.msc2815": false + } +}` + +func TestRespVersions_UnmarshalJSON(t *testing.T) { + var resp mautrix.RespVersions + err := json.Unmarshal([]byte(sampleVersions), &resp) + assert.NoError(t, err) + assert.True(t, resp.ContainsGreaterOrEqual(mautrix.SpecV11)) + assert.True(t, resp.Contains(mautrix.SpecV12)) + assert.True(t, resp.Contains(mautrix.SpecR061)) + assert.True(t, resp.ContainsGreaterOrEqual(mautrix.MustParseSpecVersion("r0.0.0"))) + assert.True(t, !resp.ContainsGreaterOrEqual(mautrix.MustParseSpecVersion("v123.456"))) +} + +func TestParseSpecVersion(t *testing.T) { + assert.Equal(t, + mautrix.SpecVersion{mautrix.SpecVersionFormatR, 0, 1, 0, "r0.1.0"}, + mautrix.MustParseSpecVersion("r0.1.0")) + assert.Equal(t, + mautrix.SpecVersion{mautrix.SpecVersionFormatV, 1, 1, 0, "v1.1"}, + mautrix.MustParseSpecVersion("v1.1")) + assert.Equal(t, + mautrix.SpecVersion{mautrix.SpecVersionFormatV, 123, 456, 0, "v123.456"}, + mautrix.MustParseSpecVersion("v123.456")) + + invalidVer, err := mautrix.ParseSpecVersion("not a version") + assert.Error(t, err) + assert.Equal(t, mautrix.SpecVersion{Raw: "not a version"}, invalidVer) + + // v syntax doesn't allow patch versions + invalidVer, err = mautrix.ParseSpecVersion("v1.2.3") + assert.Error(t, err) + assert.Equal(t, mautrix.SpecVersion{Raw: "v1.2.3"}, invalidVer) + + invalidVer, err = mautrix.ParseSpecVersion("r0.6") + assert.Error(t, err) + assert.Equal(t, mautrix.SpecVersion{Raw: "r0.6"}, invalidVer) +} + +func TestSpecVersion_String(t *testing.T) { + assert.Equal(t, "r0.1.0", (&mautrix.SpecVersion{mautrix.SpecVersionFormatR, 0, 1, 0, ""}).String()) + assert.Equal(t, "v1.2", (&mautrix.SpecVersion{mautrix.SpecVersionFormatV, 1, 2, 0, ""}).String()) + assert.Equal(t, "v567.890", (&mautrix.SpecVersion{mautrix.SpecVersionFormatV, 567, 890, 0, ""}).String()) + assert.Equal(t, "invalid version", (&mautrix.SpecVersion{Raw: "invalid version"}).String()) +} + +func TestSpecVersion_GreaterThan(t *testing.T) { + assert.True(t, mautrix.MustParseSpecVersion("r0.1.0").GreaterThan(mautrix.MustParseSpecVersion("r0.0.0"))) + assert.True(t, mautrix.MustParseSpecVersion("r0.6.0").GreaterThan(mautrix.MustParseSpecVersion("r0.1.0"))) + assert.True(t, mautrix.MustParseSpecVersion("r0.6.1").GreaterThan(mautrix.MustParseSpecVersion("r0.1.0"))) + assert.True(t, mautrix.MustParseSpecVersion("v1.1").GreaterThan(mautrix.MustParseSpecVersion("r0.6.1"))) + assert.True(t, mautrix.MustParseSpecVersion("v11.11").GreaterThan(mautrix.MustParseSpecVersion("v1.23"))) + assert.True(t, mautrix.MustParseSpecVersion("v1.123").GreaterThan(mautrix.MustParseSpecVersion("v1.1"))) + assert.True(t, !mautrix.MustParseSpecVersion("v1.23").GreaterThan(mautrix.MustParseSpecVersion("v2.31"))) + assert.True(t, !mautrix.MustParseSpecVersion("r0.6.0").GreaterThan(mautrix.MustParseSpecVersion("r0.6.1"))) + assert.True(t, !mautrix.MustParseSpecVersion("r0.6.0").GreaterThan(mautrix.MustParseSpecVersion("r0.6.0"))) + assert.True(t, !mautrix.MustParseSpecVersion("r0.6.0").LessThan(mautrix.MustParseSpecVersion("r0.6.0"))) +} From 796fdf09914501c62ccc8914c8413382b3fe79de Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 10:35:35 -0700 Subject: [PATCH 03/15] Send relayed Matrix messages via Discord webhooks --- pkg/connector/handlematrix.go | 52 ++++++++++++++++++++++++++++++++++- pkg/discordid/dbmeta.go | 3 ++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 5a758435..3b0ac2e8 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -149,7 +149,28 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M d.lastSendAttemptMutex.Unlock() } - sentMsg, err := d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx)) + var sentMsg *discordgo.Message + if msg.OrigSender != nil { + webhookID, webhookToken, err := d.getRelayWebhook(ctx, portal, parentChannelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + username := msg.OrigSender.DisambiguatedName + if username == "" { + username = msg.OrigSender.UserID.String() + } + sentMsg, err = d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadChannelID, &discordgo.WebhookParams{ + Content: sendReq.Content, + Username: username, + Embeds: sendReq.Embeds, + Components: sendReq.Components, + Attachments: sendReq.Attachments, + AllowedMentions: sendReq.AllowedMentions, + Flags: discordgo.MessageFlags(ptr.Val(sendReq.Flags)), + }, discordgo.WithContext(ctx)) + } else { + sentMsg, err = d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx)) + } if err != nil { return nil, d.tryWrappingError(ctx, err) } @@ -168,6 +189,35 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M }, nil } +func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Portal, channelID string, refererOpt discordgo.RequestOption) (id, token string, err error) { + meta := portal.Metadata.(*discordid.PortalMetadata) + if meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" { + return meta.RelayWebhookID, meta.RelayWebhookToken, nil + } + + webhooks, err := d.Session.ChannelWebhooks(channelID, refererOpt, discordgo.WithContext(ctx)) + if err != nil { + return "", "", err + } + for _, webhook := range webhooks { + if webhook != nil && webhook.Name == "mautrix-discord" && webhook.Token != "" { + meta.RelayWebhookID = webhook.ID + meta.RelayWebhookToken = webhook.Token + _ = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) + return webhook.ID, webhook.Token, nil + } + } + + webhook, err := d.Session.WebhookCreate(channelID, "mautrix-discord", "", refererOpt, discordgo.WithContext(ctx)) + if err != nil { + return "", "", err + } + meta.RelayWebhookID = webhook.ID + meta.RelayWebhookToken = webhook.Token + _ = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) + return webhook.ID, webhook.Token, nil +} + var errCannotDMStranger = errors.New("can't direct message a stranger") func (d *DiscordClient) screenOutgoingMessage(ctx context.Context, destCh *discordgo.Channel) error { diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go index d8b6ba3a..f68cc5fd 100644 --- a/pkg/discordid/dbmeta.go +++ b/pkg/discordid/dbmeta.go @@ -33,6 +33,9 @@ type PortalMetadata struct { // // This is omitted for guild space portals. ChannelType *discordgo.ChannelType `json:"channel_type,omitempty"` + + RelayWebhookID string `json:"relay_webhook_id,omitempty"` + RelayWebhookToken string `json:"relay_webhook_token,omitempty"` } type UserLoginMetadata struct { From b4fbfbf817c60c39d56397843a43a4d543772b85 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 10:54:24 -0700 Subject: [PATCH 04/15] Use valid relay webhook name --- pkg/connector/handlematrix.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 3b0ac2e8..0af11990 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -50,6 +50,7 @@ type contextKey int const ( contextKeyChannel contextKey = iota + relayWebhookName = "mau bridge" ) type SendAttempt struct { @@ -200,7 +201,7 @@ func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Po return "", "", err } for _, webhook := range webhooks { - if webhook != nil && webhook.Name == "mautrix-discord" && webhook.Token != "" { + if webhook != nil && webhook.Name == relayWebhookName && webhook.Token != "" { meta.RelayWebhookID = webhook.ID meta.RelayWebhookToken = webhook.Token _ = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) @@ -208,7 +209,7 @@ func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Po } } - webhook, err := d.Session.WebhookCreate(channelID, "mautrix-discord", "", refererOpt, discordgo.WithContext(ctx)) + webhook, err := d.Session.WebhookCreate(channelID, relayWebhookName, "", refererOpt, discordgo.WithContext(ctx)) if err != nil { return "", "", err } From 3def6bbf7a6d58e6f96a6d050d15800526826cd4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 11:08:37 -0700 Subject: [PATCH 05/15] Use webhook profiles for relayed Matrix messages --- pkg/connector/capabilities.go | 4 ++++ pkg/connector/handlematrix.go | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 20d9efeb..b09d6fca 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -168,6 +168,10 @@ var discordCaps = &event.RoomFeatures{ LocationMessage: event.CapLevelUnsupported, MaxTextLength: MaxTextLength, Thread: event.CapLevelPartialSupport, + + // Relayed Matrix users are sent through Discord webhooks, so Discord can + // show the sender name per message instead of prefixing the content. + PerMessageProfileRelay: true, } func (d *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 0af11990..9c080288 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -156,7 +156,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M if err != nil { return nil, d.tryWrappingError(ctx, err) } - username := msg.OrigSender.DisambiguatedName + username := relayWebhookUsername(msg.OrigSender) if username == "" { username = msg.OrigSender.UserID.String() } @@ -219,6 +219,16 @@ func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Po return webhook.ID, webhook.Token, nil } +func relayWebhookUsername(sender *bridgev2.OrigSender) string { + if sender.PerMessageProfile.Displayname != "" { + return sender.PerMessageProfile.Displayname + } + if sender.MemberEventContent.Displayname != "" { + return sender.MemberEventContent.Displayname + } + return sender.DisambiguatedName +} + var errCannotDMStranger = errors.New("can't direct message a stranger") func (d *DiscordClient) screenOutgoingMessage(ctx context.Context, destCh *discordgo.Channel) error { From d89c7888c3640ce1b57c04b6711597c67e2f87ab Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 11:45:06 -0700 Subject: [PATCH 06/15] Resolve relay webhooks to Discord ghost profiles --- pkg/connector/handlematrix.go | 59 ++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 9c080288..8242c248 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -156,13 +156,14 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M if err != nil { return nil, d.tryWrappingError(ctx, err) } - username := relayWebhookUsername(msg.OrigSender) + username, avatarURL := d.relayWebhookProfile(ctx, msg.OrigSender) if username == "" { username = msg.OrigSender.UserID.String() } sentMsg, err = d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadChannelID, &discordgo.WebhookParams{ Content: sendReq.Content, Username: username, + AvatarURL: avatarURL, Embeds: sendReq.Embeds, Components: sendReq.Components, Attachments: sendReq.Attachments, @@ -219,6 +220,19 @@ func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Po return webhook.ID, webhook.Token, nil } +func (d *DiscordClient) relayWebhookProfile(ctx context.Context, sender *bridgev2.OrigSender) (username, avatarURL string) { + username = relayWebhookUsername(sender) + if ghostID, ok := d.UserLogin.Bridge.Matrix.ParseGhostMXID(sender.UserID); ok { + if user := d.userCache.Resolve(ctx, discordid.ParseUserID(ghostID)); user != nil { + return user.DisplayName(), user.AvatarURL("256") + } + } + if user := d.resolveRelayGhostByDisplayName(ctx, username); user != nil { + return user.DisplayName(), user.AvatarURL("256") + } + return username, "" +} + func relayWebhookUsername(sender *bridgev2.OrigSender) string { if sender.PerMessageProfile.Displayname != "" { return sender.PerMessageProfile.Displayname @@ -229,6 +243,49 @@ func relayWebhookUsername(sender *bridgev2.OrigSender) string { return sender.DisambiguatedName } +func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *discordgo.User { + displayName = strings.TrimSpace(displayName) + if displayName == "" { + return nil + } + + rows, err := d.UserLogin.Bridge.DB.Query(ctx, ` + SELECT id FROM ghost + WHERE bridge_id=$1 AND ( + name=$2 OR + name=$3 OR + name=$4 + ) + LIMIT 2 + `, d.UserLogin.Bridge.DB.BridgeID, displayName, displayName+" (Discord)", displayName+" (bot) (Discord)") + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to resolve relay sender against Discord ghosts") + return nil + } + defer rows.Close() + + var matchedUserID string + for rows.Next() { + var userID string + if err = rows.Scan(&userID); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to scan relay ghost match") + return nil + } + if matchedUserID != "" && matchedUserID != userID { + return nil + } + matchedUserID = userID + } + if err = rows.Err(); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") + return nil + } + if matchedUserID == "" { + return nil + } + return d.userCache.Resolve(ctx, matchedUserID) +} + var errCannotDMStranger = errors.New("can't direct message a stranger") func (d *DiscordClient) screenOutgoingMessage(ctx context.Context, destCh *discordgo.Channel) error { From 5633726f9b0a4e88824c92954c602984d7545600 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 16:47:02 -0700 Subject: [PATCH 07/15] Edit relayed webhook messages via webhook API --- pkg/connector/handlematrix.go | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 8242c248..e0775c4c 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -331,6 +331,9 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr if !d.IsLoggedIn() { return bridgev2.ErrNotLoggedIn } + if msg.EditTarget == nil { + return fmt.Errorf("missing edit target") + } log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger() ctx = log.WithContext(ctx) @@ -358,6 +361,23 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr } } + if d.isRelayWebhookMessage(msg.Portal, msg.EditTarget) { + meta := msg.Portal.Metadata.(*discordid.PortalMetadata) + _, err := d.Session.WebhookMessageEdit( + meta.RelayWebhookID, + meta.RelayWebhookToken, + discordid.ParseMessageID(msg.EditTarget.ID), + &discordgo.WebhookEdit{ + Content: &content, + }, + discordgo.WithContext(ctx), + ) + if err != nil { + return d.tryWrappingError(ctx, err) + } + return nil + } + _, err := d.Session.ChannelMessageEdit( channelID, discordid.ParseMessageID(msg.EditTarget.ID), @@ -371,6 +391,14 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr return nil } +func (d *DiscordClient) isRelayWebhookMessage(portal *bridgev2.Portal, msg *database.Message) bool { + if msg == nil { + return false + } + meta := portal.Metadata.(*discordid.PortalMetadata) + return meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" && string(msg.SenderID) == meta.RelayWebhookID +} + func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { if !d.IsLoggedIn() { return bridgev2.MatrixReactionPreResponse{}, bridgev2.ErrNotLoggedIn @@ -467,6 +495,9 @@ func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal * if !d.IsLoggedIn() { return bridgev2.ErrNotLoggedIn } + if removal.TargetMessage == nil { + return fmt.Errorf("missing message remove target") + } guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID parentChannelID := discordid.ParseChannelPortalID(removal.Portal.ID) @@ -482,6 +513,15 @@ func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal * } } messageID := discordid.ParseMessageID(removal.TargetMessage.ID) + if d.isRelayWebhookMessage(removal.Portal, removal.TargetMessage) { + meta := removal.Portal.Metadata.(*discordid.PortalMetadata) + return d.tryWrappingError(ctx, d.Session.WebhookMessageDelete( + meta.RelayWebhookID, + meta.RelayWebhookToken, + messageID, + discordgo.WithContext(ctx), + )) + } return d.tryWrappingError(ctx, d.Session.ChannelMessageDelete(channelID, messageID, makeDiscordReferer(guildID, parentChannelID, threadChannelID))) } From f761ce87029102488740bcd6ab24dfb33f9928b5 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 17:37:12 -0700 Subject: [PATCH 08/15] Harden relay webhook handling --- pkg/connector/handlematrix.go | 261 ++++++++++++++++++++++++++++++---- pkg/msgconv/from-matrix.go | 77 +++++----- 2 files changed, 279 insertions(+), 59 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index e0775c4c..3c027155 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -17,13 +17,18 @@ package connector import ( + "bytes" "context" "errors" "fmt" "maps" "math" + "net/http" + "net/url" + "regexp" "strings" "time" + "unicode/utf8" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" @@ -53,6 +58,8 @@ const ( relayWebhookName = "mau bridge" ) +var discordWebhookUsernameWord = regexp.MustCompile(`(?i)discord`) + type SendAttempt struct { At time.Time ChannelType discordgo.ChannelType @@ -152,24 +159,34 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M var sentMsg *discordgo.Message if msg.OrigSender != nil { - webhookID, webhookToken, err := d.getRelayWebhook(ctx, portal, parentChannelID, refererOpt) - if err != nil { - return nil, d.tryWrappingError(ctx, err) + if guildID == "" { + err = fmt.Errorf("Discord webhooks are not available in DMs/group DMs") + return nil, bridgev2.WrapErrorInStatus(err). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithMessage("Relay messages need Discord webhooks, which are only available in guild channels."). + WithSendNotice(true) } - username, avatarURL := d.relayWebhookProfile(ctx, msg.OrigSender) + username, avatarURL := d.relayWebhookProfile(ctx, portal, msg.OrigSender) if username == "" { username = msg.OrigSender.UserID.String() } - sentMsg, err = d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadChannelID, &discordgo.WebhookParams{ + username = sanitizeRelayWebhookUsername(username, msg.OrigSender.UserID.String()) + params := &discordgo.WebhookParams{ Content: sendReq.Content, Username: username, AvatarURL: avatarURL, Embeds: sendReq.Embeds, Components: sendReq.Components, + Files: sendReq.Files, Attachments: sendReq.Attachments, AllowedMentions: sendReq.AllowedMentions, - Flags: discordgo.MessageFlags(ptr.Val(sendReq.Flags)), - }, discordgo.WithContext(ctx)) + Flags: relayWebhookFlags(sendReq.Flags), + } + if sendReq.Reference != nil { + params.Embeds = prependReplyEmbed(params.Embeds, guildID, sendReq.Reference.ChannelID, sendReq.Reference.MessageID) + } + sentMsg, err = d.executeRelayWebhook(ctx, portal, parentChannelID, threadChannelID, params, refererOpt) } else { sentMsg, err = d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx)) } @@ -191,6 +208,54 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M }, nil } +func relayWebhookFlags(flags *int) discordgo.MessageFlags { + if flags == nil { + return 0 + } + return discordgo.MessageFlags(*flags) & discordgo.MessageFlagsSuppressEmbeds +} + +func prependReplyEmbed(embeds []*discordgo.MessageEmbed, guildID, channelID, messageID string) []*discordgo.MessageEmbed { + if channelID == "" || messageID == "" { + return embeds + } + guildPart := guildID + if guildPart == "" { + guildPart = "@me" + } + replyEmbed := &discordgo.MessageEmbed{ + Description: fmt.Sprintf("[Replying to message](https://discord.com/channels/%s/%s/%s)", guildPart, channelID, messageID), + } + return append([]*discordgo.MessageEmbed{replyEmbed}, embeds...) +} + +func (d *DiscordClient) executeRelayWebhook( + ctx context.Context, + portal *bridgev2.Portal, + channelID string, + threadID string, + params *discordgo.WebhookParams, + refererOpt discordgo.RequestOption, +) (*discordgo.Message, error) { + webhookID, webhookToken, err := d.getRelayWebhook(ctx, portal, channelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + sentMsg, err := d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) + if err == nil || !isStaleWebhookError(err) { + return sentMsg, err + } + zerolog.Ctx(ctx).Warn().Err(err).Msg("Relay webhook failed, clearing cached credentials and retrying once") + if clearErr := d.clearRelayWebhook(ctx, portal); clearErr != nil { + zerolog.Ctx(ctx).Warn().Err(clearErr).Msg("Failed to clear stale relay webhook metadata") + } + webhookID, webhookToken, err = d.getRelayWebhook(ctx, portal, channelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + return d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) +} + func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Portal, channelID string, refererOpt discordgo.RequestOption) (id, token string, err error) { meta := portal.Metadata.(*discordid.PortalMetadata) if meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" { @@ -205,7 +270,9 @@ func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Po if webhook != nil && webhook.Name == relayWebhookName && webhook.Token != "" { meta.RelayWebhookID = webhook.ID meta.RelayWebhookToken = webhook.Token - _ = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) + if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") + } return webhook.ID, webhook.Token, nil } } @@ -216,12 +283,41 @@ func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Po } meta.RelayWebhookID = webhook.ID meta.RelayWebhookToken = webhook.Token - _ = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) + if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") + } return webhook.ID, webhook.Token, nil } -func (d *DiscordClient) relayWebhookProfile(ctx context.Context, sender *bridgev2.OrigSender) (username, avatarURL string) { +func (d *DiscordClient) clearRelayWebhook(ctx context.Context, portal *bridgev2.Portal) error { + meta := portal.Metadata.(*discordid.PortalMetadata) + meta.RelayWebhookID = "" + meta.RelayWebhookToken = "" + return d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) +} + +func isStaleWebhookError(err error) bool { + var restErr *discordgo.RESTError + if !errors.As(err, &restErr) { + return false + } + if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusUnauthorized || restErr.Response.StatusCode == http.StatusNotFound) { + return true + } + return restErr.Message != nil && (restErr.Message.Code == discordgo.ErrCodeUnknownWebhook || restErr.Message.Code == discordgo.ErrCodeInvalidWebhookTokenProvided) +} + +func (d *DiscordClient) relayWebhookProfile(ctx context.Context, portal *bridgev2.Portal, sender *bridgev2.OrigSender) (username, avatarURL string) { username = relayWebhookUsername(sender) + if sender.User != nil { + if login, _, err := portal.FindPreferredLogin(ctx, sender.User, false); err != nil { + zerolog.Ctx(ctx).Debug().Err(err).Stringer("sender_mxid", sender.UserID).Msg("No explicit Discord login for relayed sender in portal") + } else if login != nil { + if user := d.userCache.Resolve(ctx, discordid.ParseUserLoginID(login.ID)); user != nil { + return user.DisplayName(), user.AvatarURL("256") + } + } + } if ghostID, ok := d.UserLogin.Bridge.Matrix.ParseGhostMXID(sender.UserID); ok { if user := d.userCache.Resolve(ctx, discordid.ParseUserID(ghostID)); user != nil { return user.DisplayName(), user.AvatarURL("256") @@ -233,6 +329,29 @@ func (d *DiscordClient) relayWebhookProfile(ctx context.Context, sender *bridgev return username, "" } +func sanitizeRelayWebhookUsername(username, fallback string) string { + username = strings.TrimSpace(username) + if username == "" { + username = strings.TrimSpace(fallback) + } + username = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, username) + username = strings.TrimSpace(discordWebhookUsernameWord.ReplaceAllString(username, "dscord")) + if username == "" { + username = "Matrix user" + } + const maxWebhookUsernameRunes = 80 + if utf8.RuneCountInString(username) > maxWebhookUsernameRunes { + runes := []rune(username) + username = string(runes[:maxWebhookUsernameRunes]) + } + return username +} + func relayWebhookUsername(sender *bridgev2.OrigSender) string { if sender.PerMessageProfile.Displayname != "" { return sender.PerMessageProfile.Displayname @@ -250,13 +369,12 @@ func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, disp } rows, err := d.UserLogin.Bridge.DB.Query(ctx, ` - SELECT id FROM ghost + SELECT id, avatar_id FROM ghost WHERE bridge_id=$1 AND ( name=$2 OR name=$3 OR name=$4 ) - LIMIT 2 `, d.UserLogin.Bridge.DB.BridgeID, displayName, displayName+" (Discord)", displayName+" (bot) (Discord)") if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to resolve relay sender against Discord ghosts") @@ -264,23 +382,32 @@ func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, disp } defer rows.Close() - var matchedUserID string + var matchedUserIDs []string + var realAvatarUserIDs []string for rows.Next() { - var userID string - if err = rows.Scan(&userID); err != nil { + var userID, avatarID string + if err = rows.Scan(&userID, &avatarID); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to scan relay ghost match") return nil } - if matchedUserID != "" && matchedUserID != userID { - return nil + matchedUserIDs = append(matchedUserIDs, userID) + if !strings.Contains(avatarID, "cdn.discordapp.com/embed/avatars/") { + realAvatarUserIDs = append(realAvatarUserIDs, userID) } - matchedUserID = userID } if err = rows.Err(); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") return nil } - if matchedUserID == "" { + var matchedUserID string + switch { + case len(realAvatarUserIDs) == 1: + matchedUserID = realAvatarUserIDs[0] + case len(realAvatarUserIDs) > 1: + return nil + case len(matchedUserIDs) == 1: + matchedUserID = matchedUserIDs[0] + default: return nil } return d.userCache.Resolve(ctx, matchedUserID) @@ -338,7 +465,7 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger() ctx = log.WithContext(ctx) - content, _ := d.connector.MsgConv.ConvertMatrixMessageContent( + content, allowedMentions := d.connector.MsgConv.ConvertMatrixMessageContent( ctx, msg.Portal, msg.Content, @@ -363,14 +490,20 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr if d.isRelayWebhookMessage(msg.Portal, msg.EditTarget) { meta := msg.Portal.Metadata.(*discordid.PortalMetadata) - _, err := d.Session.WebhookMessageEdit( + edit := &discordgo.WebhookEdit{ + Content: &content, + AllowedMentions: allowedMentions, + } + if err := d.populateRelayWebhookEditMedia(ctx, edit, msg.Content); err != nil { + return err + } + _, err := d.webhookMessageEditThread( + ctx, meta.RelayWebhookID, meta.RelayWebhookToken, discordid.ParseMessageID(msg.EditTarget.ID), - &discordgo.WebhookEdit{ - Content: &content, - }, - discordgo.WithContext(ctx), + threadChannelID, + edit, ) if err != nil { return d.tryWrappingError(ctx, err) @@ -391,6 +524,75 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr return nil } +func (d *DiscordClient) populateRelayWebhookEditMedia(ctx context.Context, edit *discordgo.WebhookEdit, content *event.MessageEventContent) error { + switch content.MsgType { + case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: + default: + return nil + } + mediaData, err := d.connector.MsgConv.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to download Matrix attachment for relay webhook edit") + return bridgev2.ErrMediaDownloadFailed + } + filename := content.Body + if content.FileName != "" { + filename = content.FileName + } + if filename == "" { + filename = "attachment" + } + contentType := "" + if content.Info != nil { + contentType = content.Info.MimeType + } + edit.Files = []*discordgo.File{{ + Name: filename, + ContentType: contentType, + Reader: bytes.NewReader(mediaData), + }} + attachments := []*discordgo.MessageAttachment{} + edit.Attachments = &attachments + return nil +} + +func (d *DiscordClient) webhookMessageEditThread(ctx context.Context, webhookID, token, messageID, threadID string, data *discordgo.WebhookEdit) (*discordgo.Message, error) { + uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) + var response []byte + var err error + if len(data.Files) > 0 { + contentType, body, encodeErr := discordgo.MultipartBodyWithJSON(data, data.Files) + if encodeErr != nil { + return nil, encodeErr + } + response, err = d.Session.RequestRaw("PATCH", uri, contentType, body, uri, 0, discordgo.WithContext(ctx)) + } else { + response, err = d.Session.RequestWithBucketID("PATCH", uri, data, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) + } + if err != nil { + return nil, err + } + var edited *discordgo.Message + err = discordgo.Unmarshal(response, &edited) + return edited, err +} + +func (d *DiscordClient) webhookMessageDeleteThread(ctx context.Context, webhookID, token, messageID, threadID string) error { + uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) + _, err := d.Session.RequestWithBucketID("DELETE", uri, nil, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) + return err +} + +func webhookMessageThreadURI(webhookID, token, messageID, threadID string) string { + uri := discordgo.EndpointWebhookMessage(webhookID, token, messageID) + if threadID == "" { + return uri + } + v := url.Values{} + v.Set("thread_id", threadID) + return uri + "?" + v.Encode() +} + func (d *DiscordClient) isRelayWebhookMessage(portal *bridgev2.Portal, msg *database.Message) bool { if msg == nil { return false @@ -421,6 +623,10 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b emojiID = variationselector.FullyQualify(emojiID) } + // Relayed reactions have to use the relay login on Discord. Discord only + // allows one reaction per user/emoji, so multiple Matrix users reacting + // with the same emoji intentionally collapse into the relay user's one + // Discord reaction. return bridgev2.MatrixReactionPreResponse{ SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID), EmojiID: discordid.MakeEmojiID(emojiID), @@ -515,11 +721,12 @@ func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal * messageID := discordid.ParseMessageID(removal.TargetMessage.ID) if d.isRelayWebhookMessage(removal.Portal, removal.TargetMessage) { meta := removal.Portal.Metadata.(*discordid.PortalMetadata) - return d.tryWrappingError(ctx, d.Session.WebhookMessageDelete( + return d.tryWrappingError(ctx, d.webhookMessageDeleteThread( + ctx, meta.RelayWebhookID, meta.RelayWebhookToken, messageID, - discordgo.WithContext(ctx), + threadChannelID, )) } return d.tryWrappingError(ctx, d.Session.ChannelMessageDelete(channelID, messageID, makeDiscordReferer(guildID, parentChannelID, threadChannelID))) diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index c0a28cd1..e50bee96 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -120,10 +120,14 @@ func (mc *MessageConverter) ToDiscord( // // Since we only support real users at the moment, always ignore the // returned allowed mentions. - req.Content, _ = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) + var allowedMentions *discordgo.MessageAllowedMentions + req.Content, allowedMentions = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) if content.MsgType == event.MsgEmote { req.Content = fmt.Sprintf("_%s_", req.Content) } + if msg.OrigSender != nil { + req.AllowedMentions = allowedMentions + } } switch content.MsgType { @@ -155,7 +159,6 @@ func (mc *MessageConverter) ToDiscord( filename = "SPOILER_" + filename } - // TODO: Support attachments for relay/webhook. (A branch was removed here.) att := &discordgo.MessageAttachment{ ID: "0", Filename: filename, @@ -164,8 +167,6 @@ func (mc *MessageConverter) ToDiscord( att.OriginalContentType = content.Info.MimeType } if voiceMeta != nil { - flags := int(discordgo.MessageFlagsIsVoiceMessage) - req.Flags = &flags att.ContentType = voiceMeta.ContentType att.DurationSeconds = voiceMeta.DurationSeconds att.Waveform = voiceMeta.Waveform @@ -173,35 +174,47 @@ func (mc *MessageConverter) ToDiscord( // gets angry and returns 50160 "Voice messages must have a single // audio attachment". att.Filename = "voice-message" + voiceAttachmentExtension(voiceMeta.ContentType) + if msg.OrigSender == nil { + flags := int(discordgo.MessageFlagsIsVoiceMessage) + req.Flags = &flags + } + } + + if msg.OrigSender != nil { + req.Files = append(req.Files, &discordgo.File{ + Name: att.Filename, + ContentType: att.ContentType, + Reader: bytes.NewReader(mediaData), + }) + } else { + uploadID := mc.NextDiscordUploadID() + log.Debug().Str("upload_id", uploadID).Msg("Preparing attachment") + filePrep := &discordgo.FilePrepare{ + Size: len(mediaData), + Name: att.Filename, + ID: uploadID, + OriginalContentType: att.OriginalContentType, + } + prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ + Files: []*discordgo.FilePrepare{filePrep}, + }, refererOpt) + + if err != nil { + log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") + return nil, bridgev2.ErrMediaReuploadFailed + } + + prepared := prep.Attachments[0] + att.UploadedFilename = prepared.UploadFilename + + err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData) + if err != nil { + log.Err(err).Msg("Failed to reupload Discord attachment after preparing") + return nil, bridgev2.ErrMediaReuploadFailed + } + + req.Attachments = append(req.Attachments, att) } - - uploadID := mc.NextDiscordUploadID() - log.Debug().Str("upload_id", uploadID).Msg("Preparing attachment") - filePrep := &discordgo.FilePrepare{ - Size: len(mediaData), - Name: att.Filename, - ID: uploadID, - OriginalContentType: att.OriginalContentType, - } - prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ - Files: []*discordgo.FilePrepare{filePrep}, - }, refererOpt) - - if err != nil { - log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") - return nil, bridgev2.ErrMediaReuploadFailed - } - - prepared := prep.Attachments[0] - att.UploadedFilename = prepared.UploadFilename - - err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData) - if err != nil { - log.Err(err).Msg("Failed to reupload Discord attachment after preparing") - return nil, bridgev2.ErrMediaReuploadFailed - } - - req.Attachments = append(req.Attachments, att) } return &req, nil From 085492ca67b8bbcba5fc8126047425d73496cf7d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 23:34:14 -0700 Subject: [PATCH 09/15] Use stored ghost profiles for relay webhooks --- pkg/connector/handlematrix.go | 50 ++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 3c027155..1f9a4f58 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -323,8 +323,8 @@ func (d *DiscordClient) relayWebhookProfile(ctx context.Context, portal *bridgev return user.DisplayName(), user.AvatarURL("256") } } - if user := d.resolveRelayGhostByDisplayName(ctx, username); user != nil { - return user.DisplayName(), user.AvatarURL("256") + if profile := d.resolveRelayGhostByDisplayName(ctx, username); profile != nil { + return profile.username, profile.avatarURL } return username, "" } @@ -362,14 +362,25 @@ func relayWebhookUsername(sender *bridgev2.OrigSender) string { return sender.DisambiguatedName } -func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *discordgo.User { +type relayWebhookProfileMatch struct { + username string + avatarURL string +} + +type relayWebhookGhostMatch struct { + userID string + name string + avatarURL string +} + +func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *relayWebhookProfileMatch { displayName = strings.TrimSpace(displayName) if displayName == "" { return nil } rows, err := d.UserLogin.Bridge.DB.Query(ctx, ` - SELECT id, avatar_id FROM ghost + SELECT id, name, avatar_id FROM ghost WHERE bridge_id=$1 AND ( name=$2 OR name=$3 OR @@ -382,35 +393,38 @@ func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, disp } defer rows.Close() - var matchedUserIDs []string - var realAvatarUserIDs []string + var matches []relayWebhookGhostMatch + var realAvatarMatches []relayWebhookGhostMatch for rows.Next() { - var userID, avatarID string - if err = rows.Scan(&userID, &avatarID); err != nil { + var match relayWebhookGhostMatch + if err = rows.Scan(&match.userID, &match.name, &match.avatarURL); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to scan relay ghost match") return nil } - matchedUserIDs = append(matchedUserIDs, userID) - if !strings.Contains(avatarID, "cdn.discordapp.com/embed/avatars/") { - realAvatarUserIDs = append(realAvatarUserIDs, userID) + matches = append(matches, match) + if !strings.Contains(match.avatarURL, "cdn.discordapp.com/embed/avatars/") { + realAvatarMatches = append(realAvatarMatches, match) } } if err = rows.Err(); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") return nil } - var matchedUserID string + var matched relayWebhookGhostMatch switch { - case len(realAvatarUserIDs) == 1: - matchedUserID = realAvatarUserIDs[0] - case len(realAvatarUserIDs) > 1: + case len(realAvatarMatches) == 1: + matched = realAvatarMatches[0] + case len(realAvatarMatches) > 1: return nil - case len(matchedUserIDs) == 1: - matchedUserID = matchedUserIDs[0] + case len(matches) == 1: + matched = matches[0] default: return nil } - return d.userCache.Resolve(ctx, matchedUserID) + return &relayWebhookProfileMatch{ + username: matched.name, + avatarURL: matched.avatarURL, + } } var errCannotDMStranger = errors.New("can't direct message a stranger") From 1d93392d0738b1e386da425bd4f724d27051b2e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 23:54:07 -0700 Subject: [PATCH 10/15] Add relay webhook helper coverage --- pkg/connector/handlematrix.go | 61 +++++++--- pkg/connector/handlematrix_test.go | 180 +++++++++++++++++++++++++++++ pkg/msgconv/from_matrix_test.go | 60 ++++++++++ 3 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 pkg/connector/handlematrix_test.go create mode 100644 pkg/msgconv/from_matrix_test.go diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 1f9a4f58..f9344cd1 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -308,24 +308,47 @@ func isStaleWebhookError(err error) bool { } func (d *DiscordClient) relayWebhookProfile(ctx context.Context, portal *bridgev2.Portal, sender *bridgev2.OrigSender) (username, avatarURL string) { + log := zerolog.Ctx(ctx) username = relayWebhookUsername(sender) if sender.User != nil { if login, _, err := portal.FindPreferredLogin(ctx, sender.User, false); err != nil { zerolog.Ctx(ctx).Debug().Err(err).Stringer("sender_mxid", sender.UserID).Msg("No explicit Discord login for relayed sender in portal") } else if login != nil { if user := d.userCache.Resolve(ctx, discordid.ParseUserLoginID(login.ID)); user != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("login_id", string(login.ID)). + Str("webhook_username", user.DisplayName()). + Bool("has_avatar_url", user.AvatarURL("256") != ""). + Msg("Resolved relay webhook profile from explicit Discord login") return user.DisplayName(), user.AvatarURL("256") } } } if ghostID, ok := d.UserLogin.Bridge.Matrix.ParseGhostMXID(sender.UserID); ok { if user := d.userCache.Resolve(ctx, discordid.ParseUserID(ghostID)); user != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("ghost_id", string(ghostID)). + Str("webhook_username", user.DisplayName()). + Bool("has_avatar_url", user.AvatarURL("256") != ""). + Msg("Resolved relay webhook profile from ghost MXID") return user.DisplayName(), user.AvatarURL("256") } } if profile := d.resolveRelayGhostByDisplayName(ctx, username); profile != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("display_name", username). + Str("webhook_username", profile.username). + Bool("has_avatar_url", profile.avatarURL != ""). + Msg("Resolved relay webhook profile from matching Discord ghost display name") return profile.username, profile.avatarURL } + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("display_name", username). + Msg("Falling back to Matrix relay webhook profile") return username, "" } @@ -373,6 +396,25 @@ type relayWebhookGhostMatch struct { avatarURL string } +func chooseRelayWebhookGhostMatch(matches []relayWebhookGhostMatch) *relayWebhookGhostMatch { + var realAvatarMatches []relayWebhookGhostMatch + for _, match := range matches { + if !strings.Contains(match.avatarURL, "cdn.discordapp.com/embed/avatars/") { + realAvatarMatches = append(realAvatarMatches, match) + } + } + switch { + case len(realAvatarMatches) == 1: + return &realAvatarMatches[0] + case len(realAvatarMatches) > 1: + return nil + case len(matches) == 1: + return &matches[0] + default: + return nil + } +} + func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *relayWebhookProfileMatch { displayName = strings.TrimSpace(displayName) if displayName == "" { @@ -394,7 +436,6 @@ func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, disp defer rows.Close() var matches []relayWebhookGhostMatch - var realAvatarMatches []relayWebhookGhostMatch for rows.Next() { var match relayWebhookGhostMatch if err = rows.Scan(&match.userID, &match.name, &match.avatarURL); err != nil { @@ -402,23 +443,17 @@ func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, disp return nil } matches = append(matches, match) - if !strings.Contains(match.avatarURL, "cdn.discordapp.com/embed/avatars/") { - realAvatarMatches = append(realAvatarMatches, match) - } } if err = rows.Err(); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") return nil } - var matched relayWebhookGhostMatch - switch { - case len(realAvatarMatches) == 1: - matched = realAvatarMatches[0] - case len(realAvatarMatches) > 1: - return nil - case len(matches) == 1: - matched = matches[0] - default: + matched := chooseRelayWebhookGhostMatch(matches) + if matched == nil { + zerolog.Ctx(ctx).Debug(). + Str("display_name", displayName). + Int("match_count", len(matches)). + Msg("Skipping relay webhook profile match due to ambiguous Discord ghost display names") return nil } return &relayWebhookProfileMatch{ diff --git a/pkg/connector/handlematrix_test.go b/pkg/connector/handlematrix_test.go new file mode 100644 index 00000000..e0fd883a --- /dev/null +++ b/pkg/connector/handlematrix_test.go @@ -0,0 +1,180 @@ +package connector + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/bwmarrin/discordgo" +) + +func TestChooseRelayWebhookGhostMatch(t *testing.T) { + defaultAvatar := "https://cdn.discordapp.com/embed/avatars/0.png?size=256" + realAvatar := "https://cdn.discordapp.com/avatars/325703750469550084/avatar.png?size=256" + + tests := []struct { + name string + matches []relayWebhookGhostMatch + wantID string + }{ + { + name: "single default avatar match", + matches: []relayWebhookGhostMatch{{ + userID: "default", + name: "keith", + avatarURL: defaultAvatar, + }}, + wantID: "default", + }, + { + name: "one real avatar among webhook default avatar duplicates", + matches: []relayWebhookGhostMatch{ + {userID: "webhook1", name: "keith", avatarURL: defaultAvatar}, + {userID: "real", name: "keith", avatarURL: realAvatar}, + {userID: "webhook2", name: "keith", avatarURL: defaultAvatar}, + }, + wantID: "real", + }, + { + name: "ambiguous real avatars", + matches: []relayWebhookGhostMatch{ + {userID: "real1", name: "keith", avatarURL: realAvatar}, + {userID: "real2", name: "keith", avatarURL: strings.Replace(realAvatar, "avatar", "other", 1)}, + }, + }, + { + name: "ambiguous default avatars", + matches: []relayWebhookGhostMatch{ + {userID: "webhook1", name: "keith", avatarURL: defaultAvatar}, + {userID: "webhook2", name: "keith", avatarURL: defaultAvatar}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := chooseRelayWebhookGhostMatch(tc.matches) + if tc.wantID == "" { + if got != nil { + t.Fatalf("expected no match, got %#v", *got) + } + return + } + if got == nil { + t.Fatalf("expected match %q, got nil", tc.wantID) + } + if got.userID != tc.wantID { + t.Fatalf("unexpected match: got %q, want %q", got.userID, tc.wantID) + } + }) + } +} + +func TestPrependReplyEmbed(t *testing.T) { + embeds := []*discordgo.MessageEmbed{{Description: "existing"}} + got := prependReplyEmbed(embeds, "guild", "channel", "message") + if len(got) != 2 { + t.Fatalf("expected 2 embeds, got %d", len(got)) + } + if !strings.Contains(got[0].Description, "https://discord.com/channels/guild/channel/message") { + t.Fatalf("reply embed did not contain jump link: %q", got[0].Description) + } + if got[1] != embeds[0] { + t.Fatal("existing embeds were not preserved after reply embed") + } +} + +func TestPrependReplyEmbedSkipsMissingTarget(t *testing.T) { + embeds := []*discordgo.MessageEmbed{{Description: "existing"}} + got := prependReplyEmbed(embeds, "guild", "", "message") + if len(got) != 1 || got[0] != embeds[0] { + t.Fatalf("expected original embeds unchanged, got %#v", got) + } +} + +func TestRelayWebhookFlagsOnlyAllowsSuppressEmbeds(t *testing.T) { + flags := int(discordgo.MessageFlagsSuppressEmbeds | discordgo.MessageFlagsIsVoiceMessage | discordgo.MessageFlagsSuppressNotifications) + got := relayWebhookFlags(&flags) + if got != discordgo.MessageFlagsSuppressEmbeds { + t.Fatalf("unexpected flags: got %d, want %d", got, discordgo.MessageFlagsSuppressEmbeds) + } +} + +func TestSanitizeRelayWebhookUsername(t *testing.T) { + got := sanitizeRelayWebhookUsername(" Discord\nBridge ", "@user:example.com") + if strings.Contains(strings.ToLower(got), "discord") { + t.Fatalf("username still contains discord: %q", got) + } + if strings.Contains(got, "\n") { + t.Fatalf("username still contains control character: %q", got) + } + + long := strings.Repeat("a", 90) + got = sanitizeRelayWebhookUsername(long, "") + if len([]rune(got)) != 80 { + t.Fatalf("username was not truncated to 80 runes: got %d", len([]rune(got))) + } + + got = sanitizeRelayWebhookUsername(" \n\t ", "@user:example.com") + if got != "@user:example.com" { + t.Fatalf("unexpected fallback username: got %q", got) + } +} + +func TestWebhookMessageThreadURI(t *testing.T) { + got := webhookMessageThreadURI("webhook", "token", "message", "thread") + want := discordgo.EndpointWebhookMessage("webhook", "token", "message") + "?thread_id=thread" + if got != want { + t.Fatalf("unexpected URI:\n got: %s\nwant: %s", got, want) + } +} + +func TestIsStaleWebhookError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "unknown webhook code", + err: &discordgo.RESTError{ + Message: &discordgo.APIErrorMessage{Code: discordgo.ErrCodeUnknownWebhook}, + }, + want: true, + }, + { + name: "invalid webhook token code", + err: &discordgo.RESTError{ + Message: &discordgo.APIErrorMessage{Code: discordgo.ErrCodeInvalidWebhookTokenProvided}, + }, + want: true, + }, + { + name: "not found status", + err: &discordgo.RESTError{ + Response: &http.Response{StatusCode: http.StatusNotFound}, + }, + want: true, + }, + { + name: "other rest error", + err: &discordgo.RESTError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + Message: &discordgo.APIErrorMessage{Code: discordgo.ErrCodeInvalidFormBody}, + }, + }, + { + name: "plain error", + err: errors.New("nope"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isStaleWebhookError(tc.err); got != tc.want { + t.Fatalf("unexpected stale-webhook classification: got %t, want %t", got, tc.want) + } + }) + } +} diff --git a/pkg/msgconv/from_matrix_test.go b/pkg/msgconv/from_matrix_test.go new file mode 100644 index 00000000..78ec313b --- /dev/null +++ b/pkg/msgconv/from_matrix_test.go @@ -0,0 +1,60 @@ +package msgconv + +import ( + "context" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +func TestToDiscordRelayedTextKeepsRestrictiveAllowedMentions(t *testing.T) { + mc := NewMessageConverter(nil) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "@everyone <@123456789012345678>", + } + msg := &bridgev2.MatrixMessage{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Event: &event.Event{Content: event.Content{Raw: map[string]any{}}}, + Content: content, + OrigSender: &bridgev2.OrigSender{}, + }, + } + + got, err := mc.ToDiscord(context.Background(), nil, msg, "channel", nil) + if err != nil { + t.Fatalf("ToDiscord() failed: %v", err) + } + if got.AllowedMentions == nil { + t.Fatal("expected relayed text to include restrictive allowed_mentions") + } + if len(got.AllowedMentions.Parse) != 0 { + t.Fatalf("expected no mention parse types, got %#v", got.AllowedMentions.Parse) + } + if len(got.AllowedMentions.Users) != 0 { + t.Fatalf("expected no explicitly allowed users, got %#v", got.AllowedMentions.Users) + } +} + +func TestToDiscordNormalTextLeavesAllowedMentionsUnset(t *testing.T) { + mc := NewMessageConverter(nil) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "hello", + } + msg := &bridgev2.MatrixMessage{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Event: &event.Event{Content: event.Content{Raw: map[string]any{}}}, + Content: content, + }, + } + + got, err := mc.ToDiscord(context.Background(), nil, msg, "channel", nil) + if err != nil { + t.Fatalf("ToDiscord() failed: %v", err) + } + if got.AllowedMentions != nil { + t.Fatalf("expected normal puppet send to keep old nil allowed_mentions behavior, got %#v", got.AllowedMentions) + } +} From 554132ba2d9fbed02858c1272a7a43b5b01a098f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 01:33:14 -0700 Subject: [PATCH 11/15] Drop vendored mautrix-go patch --- go.mod | 2 - mautrix-patched/.editorconfig | 15 - mautrix-patched/.github/workflows/go.yml | 102 - mautrix-patched/.github/workflows/stale.yml | 29 - mautrix-patched/.gitignore | 6 - mautrix-patched/.pre-commit-config.yaml | 29 - mautrix-patched/CHANGELOG.md | 1555 ----- mautrix-patched/LICENSE | 373 -- mautrix-patched/README.md | 24 - mautrix-patched/appservice/appservice.go | 434 -- mautrix-patched/appservice/appservice_test.go | 42 - mautrix-patched/appservice/eventprocessor.go | 209 - mautrix-patched/appservice/http.go | 296 - mautrix-patched/appservice/intent.go | 562 -- mautrix-patched/appservice/ping.go | 68 - mautrix-patched/appservice/protocol.go | 103 - mautrix-patched/appservice/registration.go | 101 - mautrix-patched/appservice/txnid.go | 43 - mautrix-patched/appservice/websocket.go | 455 -- mautrix-patched/appservice/wshttp.go | 98 - mautrix-patched/beeperstream/crypto.go | 110 - mautrix-patched/beeperstream/helper.go | 462 -- mautrix-patched/beeperstream/helper_test.go | 824 --- mautrix-patched/beeperstream/publisher.go | 429 -- mautrix-patched/beeperstream/receiver.go | 172 - mautrix-patched/bridgev2/backfillqueue.go | 360 -- mautrix-patched/bridgev2/bridge.go | 470 -- .../bridgev2/bridgeconfig/appservice.go | 142 - .../bridgev2/bridgeconfig/backfill.go | 45 - .../bridgev2/bridgeconfig/config.go | 211 - .../bridgev2/bridgeconfig/encryption.go | 51 - .../bridgev2/bridgeconfig/homeserver.go | 40 - .../bridgev2/bridgeconfig/legacymigrate.go | 176 - .../bridgev2/bridgeconfig/permissions.go | 124 - .../bridgev2/bridgeconfig/relay.go | 111 - .../bridgev2/bridgeconfig/upgrade.go | 240 - mautrix-patched/bridgev2/bridgestate.go | 333 - mautrix-patched/bridgev2/commands/cleanup.go | 97 - mautrix-patched/bridgev2/commands/debug.go | 125 - mautrix-patched/bridgev2/commands/event.go | 105 - mautrix-patched/bridgev2/commands/handler.go | 118 - mautrix-patched/bridgev2/commands/help.go | 142 - .../bridgev2/commands/imagepack.go | 51 - mautrix-patched/bridgev2/commands/login.go | 667 -- .../bridgev2/commands/managechat.go | 358 -- .../bridgev2/commands/processor.go | 208 - mautrix-patched/bridgev2/commands/relay.go | 170 - .../bridgev2/commands/startchat.go | 366 -- mautrix-patched/bridgev2/commands/sudo.go | 108 - .../bridgev2/database/backfillqueue.go | 192 - mautrix-patched/bridgev2/database/database.go | 154 - .../bridgev2/database/disappear.go | 142 - mautrix-patched/bridgev2/database/ghost.go | 175 - mautrix-patched/bridgev2/database/kvstore.go | 59 - mautrix-patched/bridgev2/database/message.go | 334 - mautrix-patched/bridgev2/database/portal.go | 296 - .../bridgev2/database/publicmedia.go | 72 - mautrix-patched/bridgev2/database/reaction.go | 120 - .../bridgev2/database/upgrades/00-latest.sql | 234 - .../upgrades/02-disappearing-messages.sql | 11 - .../upgrades/03-portal-relay-postgres.sql | 13 - .../upgrades/04-portal-relay-sqlite.sql | 100 - .../05-message-receiver-pkey-postgres.sql | 10 - .../06-message-receiver-pkey-sqlite.sql | 75 - .../07-message-relation-without-fkey.sql | 4 - .../08-drop-message-relates-to.postgres.sql | 3 - .../08-drop-message-relates-to.sqlite.sql | 41 - .../upgrades/09-remove-standard-metadata.sql | 45 - ...10-fix-signal-portal-revision.postgres.sql | 4 - .../10-fix-signal-portal-revision.sqlite.sql | 4 - .../database/upgrades/11-room-fkey-idx.sql | 5 - .../upgrades/12-dm-portal-other-user.sql | 2 - .../database/upgrades/13-backfill-queue.sql | 20 - .../upgrades/14-portal-name-custom.sql | 2 - .../upgrades/15-reaction-sender-mxid.sql | 2 - .../upgrades/16-user-login-profile.sql | 2 - .../upgrades/17-message-mxid-unique.sql | 8 - .../database/upgrades/18-kv-store.sql | 8 - .../19-add-double-puppeted-to-message.sql | 2 - .../upgrades/20-portal-capabilities.sql | 2 - .../21-disappearing-message-fkey.postgres.sql | 8 - .../21-disappearing-message-fkey.sqlite.sql | 24 - .../upgrades/22-message-send-txn-id.sql | 6 - .../upgrades/23-disappearing-timer-ts.sql | 2 - .../database/upgrades/24-public-media.sql | 11 - .../database/upgrades/25-message-requests.sql | 2 - .../26-disappearing-message-portal-index.sql | 3 - .../upgrades/27-ghost-extra-profile.sql | 2 - .../upgrades/28-backfill-queue-done-flag.sql | 3 - ...ear-incorrect-personal-filtering-space.sql | 2 - .../bridgev2/database/upgrades/upgrades.go | 22 - mautrix-patched/bridgev2/database/user.go | 74 - .../bridgev2/database/userlogin.go | 123 - .../bridgev2/database/userportal.go | 155 - mautrix-patched/bridgev2/disappear.go | 153 - mautrix-patched/bridgev2/errors.go | 137 - mautrix-patched/bridgev2/ghost.go | 405 -- mautrix-patched/bridgev2/login.go | 318 - mautrix-patched/bridgev2/matrix/analytics.go | 62 - mautrix-patched/bridgev2/matrix/cmdadmin.go | 79 - .../bridgev2/matrix/cmddoublepuppet.go | 90 - mautrix-patched/bridgev2/matrix/connector.go | 786 --- mautrix-patched/bridgev2/matrix/crypto.go | 607 -- .../bridgev2/matrix/cryptoerror.go | 94 - .../bridgev2/matrix/cryptostore.go | 63 - .../bridgev2/matrix/directmedia.go | 107 - .../bridgev2/matrix/doublepuppet.go | 131 - mautrix-patched/bridgev2/matrix/exiterror.go | 24 - mautrix-patched/bridgev2/matrix/intent.go | 791 --- mautrix-patched/bridgev2/matrix/matrix.go | 251 - .../bridgev2/matrix/mxmain/config.go | 36 - .../bridgev2/matrix/mxmain/dberror.go | 79 - .../bridgev2/matrix/mxmain/envconfig.go | 191 - .../matrix/mxmain/example-config.yaml | 513 -- .../bridgev2/matrix/mxmain/legacymigrate.go | 270 - .../bridgev2/matrix/mxmain/main.go | 454 -- .../bridgev2/matrix/mxmain/main_test.go | 40 - mautrix-patched/bridgev2/matrix/no-crypto.go | 26 - .../bridgev2/matrix/provisioning.go | 704 --- .../bridgev2/matrix/provisioninglogin.go | 295 - .../bridgev2/matrix/publicmedia.go | 278 - mautrix-patched/bridgev2/matrix/websocket.go | 169 - mautrix-patched/bridgev2/matrixinterface.go | 243 - mautrix-patched/bridgev2/matrixinvite.go | 294 - mautrix-patched/bridgev2/messagestatus.go | 243 - .../bridgev2/networkid/bridgeid.go | 146 - mautrix-patched/bridgev2/networkinterface.go | 1501 ----- mautrix-patched/bridgev2/portal.go | 5557 ----------------- mautrix-patched/bridgev2/portalbackfill.go | 646 -- mautrix-patched/bridgev2/portalinternal.go | 418 -- .../bridgev2/portalinternal_generate.go | 173 - mautrix-patched/bridgev2/portalreid.go | 161 - .../bridgev2/provisionutil/creategroup.go | 161 - .../bridgev2/provisionutil/imagepack.go | 217 - .../bridgev2/provisionutil/listcontacts.go | 98 - .../provisionutil/resolveidentifier.go | 125 - mautrix-patched/bridgev2/queue.go | 275 - mautrix-patched/bridgev2/simpleremoteevent.go | 133 - mautrix-patched/bridgev2/simplevent/chat.go | 108 - .../bridgev2/simplevent/message.go | 121 - mautrix-patched/bridgev2/simplevent/meta.go | 163 - .../bridgev2/simplevent/reaction.go | 67 - .../bridgev2/simplevent/receipt.go | 77 - mautrix-patched/bridgev2/space.go | 211 - .../bridgev2/status/bridgestate.go | 216 - .../bridgev2/status/localbridgestate.go | 23 - .../bridgev2/status/messagecheckpoint.go | 212 - .../bridgev2/unorganized-docs/FEATURES.md | 49 - .../bridgev2/unorganized-docs/README.md | 66 - .../incoming-matrix-message.uml | 23 - .../incoming-remote-message.uml | 22 - .../unorganized-docs/login-step.schema.json | 155 - .../bridgev2/unorganized-docs/login-steps.uml | 43 - mautrix-patched/bridgev2/user.go | 275 - mautrix-patched/bridgev2/userlogin.go | 577 -- mautrix-patched/client.go | 2999 --------- mautrix-patched/client_retry_test.go | 491 -- mautrix-patched/commands/container.go | 133 - mautrix-patched/commands/event.go | 237 - mautrix-patched/commands/handler.go | 105 - mautrix-patched/commands/prevalidate.go | 84 - mautrix-patched/commands/processor.go | 152 - mautrix-patched/commands/reactions.go | 143 - mautrix-patched/crypto/account.go | 128 - mautrix-patched/crypto/aescbc/aes_cbc.go | 59 - mautrix-patched/crypto/aescbc/aes_cbc_test.go | 63 - mautrix-patched/crypto/aescbc/errors.go | 15 - .../crypto/attachment/attachments.go | 317 - .../crypto/attachment/attachments_test.go | 85 - .../crypto/backup/encryptedsessiondata.go | 134 - .../backup/encryptedsessiondata_test.go | 108 - mautrix-patched/crypto/backup/ephemeralkey.go | 41 - .../crypto/backup/ephemeralkey_test.go | 57 - mautrix-patched/crypto/backup/megolmbackup.go | 39 - .../crypto/backup/megolmbackupkey.go | 34 - .../crypto/canonicaljson/README.md | 6 - mautrix-patched/crypto/canonicaljson/json.go | 281 - .../crypto/canonicaljson/json_test.go | 110 - .../crypto/canonicaljson/jsonv2.go | 244 - .../crypto/canonicaljson/jsonv2_test.go | 364 -- mautrix-patched/crypto/cross_sign_key.go | 161 - mautrix-patched/crypto/cross_sign_pubkey.go | 138 - mautrix-patched/crypto/cross_sign_signing.go | 204 - mautrix-patched/crypto/cross_sign_ssss.go | 176 - mautrix-patched/crypto/cross_sign_store.go | 110 - mautrix-patched/crypto/cross_sign_test.go | 188 - .../crypto/cross_sign_validation.go | 158 - .../crypto/cryptohelper/cryptohelper.go | 440 -- mautrix-patched/crypto/decryptmegolm.go | 353 -- mautrix-patched/crypto/decryptolm.go | 438 -- mautrix-patched/crypto/devicelist.go | 386 -- mautrix-patched/crypto/ed25519/ed25519.go | 302 - .../crypto/ed25519/ed25519_test.go | 20 - mautrix-patched/crypto/encryptmegolm.go | 463 -- mautrix-patched/crypto/encryptolm.go | 202 - mautrix-patched/crypto/goolm/README.md | 5 - .../crypto/goolm/account/account.go | 423 -- .../crypto/goolm/account/account_data_test.go | 267 - .../crypto/goolm/account/account_test.go | 366 -- .../crypto/goolm/account/register.go | 23 - .../crypto/goolm/aessha2/aessha2.go | 63 - .../crypto/goolm/aessha2/aessha2_test.go | 36 - .../crypto/goolm/crypto/curve25519.go | 127 - .../crypto/goolm/crypto/curve25519_test.go | 125 - mautrix-patched/crypto/goolm/crypto/doc.go | 2 - .../crypto/goolm/crypto/ed25519.go | 164 - .../crypto/goolm/crypto/ed25519_test.go | 89 - .../crypto/goolm/crypto/one_time_key.go | 46 - .../crypto/goolm/goolmbase64/base64.go | 22 - .../crypto/goolm/libolmpickle/encoder.go | 40 - .../crypto/goolm/libolmpickle/encoder_test.go | 99 - .../crypto/goolm/libolmpickle/pickle.go | 53 - .../crypto/goolm/libolmpickle/pickle_test.go | 26 - .../crypto/goolm/libolmpickle/unpickle.go | 58 - .../goolm/libolmpickle/unpickle_test.go | 83 - mautrix-patched/crypto/goolm/main.go | 6 - mautrix-patched/crypto/goolm/megolm/megolm.go | 209 - .../crypto/goolm/megolm/megolm_test.go | 105 - .../crypto/goolm/message/decoder.go | 33 - .../crypto/goolm/message/encoder.go | 24 - .../crypto/goolm/message/encoder_test.go | 59 - .../crypto/goolm/message/group_message.go | 109 - .../goolm/message/group_message_test.go | 62 - .../crypto/goolm/message/message.go | 103 - .../crypto/goolm/message/message_test.go | 42 - .../crypto/goolm/message/prekey_message.go | 114 - .../goolm/message/prekey_message_test.go | 43 - .../crypto/goolm/message/session_export.go | 47 - .../crypto/goolm/message/session_sharing.go | 50 - mautrix-patched/crypto/goolm/pk/pk_test.go | 54 - mautrix-patched/crypto/goolm/pk/register.go | 18 - mautrix-patched/crypto/goolm/pk/signing.go | 71 - mautrix-patched/crypto/goolm/ratchet/chain.go | 178 - mautrix-patched/crypto/goolm/ratchet/olm.go | 393 -- .../crypto/goolm/ratchet/olm_test.go | 126 - .../crypto/goolm/ratchet/skipped_message.go | 27 - mautrix-patched/crypto/goolm/register.go | 29 - mautrix-patched/crypto/goolm/session/doc.go | 3 - .../goolm/session/megolm_inbound_session.go | 250 - .../goolm/session/megolm_outbound_session.go | 135 - .../goolm/session/megolm_session_test.go | 186 - .../crypto/goolm/session/olm_session.go | 404 -- .../crypto/goolm/session/olm_session_test.go | 123 - .../crypto/goolm/session/register.go | 61 - mautrix-patched/crypto/keybackup.go | 242 - mautrix-patched/crypto/keyexport.go | 204 - mautrix-patched/crypto/keyexport_test.go | 35 - mautrix-patched/crypto/keyimport.go | 167 - mautrix-patched/crypto/keysharing.go | 423 -- mautrix-patched/crypto/libolm/account.go | 419 -- mautrix-patched/crypto/libolm/error.go | 37 - .../crypto/libolm/inboundgroupsession.go | 327 - mautrix-patched/crypto/libolm/libolm.go | 10 - .../crypto/libolm/outboundgroupsession.go | 245 - mautrix-patched/crypto/libolm/pk.go | 149 - mautrix-patched/crypto/libolm/register.go | 72 - mautrix-patched/crypto/libolm/session.go | 401 -- mautrix-patched/crypto/machine.go | 839 --- mautrix-patched/crypto/machine_bench_test.go | 67 - mautrix-patched/crypto/machine_test.go | 151 - mautrix-patched/crypto/olm/README.md | 4 - mautrix-patched/crypto/olm/account.go | 116 - mautrix-patched/crypto/olm/account_test.go | 124 - mautrix-patched/crypto/olm/errors.go | 76 - .../crypto/olm/groupsession_test.go | 50 - .../crypto/olm/inboundgroupsession.go | 80 - mautrix-patched/crypto/olm/olm.go | 20 - .../crypto/olm/outboundgroupsession.go | 57 - .../crypto/olm/outboundgroupsession_test.go | 135 - mautrix-patched/crypto/olm/pk.go | 41 - mautrix-patched/crypto/olm/pk_test.go | 46 - mautrix-patched/crypto/olm/session.go | 83 - mautrix-patched/crypto/olm/session_test.go | 143 - mautrix-patched/crypto/registergoolm.go | 11 - mautrix-patched/crypto/registerlibolm.go | 9 - mautrix-patched/crypto/sessions.go | 290 - mautrix-patched/crypto/sharing.go | 190 - .../crypto/signatures/signatures.go | 101 - mautrix-patched/crypto/sql_store.go | 983 --- .../sql_store_upgrade/00-latest-revision.sql | 126 - .../04-cross-signing-keys.sql | 16 - .../sql_store_upgrade/05-varchar-to-text.sql | 31 - .../06-olm-session-last-used-split.sql | 6 - .../07-trust-state-value-change.sql | 4 - .../08-cs-key-expired-field.sql | 5 - .../sql_store_upgrade/09-max-age-ms.sql | 2 - .../10-mark-ratchetable-keys.sql | 6 - .../sql_store_upgrade/11-outdated-devices.sql | 2 - .../crypto/sql_store_upgrade/12-secrets.sql | 5 - .../13-megolm-session-sharing.sql | 9 - .../14-account-key-backup-version.sql | 4 - .../sql_store_upgrade/15-fix-secrets.sql | 21 - .../16-crypto-olm-sessions-index.sql | 2 - .../17-decrypted-olm-messages.sql | 11 - ...18-megolm-inbound-session-backup-index.sql | 2 - .../19-megolm-session-source.sql | 2 - .../sql_store_upgrade/20-message-key-index.go | 55 - .../crypto/sql_store_upgrade/upgrade.go | 29 - mautrix-patched/crypto/ssss/client.go | 125 - mautrix-patched/crypto/ssss/key.go | 131 - mautrix-patched/crypto/ssss/key_test.go | 89 - mautrix-patched/crypto/ssss/meta.go | 158 - mautrix-patched/crypto/ssss/meta_test.go | 174 - mautrix-patched/crypto/ssss/types.go | 81 - mautrix-patched/crypto/store.go | 755 --- mautrix-patched/crypto/store_test.go | 299 - mautrix-patched/crypto/utils/utils.go | 132 - mautrix-patched/crypto/utils/utils_test.go | 79 - .../verificationhelper/callbacks_test.go | 167 - .../crypto/verificationhelper/doc.go | 11 - .../crypto/verificationhelper/ecdhkeys.go | 57 - .../verificationhelper/ecdhkeys_test.go | 48 - .../crypto/verificationhelper/qrcode.go | 121 - .../crypto/verificationhelper/qrcode_test.go | 90 - .../crypto/verificationhelper/reciprocate.go | 353 -- .../crypto/verificationhelper/sas.go | 821 --- .../crypto/verificationhelper/sas_test.go | 28 - .../verificationhelper/verificationhelper.go | 932 --- .../verificationhelper_qr_crosssign_test.go | 153 - .../verificationhelper_qr_self_test.go | 370 -- .../verificationhelper_sas_test.go | 360 -- .../verificationhelper_test.go | 517 -- .../verificationhelper/verificationstore.go | 159 - .../verificationstore_test.go | 85 - mautrix-patched/error.go | 258 - mautrix-patched/event/accountdata.go | 119 - mautrix-patched/event/audio.go | 21 - mautrix-patched/event/beeper.go | 383 -- mautrix-patched/event/capabilities.d.ts | 231 - mautrix-patched/event/capabilities.go | 416 -- mautrix-patched/event/cmdschema/content.go | 78 - mautrix-patched/event/cmdschema/parameter.go | 286 - mautrix-patched/event/cmdschema/parse.go | 478 -- mautrix-patched/event/cmdschema/parse_test.go | 118 - mautrix-patched/event/cmdschema/roomid.go | 135 - mautrix-patched/event/cmdschema/stringify.go | 122 - .../cmdschema/testdata/commands.schema.json | 281 - .../cmdschema/testdata/commands/flags.json | 126 - .../testdata/commands/room_id_or_alias.json | 85 - .../commands/room_reference_list.json | 106 - .../cmdschema/testdata/commands/simple.json | 46 - .../cmdschema/testdata/commands/tail.json | 60 - .../event/cmdschema/testdata/data.go | 14 - .../event/cmdschema/testdata/parse_quote.json | 30 - .../testdata/parse_quote.schema.json | 46 - mautrix-patched/event/content.go | 613 -- mautrix-patched/event/delayed.go | 70 - mautrix-patched/event/encryption.go | 211 - mautrix-patched/event/ephemeral.go | 140 - mautrix-patched/event/events.go | 188 - mautrix-patched/event/eventsource.go | 73 - mautrix-patched/event/imagepack.go | 65 - mautrix-patched/event/member.go | 69 - mautrix-patched/event/message.go | 501 -- mautrix-patched/event/message_test.go | 187 - mautrix-patched/event/poll.go | 64 - mautrix-patched/event/powerlevels.go | 235 - mautrix-patched/event/relations.go | 240 - mautrix-patched/event/reply.go | 74 - mautrix-patched/event/state.go | 357 -- mautrix-patched/event/type.go | 312 - mautrix-patched/event/verification.go | 308 - mautrix-patched/event/voip.go | 116 - mautrix-patched/event/voip_test.go | 175 - mautrix-patched/example/main.go | 155 - mautrix-patched/federation/cache.go | 153 - mautrix-patched/federation/client.go | 629 -- mautrix-patched/federation/client_test.go | 25 - mautrix-patched/federation/context.go | 42 - .../federation/eventauth/eventauth.go | 851 --- .../eventauth/eventauth_internal_test.go | 66 - .../federation/eventauth/eventauth_test.go | 85 - .../eventauth/testroom-v12-success.jsonl | 21 - mautrix-patched/federation/httpclient.go | 116 - mautrix-patched/federation/keyserver.go | 205 - mautrix-patched/federation/media.go | 125 - mautrix-patched/federation/pdu/auth.go | 71 - mautrix-patched/federation/pdu/hash.go | 119 - mautrix-patched/federation/pdu/hash_test.go | 55 - mautrix-patched/federation/pdu/pdu.go | 141 - mautrix-patched/federation/pdu/pdu_test.go | 193 - mautrix-patched/federation/pdu/redact.go | 111 - mautrix-patched/federation/pdu/signature.go | 67 - .../federation/pdu/signature_test.go | 102 - mautrix-patched/federation/pdu/v1.go | 278 - mautrix-patched/federation/pdu/v1_test.go | 86 - mautrix-patched/federation/resolution.go | 198 - mautrix-patched/federation/resolution_test.go | 115 - mautrix-patched/federation/serverauth.go | 264 - mautrix-patched/federation/serverauth_test.go | 42 - mautrix-patched/federation/servername.go | 95 - mautrix-patched/federation/servername_test.go | 64 - mautrix-patched/federation/signingkey.go | 168 - mautrix-patched/federation/signutil/verify.go | 120 - mautrix-patched/filter.go | 95 - mautrix-patched/format/doc.go | 5 - mautrix-patched/format/htmlparser.go | 520 -- mautrix-patched/format/markdown.go | 145 - mautrix-patched/format/markdown_test.go | 213 - mautrix-patched/format/mdext/customemoji.go | 73 - .../format/mdext/discordunderline.go | 137 - .../format/mdext/filteredparser.go | 48 - .../format/mdext/indentableparagraph.go | 28 - mautrix-patched/format/mdext/math.go | 240 - mautrix-patched/format/mdext/nohtml.go | 61 - mautrix-patched/format/mdext/shortemphasis.go | 96 - mautrix-patched/format/mdext/shortstrike.go | 76 - mautrix-patched/format/mdext/simplespoiler.go | 62 - mautrix-patched/format/mdext/spoiler.go | 168 - mautrix-patched/go.mod | 42 - mautrix-patched/go.sum | 74 - mautrix-patched/id/contenturi.go | 183 - mautrix-patched/id/crypto.go | 232 - mautrix-patched/id/matrixuri.go | 309 - mautrix-patched/id/matrixuri_test.go | 163 - mautrix-patched/id/opaque.go | 101 - mautrix-patched/id/roomversion.go | 265 - mautrix-patched/id/servername.go | 58 - mautrix-patched/id/trust.go | 97 - mautrix-patched/id/userid.go | 254 - mautrix-patched/id/userid_test.go | 86 - mautrix-patched/mediaproxy/mediaproxy.go | 534 -- mautrix-patched/mockserver/mockserver.go | 307 - mautrix-patched/pushrules/action.go | 123 - mautrix-patched/pushrules/action_test.go | 180 - mautrix-patched/pushrules/condition.go | 359 -- .../pushrules/condition_displayname_test.go | 43 - .../pushrules/condition_eventmatch_test.go | 91 - .../condition_eventpropertyis_test.go | 91 - .../pushrules/condition_membercount_test.go | 61 - mautrix-patched/pushrules/condition_test.go | 159 - mautrix-patched/pushrules/doc.go | 2 - .../pushrules/pushgateway/gateway.go | 195 - mautrix-patched/pushrules/pushrules.go | 35 - mautrix-patched/pushrules/pushrules_test.go | 239 - mautrix-patched/pushrules/rule.go | 181 - mautrix-patched/pushrules/rule_array_test.go | 285 - mautrix-patched/pushrules/rule_test.go | 312 - mautrix-patched/pushrules/ruleset.go | 100 - mautrix-patched/requests.go | 711 --- mautrix-patched/responses.go | 810 --- mautrix-patched/responses_test.go | 111 - mautrix-patched/room.go | 52 - mautrix-patched/sqlstatestore/statestore.go | 495 -- .../sqlstatestore/v00-latest-revision.sql | 32 - .../sqlstatestore/v02-membership-enum.sql | 6 - mautrix-patched/sqlstatestore/v03-no-null.sql | 10 - .../sqlstatestore/v04-encryption-info.sql | 2 - .../v05-mark-encryption-state-resync.go | 30 - .../v06-displayname-disambiguation.go | 55 - .../sqlstatestore/v07-full-member-flag.sql | 2 - .../sqlstatestore/v08-create-event.sql | 2 - .../v09-clear-empty-room-ids.sql | 3 - .../sqlstatestore/v10-join-rules.sql | 2 - mautrix-patched/statestore.go | 392 -- mautrix-patched/synapseadmin/client.go | 22 - mautrix-patched/synapseadmin/register.go | 101 - mautrix-patched/synapseadmin/roomapi.go | 250 - mautrix-patched/synapseadmin/userapi.go | 214 - mautrix-patched/sync.go | 287 - mautrix-patched/syncstore.go | 175 - mautrix-patched/url.go | 127 - mautrix-patched/url_test.go | 65 - mautrix-patched/version.go | 41 - mautrix-patched/versions.go | 216 - mautrix-patched/versions_test.go | 102 - 466 files changed, 85862 deletions(-) delete mode 100644 mautrix-patched/.editorconfig delete mode 100644 mautrix-patched/.github/workflows/go.yml delete mode 100644 mautrix-patched/.github/workflows/stale.yml delete mode 100644 mautrix-patched/.gitignore delete mode 100644 mautrix-patched/.pre-commit-config.yaml delete mode 100644 mautrix-patched/CHANGELOG.md delete mode 100644 mautrix-patched/LICENSE delete mode 100644 mautrix-patched/README.md delete mode 100644 mautrix-patched/appservice/appservice.go delete mode 100644 mautrix-patched/appservice/appservice_test.go delete mode 100644 mautrix-patched/appservice/eventprocessor.go delete mode 100644 mautrix-patched/appservice/http.go delete mode 100644 mautrix-patched/appservice/intent.go delete mode 100644 mautrix-patched/appservice/ping.go delete mode 100644 mautrix-patched/appservice/protocol.go delete mode 100644 mautrix-patched/appservice/registration.go delete mode 100644 mautrix-patched/appservice/txnid.go delete mode 100644 mautrix-patched/appservice/websocket.go delete mode 100644 mautrix-patched/appservice/wshttp.go delete mode 100644 mautrix-patched/beeperstream/crypto.go delete mode 100644 mautrix-patched/beeperstream/helper.go delete mode 100644 mautrix-patched/beeperstream/helper_test.go delete mode 100644 mautrix-patched/beeperstream/publisher.go delete mode 100644 mautrix-patched/beeperstream/receiver.go delete mode 100644 mautrix-patched/bridgev2/backfillqueue.go delete mode 100644 mautrix-patched/bridgev2/bridge.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/appservice.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/backfill.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/config.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/encryption.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/homeserver.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/permissions.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/relay.go delete mode 100644 mautrix-patched/bridgev2/bridgeconfig/upgrade.go delete mode 100644 mautrix-patched/bridgev2/bridgestate.go delete mode 100644 mautrix-patched/bridgev2/commands/cleanup.go delete mode 100644 mautrix-patched/bridgev2/commands/debug.go delete mode 100644 mautrix-patched/bridgev2/commands/event.go delete mode 100644 mautrix-patched/bridgev2/commands/handler.go delete mode 100644 mautrix-patched/bridgev2/commands/help.go delete mode 100644 mautrix-patched/bridgev2/commands/imagepack.go delete mode 100644 mautrix-patched/bridgev2/commands/login.go delete mode 100644 mautrix-patched/bridgev2/commands/managechat.go delete mode 100644 mautrix-patched/bridgev2/commands/processor.go delete mode 100644 mautrix-patched/bridgev2/commands/relay.go delete mode 100644 mautrix-patched/bridgev2/commands/startchat.go delete mode 100644 mautrix-patched/bridgev2/commands/sudo.go delete mode 100644 mautrix-patched/bridgev2/database/backfillqueue.go delete mode 100644 mautrix-patched/bridgev2/database/database.go delete mode 100644 mautrix-patched/bridgev2/database/disappear.go delete mode 100644 mautrix-patched/bridgev2/database/ghost.go delete mode 100644 mautrix-patched/bridgev2/database/kvstore.go delete mode 100644 mautrix-patched/bridgev2/database/message.go delete mode 100644 mautrix-patched/bridgev2/database/portal.go delete mode 100644 mautrix-patched/bridgev2/database/publicmedia.go delete mode 100644 mautrix-patched/bridgev2/database/reaction.go delete mode 100644 mautrix-patched/bridgev2/database/upgrades/00-latest.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/24-public-media.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql delete mode 100644 mautrix-patched/bridgev2/database/upgrades/upgrades.go delete mode 100644 mautrix-patched/bridgev2/database/user.go delete mode 100644 mautrix-patched/bridgev2/database/userlogin.go delete mode 100644 mautrix-patched/bridgev2/database/userportal.go delete mode 100644 mautrix-patched/bridgev2/disappear.go delete mode 100644 mautrix-patched/bridgev2/errors.go delete mode 100644 mautrix-patched/bridgev2/ghost.go delete mode 100644 mautrix-patched/bridgev2/login.go delete mode 100644 mautrix-patched/bridgev2/matrix/analytics.go delete mode 100644 mautrix-patched/bridgev2/matrix/cmdadmin.go delete mode 100644 mautrix-patched/bridgev2/matrix/cmddoublepuppet.go delete mode 100644 mautrix-patched/bridgev2/matrix/connector.go delete mode 100644 mautrix-patched/bridgev2/matrix/crypto.go delete mode 100644 mautrix-patched/bridgev2/matrix/cryptoerror.go delete mode 100644 mautrix-patched/bridgev2/matrix/cryptostore.go delete mode 100644 mautrix-patched/bridgev2/matrix/directmedia.go delete mode 100644 mautrix-patched/bridgev2/matrix/doublepuppet.go delete mode 100644 mautrix-patched/bridgev2/matrix/exiterror.go delete mode 100644 mautrix-patched/bridgev2/matrix/intent.go delete mode 100644 mautrix-patched/bridgev2/matrix/matrix.go delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/config.go delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/dberror.go delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/envconfig.go delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/main.go delete mode 100644 mautrix-patched/bridgev2/matrix/mxmain/main_test.go delete mode 100644 mautrix-patched/bridgev2/matrix/no-crypto.go delete mode 100644 mautrix-patched/bridgev2/matrix/provisioning.go delete mode 100644 mautrix-patched/bridgev2/matrix/provisioninglogin.go delete mode 100644 mautrix-patched/bridgev2/matrix/publicmedia.go delete mode 100644 mautrix-patched/bridgev2/matrix/websocket.go delete mode 100644 mautrix-patched/bridgev2/matrixinterface.go delete mode 100644 mautrix-patched/bridgev2/matrixinvite.go delete mode 100644 mautrix-patched/bridgev2/messagestatus.go delete mode 100644 mautrix-patched/bridgev2/networkid/bridgeid.go delete mode 100644 mautrix-patched/bridgev2/networkinterface.go delete mode 100644 mautrix-patched/bridgev2/portal.go delete mode 100644 mautrix-patched/bridgev2/portalbackfill.go delete mode 100644 mautrix-patched/bridgev2/portalinternal.go delete mode 100644 mautrix-patched/bridgev2/portalinternal_generate.go delete mode 100644 mautrix-patched/bridgev2/portalreid.go delete mode 100644 mautrix-patched/bridgev2/provisionutil/creategroup.go delete mode 100644 mautrix-patched/bridgev2/provisionutil/imagepack.go delete mode 100644 mautrix-patched/bridgev2/provisionutil/listcontacts.go delete mode 100644 mautrix-patched/bridgev2/provisionutil/resolveidentifier.go delete mode 100644 mautrix-patched/bridgev2/queue.go delete mode 100644 mautrix-patched/bridgev2/simpleremoteevent.go delete mode 100644 mautrix-patched/bridgev2/simplevent/chat.go delete mode 100644 mautrix-patched/bridgev2/simplevent/message.go delete mode 100644 mautrix-patched/bridgev2/simplevent/meta.go delete mode 100644 mautrix-patched/bridgev2/simplevent/reaction.go delete mode 100644 mautrix-patched/bridgev2/simplevent/receipt.go delete mode 100644 mautrix-patched/bridgev2/space.go delete mode 100644 mautrix-patched/bridgev2/status/bridgestate.go delete mode 100644 mautrix-patched/bridgev2/status/localbridgestate.go delete mode 100644 mautrix-patched/bridgev2/status/messagecheckpoint.go delete mode 100644 mautrix-patched/bridgev2/unorganized-docs/FEATURES.md delete mode 100644 mautrix-patched/bridgev2/unorganized-docs/README.md delete mode 100644 mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml delete mode 100644 mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml delete mode 100644 mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json delete mode 100644 mautrix-patched/bridgev2/unorganized-docs/login-steps.uml delete mode 100644 mautrix-patched/bridgev2/user.go delete mode 100644 mautrix-patched/bridgev2/userlogin.go delete mode 100644 mautrix-patched/client.go delete mode 100644 mautrix-patched/client_retry_test.go delete mode 100644 mautrix-patched/commands/container.go delete mode 100644 mautrix-patched/commands/event.go delete mode 100644 mautrix-patched/commands/handler.go delete mode 100644 mautrix-patched/commands/prevalidate.go delete mode 100644 mautrix-patched/commands/processor.go delete mode 100644 mautrix-patched/commands/reactions.go delete mode 100644 mautrix-patched/crypto/account.go delete mode 100644 mautrix-patched/crypto/aescbc/aes_cbc.go delete mode 100644 mautrix-patched/crypto/aescbc/aes_cbc_test.go delete mode 100644 mautrix-patched/crypto/aescbc/errors.go delete mode 100644 mautrix-patched/crypto/attachment/attachments.go delete mode 100644 mautrix-patched/crypto/attachment/attachments_test.go delete mode 100644 mautrix-patched/crypto/backup/encryptedsessiondata.go delete mode 100644 mautrix-patched/crypto/backup/encryptedsessiondata_test.go delete mode 100644 mautrix-patched/crypto/backup/ephemeralkey.go delete mode 100644 mautrix-patched/crypto/backup/ephemeralkey_test.go delete mode 100644 mautrix-patched/crypto/backup/megolmbackup.go delete mode 100644 mautrix-patched/crypto/backup/megolmbackupkey.go delete mode 100644 mautrix-patched/crypto/canonicaljson/README.md delete mode 100644 mautrix-patched/crypto/canonicaljson/json.go delete mode 100644 mautrix-patched/crypto/canonicaljson/json_test.go delete mode 100644 mautrix-patched/crypto/canonicaljson/jsonv2.go delete mode 100644 mautrix-patched/crypto/canonicaljson/jsonv2_test.go delete mode 100644 mautrix-patched/crypto/cross_sign_key.go delete mode 100644 mautrix-patched/crypto/cross_sign_pubkey.go delete mode 100644 mautrix-patched/crypto/cross_sign_signing.go delete mode 100644 mautrix-patched/crypto/cross_sign_ssss.go delete mode 100644 mautrix-patched/crypto/cross_sign_store.go delete mode 100644 mautrix-patched/crypto/cross_sign_test.go delete mode 100644 mautrix-patched/crypto/cross_sign_validation.go delete mode 100644 mautrix-patched/crypto/cryptohelper/cryptohelper.go delete mode 100644 mautrix-patched/crypto/decryptmegolm.go delete mode 100644 mautrix-patched/crypto/decryptolm.go delete mode 100644 mautrix-patched/crypto/devicelist.go delete mode 100644 mautrix-patched/crypto/ed25519/ed25519.go delete mode 100644 mautrix-patched/crypto/ed25519/ed25519_test.go delete mode 100644 mautrix-patched/crypto/encryptmegolm.go delete mode 100644 mautrix-patched/crypto/encryptolm.go delete mode 100644 mautrix-patched/crypto/goolm/README.md delete mode 100644 mautrix-patched/crypto/goolm/account/account.go delete mode 100644 mautrix-patched/crypto/goolm/account/account_data_test.go delete mode 100644 mautrix-patched/crypto/goolm/account/account_test.go delete mode 100644 mautrix-patched/crypto/goolm/account/register.go delete mode 100644 mautrix-patched/crypto/goolm/aessha2/aessha2.go delete mode 100644 mautrix-patched/crypto/goolm/aessha2/aessha2_test.go delete mode 100644 mautrix-patched/crypto/goolm/crypto/curve25519.go delete mode 100644 mautrix-patched/crypto/goolm/crypto/curve25519_test.go delete mode 100644 mautrix-patched/crypto/goolm/crypto/doc.go delete mode 100644 mautrix-patched/crypto/goolm/crypto/ed25519.go delete mode 100644 mautrix-patched/crypto/goolm/crypto/ed25519_test.go delete mode 100644 mautrix-patched/crypto/goolm/crypto/one_time_key.go delete mode 100644 mautrix-patched/crypto/goolm/goolmbase64/base64.go delete mode 100644 mautrix-patched/crypto/goolm/libolmpickle/encoder.go delete mode 100644 mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go delete mode 100644 mautrix-patched/crypto/goolm/libolmpickle/pickle.go delete mode 100644 mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go delete mode 100644 mautrix-patched/crypto/goolm/libolmpickle/unpickle.go delete mode 100644 mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go delete mode 100644 mautrix-patched/crypto/goolm/main.go delete mode 100644 mautrix-patched/crypto/goolm/megolm/megolm.go delete mode 100644 mautrix-patched/crypto/goolm/megolm/megolm_test.go delete mode 100644 mautrix-patched/crypto/goolm/message/decoder.go delete mode 100644 mautrix-patched/crypto/goolm/message/encoder.go delete mode 100644 mautrix-patched/crypto/goolm/message/encoder_test.go delete mode 100644 mautrix-patched/crypto/goolm/message/group_message.go delete mode 100644 mautrix-patched/crypto/goolm/message/group_message_test.go delete mode 100644 mautrix-patched/crypto/goolm/message/message.go delete mode 100644 mautrix-patched/crypto/goolm/message/message_test.go delete mode 100644 mautrix-patched/crypto/goolm/message/prekey_message.go delete mode 100644 mautrix-patched/crypto/goolm/message/prekey_message_test.go delete mode 100644 mautrix-patched/crypto/goolm/message/session_export.go delete mode 100644 mautrix-patched/crypto/goolm/message/session_sharing.go delete mode 100644 mautrix-patched/crypto/goolm/pk/pk_test.go delete mode 100644 mautrix-patched/crypto/goolm/pk/register.go delete mode 100644 mautrix-patched/crypto/goolm/pk/signing.go delete mode 100644 mautrix-patched/crypto/goolm/ratchet/chain.go delete mode 100644 mautrix-patched/crypto/goolm/ratchet/olm.go delete mode 100644 mautrix-patched/crypto/goolm/ratchet/olm_test.go delete mode 100644 mautrix-patched/crypto/goolm/ratchet/skipped_message.go delete mode 100644 mautrix-patched/crypto/goolm/register.go delete mode 100644 mautrix-patched/crypto/goolm/session/doc.go delete mode 100644 mautrix-patched/crypto/goolm/session/megolm_inbound_session.go delete mode 100644 mautrix-patched/crypto/goolm/session/megolm_outbound_session.go delete mode 100644 mautrix-patched/crypto/goolm/session/megolm_session_test.go delete mode 100644 mautrix-patched/crypto/goolm/session/olm_session.go delete mode 100644 mautrix-patched/crypto/goolm/session/olm_session_test.go delete mode 100644 mautrix-patched/crypto/goolm/session/register.go delete mode 100644 mautrix-patched/crypto/keybackup.go delete mode 100644 mautrix-patched/crypto/keyexport.go delete mode 100644 mautrix-patched/crypto/keyexport_test.go delete mode 100644 mautrix-patched/crypto/keyimport.go delete mode 100644 mautrix-patched/crypto/keysharing.go delete mode 100644 mautrix-patched/crypto/libolm/account.go delete mode 100644 mautrix-patched/crypto/libolm/error.go delete mode 100644 mautrix-patched/crypto/libolm/inboundgroupsession.go delete mode 100644 mautrix-patched/crypto/libolm/libolm.go delete mode 100644 mautrix-patched/crypto/libolm/outboundgroupsession.go delete mode 100644 mautrix-patched/crypto/libolm/pk.go delete mode 100644 mautrix-patched/crypto/libolm/register.go delete mode 100644 mautrix-patched/crypto/libolm/session.go delete mode 100644 mautrix-patched/crypto/machine.go delete mode 100644 mautrix-patched/crypto/machine_bench_test.go delete mode 100644 mautrix-patched/crypto/machine_test.go delete mode 100644 mautrix-patched/crypto/olm/README.md delete mode 100644 mautrix-patched/crypto/olm/account.go delete mode 100644 mautrix-patched/crypto/olm/account_test.go delete mode 100644 mautrix-patched/crypto/olm/errors.go delete mode 100644 mautrix-patched/crypto/olm/groupsession_test.go delete mode 100644 mautrix-patched/crypto/olm/inboundgroupsession.go delete mode 100644 mautrix-patched/crypto/olm/olm.go delete mode 100644 mautrix-patched/crypto/olm/outboundgroupsession.go delete mode 100644 mautrix-patched/crypto/olm/outboundgroupsession_test.go delete mode 100644 mautrix-patched/crypto/olm/pk.go delete mode 100644 mautrix-patched/crypto/olm/pk_test.go delete mode 100644 mautrix-patched/crypto/olm/session.go delete mode 100644 mautrix-patched/crypto/olm/session_test.go delete mode 100644 mautrix-patched/crypto/registergoolm.go delete mode 100644 mautrix-patched/crypto/registerlibolm.go delete mode 100644 mautrix-patched/crypto/sessions.go delete mode 100644 mautrix-patched/crypto/sharing.go delete mode 100644 mautrix-patched/crypto/signatures/signatures.go delete mode 100644 mautrix-patched/crypto/sql_store.go delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go delete mode 100644 mautrix-patched/crypto/sql_store_upgrade/upgrade.go delete mode 100644 mautrix-patched/crypto/ssss/client.go delete mode 100644 mautrix-patched/crypto/ssss/key.go delete mode 100644 mautrix-patched/crypto/ssss/key_test.go delete mode 100644 mautrix-patched/crypto/ssss/meta.go delete mode 100644 mautrix-patched/crypto/ssss/meta_test.go delete mode 100644 mautrix-patched/crypto/ssss/types.go delete mode 100644 mautrix-patched/crypto/store.go delete mode 100644 mautrix-patched/crypto/store_test.go delete mode 100644 mautrix-patched/crypto/utils/utils.go delete mode 100644 mautrix-patched/crypto/utils/utils_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/callbacks_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/doc.go delete mode 100644 mautrix-patched/crypto/verificationhelper/ecdhkeys.go delete mode 100644 mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/qrcode.go delete mode 100644 mautrix-patched/crypto/verificationhelper/qrcode_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/reciprocate.go delete mode 100644 mautrix-patched/crypto/verificationhelper/sas.go delete mode 100644 mautrix-patched/crypto/verificationhelper/sas_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationhelper_test.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationstore.go delete mode 100644 mautrix-patched/crypto/verificationhelper/verificationstore_test.go delete mode 100644 mautrix-patched/error.go delete mode 100644 mautrix-patched/event/accountdata.go delete mode 100644 mautrix-patched/event/audio.go delete mode 100644 mautrix-patched/event/beeper.go delete mode 100644 mautrix-patched/event/capabilities.d.ts delete mode 100644 mautrix-patched/event/capabilities.go delete mode 100644 mautrix-patched/event/cmdschema/content.go delete mode 100644 mautrix-patched/event/cmdschema/parameter.go delete mode 100644 mautrix-patched/event/cmdschema/parse.go delete mode 100644 mautrix-patched/event/cmdschema/parse_test.go delete mode 100644 mautrix-patched/event/cmdschema/roomid.go delete mode 100644 mautrix-patched/event/cmdschema/stringify.go delete mode 100644 mautrix-patched/event/cmdschema/testdata/commands.schema.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/commands/flags.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/commands/simple.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/commands/tail.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/data.go delete mode 100644 mautrix-patched/event/cmdschema/testdata/parse_quote.json delete mode 100644 mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json delete mode 100644 mautrix-patched/event/content.go delete mode 100644 mautrix-patched/event/delayed.go delete mode 100644 mautrix-patched/event/encryption.go delete mode 100644 mautrix-patched/event/ephemeral.go delete mode 100644 mautrix-patched/event/events.go delete mode 100644 mautrix-patched/event/eventsource.go delete mode 100644 mautrix-patched/event/imagepack.go delete mode 100644 mautrix-patched/event/member.go delete mode 100644 mautrix-patched/event/message.go delete mode 100644 mautrix-patched/event/message_test.go delete mode 100644 mautrix-patched/event/poll.go delete mode 100644 mautrix-patched/event/powerlevels.go delete mode 100644 mautrix-patched/event/relations.go delete mode 100644 mautrix-patched/event/reply.go delete mode 100644 mautrix-patched/event/state.go delete mode 100644 mautrix-patched/event/type.go delete mode 100644 mautrix-patched/event/verification.go delete mode 100644 mautrix-patched/event/voip.go delete mode 100644 mautrix-patched/event/voip_test.go delete mode 100644 mautrix-patched/example/main.go delete mode 100644 mautrix-patched/federation/cache.go delete mode 100644 mautrix-patched/federation/client.go delete mode 100644 mautrix-patched/federation/client_test.go delete mode 100644 mautrix-patched/federation/context.go delete mode 100644 mautrix-patched/federation/eventauth/eventauth.go delete mode 100644 mautrix-patched/federation/eventauth/eventauth_internal_test.go delete mode 100644 mautrix-patched/federation/eventauth/eventauth_test.go delete mode 100644 mautrix-patched/federation/eventauth/testroom-v12-success.jsonl delete mode 100644 mautrix-patched/federation/httpclient.go delete mode 100644 mautrix-patched/federation/keyserver.go delete mode 100644 mautrix-patched/federation/media.go delete mode 100644 mautrix-patched/federation/pdu/auth.go delete mode 100644 mautrix-patched/federation/pdu/hash.go delete mode 100644 mautrix-patched/federation/pdu/hash_test.go delete mode 100644 mautrix-patched/federation/pdu/pdu.go delete mode 100644 mautrix-patched/federation/pdu/pdu_test.go delete mode 100644 mautrix-patched/federation/pdu/redact.go delete mode 100644 mautrix-patched/federation/pdu/signature.go delete mode 100644 mautrix-patched/federation/pdu/signature_test.go delete mode 100644 mautrix-patched/federation/pdu/v1.go delete mode 100644 mautrix-patched/federation/pdu/v1_test.go delete mode 100644 mautrix-patched/federation/resolution.go delete mode 100644 mautrix-patched/federation/resolution_test.go delete mode 100644 mautrix-patched/federation/serverauth.go delete mode 100644 mautrix-patched/federation/serverauth_test.go delete mode 100644 mautrix-patched/federation/servername.go delete mode 100644 mautrix-patched/federation/servername_test.go delete mode 100644 mautrix-patched/federation/signingkey.go delete mode 100644 mautrix-patched/federation/signutil/verify.go delete mode 100644 mautrix-patched/filter.go delete mode 100644 mautrix-patched/format/doc.go delete mode 100644 mautrix-patched/format/htmlparser.go delete mode 100644 mautrix-patched/format/markdown.go delete mode 100644 mautrix-patched/format/markdown_test.go delete mode 100644 mautrix-patched/format/mdext/customemoji.go delete mode 100644 mautrix-patched/format/mdext/discordunderline.go delete mode 100644 mautrix-patched/format/mdext/filteredparser.go delete mode 100644 mautrix-patched/format/mdext/indentableparagraph.go delete mode 100644 mautrix-patched/format/mdext/math.go delete mode 100644 mautrix-patched/format/mdext/nohtml.go delete mode 100644 mautrix-patched/format/mdext/shortemphasis.go delete mode 100644 mautrix-patched/format/mdext/shortstrike.go delete mode 100644 mautrix-patched/format/mdext/simplespoiler.go delete mode 100644 mautrix-patched/format/mdext/spoiler.go delete mode 100644 mautrix-patched/go.mod delete mode 100644 mautrix-patched/go.sum delete mode 100644 mautrix-patched/id/contenturi.go delete mode 100644 mautrix-patched/id/crypto.go delete mode 100644 mautrix-patched/id/matrixuri.go delete mode 100644 mautrix-patched/id/matrixuri_test.go delete mode 100644 mautrix-patched/id/opaque.go delete mode 100644 mautrix-patched/id/roomversion.go delete mode 100644 mautrix-patched/id/servername.go delete mode 100644 mautrix-patched/id/trust.go delete mode 100644 mautrix-patched/id/userid.go delete mode 100644 mautrix-patched/id/userid_test.go delete mode 100644 mautrix-patched/mediaproxy/mediaproxy.go delete mode 100644 mautrix-patched/mockserver/mockserver.go delete mode 100644 mautrix-patched/pushrules/action.go delete mode 100644 mautrix-patched/pushrules/action_test.go delete mode 100644 mautrix-patched/pushrules/condition.go delete mode 100644 mautrix-patched/pushrules/condition_displayname_test.go delete mode 100644 mautrix-patched/pushrules/condition_eventmatch_test.go delete mode 100644 mautrix-patched/pushrules/condition_eventpropertyis_test.go delete mode 100644 mautrix-patched/pushrules/condition_membercount_test.go delete mode 100644 mautrix-patched/pushrules/condition_test.go delete mode 100644 mautrix-patched/pushrules/doc.go delete mode 100644 mautrix-patched/pushrules/pushgateway/gateway.go delete mode 100644 mautrix-patched/pushrules/pushrules.go delete mode 100644 mautrix-patched/pushrules/pushrules_test.go delete mode 100644 mautrix-patched/pushrules/rule.go delete mode 100644 mautrix-patched/pushrules/rule_array_test.go delete mode 100644 mautrix-patched/pushrules/rule_test.go delete mode 100644 mautrix-patched/pushrules/ruleset.go delete mode 100644 mautrix-patched/requests.go delete mode 100644 mautrix-patched/responses.go delete mode 100644 mautrix-patched/responses_test.go delete mode 100644 mautrix-patched/room.go delete mode 100644 mautrix-patched/sqlstatestore/statestore.go delete mode 100644 mautrix-patched/sqlstatestore/v00-latest-revision.sql delete mode 100644 mautrix-patched/sqlstatestore/v02-membership-enum.sql delete mode 100644 mautrix-patched/sqlstatestore/v03-no-null.sql delete mode 100644 mautrix-patched/sqlstatestore/v04-encryption-info.sql delete mode 100644 mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go delete mode 100644 mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go delete mode 100644 mautrix-patched/sqlstatestore/v07-full-member-flag.sql delete mode 100644 mautrix-patched/sqlstatestore/v08-create-event.sql delete mode 100644 mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql delete mode 100644 mautrix-patched/sqlstatestore/v10-join-rules.sql delete mode 100644 mautrix-patched/statestore.go delete mode 100644 mautrix-patched/synapseadmin/client.go delete mode 100644 mautrix-patched/synapseadmin/register.go delete mode 100644 mautrix-patched/synapseadmin/roomapi.go delete mode 100644 mautrix-patched/synapseadmin/userapi.go delete mode 100644 mautrix-patched/sync.go delete mode 100644 mautrix-patched/syncstore.go delete mode 100644 mautrix-patched/url.go delete mode 100644 mautrix-patched/url_test.go delete mode 100644 mautrix-patched/version.go delete mode 100644 mautrix-patched/versions.go delete mode 100644 mautrix-patched/versions_test.go diff --git a/go.mod b/go.mod index a074fa69..29b27b3e 100644 --- a/go.mod +++ b/go.mod @@ -53,5 +53,3 @@ require ( replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260620030032-507542c8bda5 replace github.com/imroc/req/v3 => github.com/beeper/req/v3 v3.0.0-20260114152409-4c060b237f73 - -replace maunium.net/go/mautrix => ./mautrix-patched diff --git a/mautrix-patched/.editorconfig b/mautrix-patched/.editorconfig deleted file mode 100644 index 1a167e7e..00000000 --- a/mautrix-patched/.editorconfig +++ /dev/null @@ -1,15 +0,0 @@ -root = true - -[*] -indent_style = tab -indent_size = 4 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.{yaml,yml}] -indent_style = space - -[provisioning.yaml] -indent_size = 2 diff --git a/mautrix-patched/.github/workflows/go.yml b/mautrix-patched/.github/workflows/go.yml deleted file mode 100644 index 47a6aab8..00000000 --- a/mautrix-patched/.github/workflows/go.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Go - -on: [push, pull_request] - -env: - GOTOOLCHAIN: local - -jobs: - lint: - runs-on: ubuntu-latest - name: Lint (latest) - steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.26" - cache: true - - - name: Install libolm - run: sudo apt-get install libolm-dev libolm3 - - - name: Install goimports - run: | - go install golang.org/x/tools/cmd/goimports@latest - go install honnef.co/go/tools/cmd/staticcheck@latest - export PATH="$HOME/go/bin:$PATH" - - - name: Run pre-commit - uses: pre-commit/action@v3.0.1 - - build: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - go-version: ["1.25", "1.26"] - name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, libolm) - - steps: - - uses: actions/checkout@v6 - - - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v6 - with: - go-version: ${{ matrix.go-version }} - cache: true - - - name: Set up gotestfmt - uses: GoTestTools/gotestfmt-action@eba2ac97f623e866e7b1b117c99f18b43cd36d18 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install libolm - run: sudo apt-get install libolm-dev libolm3 - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -json -v ./... 2>&1 | gotestfmt - - - name: Build (jsonv2) - env: - GOEXPERIMENT: jsonv2 - run: go build -v ./... - - - name: Test (jsonv2) - env: - GOEXPERIMENT: jsonv2 - run: go test -json -v ./... 2>&1 | gotestfmt - - build-goolm: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - go-version: ["1.25", "1.26"] - name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, goolm) - - steps: - - uses: actions/checkout@v6 - - - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v6 - with: - go-version: ${{ matrix.go-version }} - cache: true - - - name: Set up gotestfmt - uses: GoTestTools/gotestfmt-action@eba2ac97f623e866e7b1b117c99f18b43cd36d18 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Build - run: | - rm -rf crypto/libolm - go build -tags=goolm -v ./... - - - name: Test - run: go test -tags=goolm -json -v ./... 2>&1 | gotestfmt diff --git a/mautrix-patched/.github/workflows/stale.yml b/mautrix-patched/.github/workflows/stale.yml deleted file mode 100644 index 9a9e7375..00000000 --- a/mautrix-patched/.github/workflows/stale.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: 'Lock old issues' - -on: - schedule: - - cron: '0 6 * * *' - workflow_dispatch: - -permissions: - issues: write -# pull-requests: write -# discussions: write - -concurrency: - group: lock-threads - -jobs: - lock-stale: - runs-on: ubuntu-latest - steps: - - uses: dessant/lock-threads@v6 - id: lock - with: - issue-inactive-days: 90 - process-only: issues - - name: Log processed threads - run: | - if [ '${{ steps.lock.outputs.issues }}' ]; then - echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"' - fi diff --git a/mautrix-patched/.gitignore b/mautrix-patched/.gitignore deleted file mode 100644 index 0f69cfc6..00000000 --- a/mautrix-patched/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -.idea/ -.vscode/ -*.db* -*.log -.cursor/plans/ -.claude/settings.local.json diff --git a/mautrix-patched/.pre-commit-config.yaml b/mautrix-patched/.pre-commit-config.yaml deleted file mode 100644 index 616fccb2..00000000 --- a/mautrix-patched/.pre-commit-config.yaml +++ /dev/null @@ -1,29 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: trailing-whitespace - exclude_types: [markdown] - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - - repo: https://github.com/tekwizely/pre-commit-golang - rev: v1.0.0-rc.4 - hooks: - - id: go-imports-repo - args: - - "-local" - - "maunium.net/go/mautrix" - - "-w" - - id: go-vet-repo-mod - - id: go-mod-tidy - - id: go-staticcheck-repo-mod - - - repo: https://github.com/beeper/pre-commit-go - rev: v0.4.2 - hooks: - - id: prevent-literal-http-methods - - id: zerolog-ban-global-log - - id: zerolog-ban-msgf - - id: zerolog-use-stringer diff --git a/mautrix-patched/CHANGELOG.md b/mautrix-patched/CHANGELOG.md deleted file mode 100644 index 05150a8e..00000000 --- a/mautrix-patched/CHANGELOG.md +++ /dev/null @@ -1,1555 +0,0 @@ -## v0.28.1 (2026-06-16) - -* *(pushrules)* Removed deprecated `NotifySpecified` field in `Should()`. -* *(pushrules)* Deprecated `ActionDontNotify` and `ActionCoalesce` constants. -* *(client)* Added wrapper for `/search` endpoint. -* *(client)* Added wrappers for the `/enabled` and `/actions` sub-endpoints - for modifying push rules. -* *(bridgev2/commands)* Added warning when using the login command in a - non-management room. -* *(bridgev2/matrix)* Added `ParseContentURI` method for parsing a `mxc://` URI - previously generated for direct media. -* *(bridgev2/provisioning)* Changed request handling to allow retrying requests. - * Requests can be retried as long as the next step hasn't been completed. - * Cancellation is now done with a separate API call. -* *(bridgev2/provisioning)* Changed group creation to ignore self in participants - list instead of rejecting the request. -* *(bridgev2/provisioning)* Changed error responses to include internal error in - a separate field. -* *(bridgev2)* Fixed `m.bridge` event in child portals not being updated when - the parent portal name or avatar changes. -* *(client)* Fixed request retry trigger not working correctly in some cases. - -## v0.28.0 (2026-05-16) - -* **Breaking change *(federation)*** Changed `NewClient` to take HTTP client - settings as an extra parameter. -* *(federation)* Changed client to block requests to private IPs by default. - * The `AllowIP` method can be changed to adjust the blocking behavior. -* *(federation)* Added `DownloadMedia` method. -* *(event)* Added `Extra` field to `FileInfo` to allow easily adding custom - fields. -* *(event)* Added sticker source info to events (both [MSC4459] and a custom - format for bridges). -* *(client)* Added support for `server` parameter in `/publicRooms` - (thanks to [@zluudg] in [#497]). -* *(client)* Added `RequestRetryTrigger` event, which can be used to force all - in-flight requests to be interrupted and retried (e.g. in case the network - connection changed). -* *(crypto)* Added support for bundled device keys in Olm messages introduced - in Matrix v1.15. -* *(crypto/canonicaljson)* Added jsonv2-based implementation, which replaces - the old gjson/sjson-based implementation when jsonv2 is enabled. -* *(bridgev2)* Added interface for importing image packs from remote networks. -* *(bridgev2)* Added more detail to "not logged in" error messages. -* *(bridgev2)* Expanded `bridge_matrix_leave` option to cover invite rejections - in addition to actual leaves. -* *(bridgev2)* Changed group creation error messages to be clearer when there - aren't enough members to create a group. -* *(bridgev2/matrix)* Changed message sending to never send unencrypted messages - if `encryption.require` is set `true` even if the room is unencrypted. -* *(crypto)* Changed trust resolution to not trust own cross-signing master key - unless the private key is available or the public key is signed by the device - key. -* *(bridgev2)* Fixed event power levels being set incorrectly in some cases if - `events_default` or `state_default` is changed in the same event. -* *(bridgev2)* Fixed portal deletion always failing due to context cancellation. -* *(bridgev2)* Fixed per-message profile fallbacks being added for events that - shouldn't have it, like stickers. -* *(bridgev2)* Fixed some cases where backfill would start from a non-latest - message. -* *(crypto)* Fixed dehydrated devices not passing device key validation. -* *(federation/pdu)* Fixed canonicalizing JSON which contains keys with code - points between `\uF000` and `\uFFFF` by switching to the crypto/canonicaljson - package instead of jsonv2's standard RFC 8785 canonicalization. -* *(federation/eventauth)* Fixed restricted join checks - (thanks to [@timedoutuk] in [#491]). -* *(federation/eventauth)* Fixed creator join check in v10 rooms - (thanks to [@timedoutuk] in [#496]). -* *(crypto/goolm)* Fixed various small issues. - -[MSC445]: https://github.com/matrix-org/matrix-spec-proposals/pull/4459 -[#491]: https://github.com/mautrix/go/pull/491 -[#496]: https://github.com/mautrix/go/pull/496 -[#497]: https://github.com/mautrix/go/pull/497 -[@zluudg]: https://github.com/zluudg - -## v0.27.0 (2026-04-16) - -### Slightly breaking changes -* *(crypto)* Changed `GetOwnCrossSigningPublicKeys` to return errors instead of - only logging them and returning nil. -* *(event)* Removed automatic registrations of content structs to encoding/gob. - If you use mautrix types with gob, you'll have to register the structs yourself. -* *(crypto)* Removed unused Olm PK encryption/decryption interface. -* *(crypto/goolm)* Removed unused JSON pickling methods. - -### New features and non-breaking changes -* *(client)* Added support for [MSC4446] for moving `m.fully_read` backwards. -* *(appservice)* Added support for escaped paths in HTTP over websocket proxy. -* *(synapseadmin)* Added wrapper for redacting all events from a specific user - (thanks to [@timedoutuk] in [#466]). -* *(event)* Added types for [MSC2545] image packs. -* *(bridgev2)* Added option to block automatic portal creation for specific - chats and/or users. -* *(bridgev2)* Added commands to bridge existing groups to existing rooms and - to create new portal rooms for existing groups. -* *(bridgev2)* Added option to always prefer default relays for the `bridge` - and `set-relay` commands. -* *(bridgev2)* Added support for using [MSC4437] for ghost profile updates. -* *(bridgev2)* Added optional GetStateEvent method to `ASIntent` to get state - while respecting room membership. -* *(bridgev2/mxmain)* Added environment variables to change global values like - the portal event buffer size. -* *(event)* Changed `EnsureHasHTML` to also ensure the body is treated as a - caption for media messages. -* *(bridgev2)* Changed relay mode to treat stickers as normal images. -* *(bridgev2/matrix)* Changed various start methods to return ExitErrors instead - of calling `os.Exit` directly. -* *(client)* Changed sync response structs to use `omitzero` instead of custom - JSON marshaling functions. - -### Bug fixes -* *(crypto/goolm)* Fixed various issues. -* *(crypto)* Fixed new Olm session handling to only delete one-time keys after - successfully decrypting a message. -* *(crypto)* Fixed `ResolveTrust` not checking trust status of cross-signing - keys correctly. -* *(crypto)* Fixed `m.relates_to` copying not working for some inputs with goolm. -* *(event)* Fixed `Content.UnmarshalJSON` incorrectly keeping a reference to the - input data. -* *(format)* Fixed math blocks not being routed to correct convert function. -* *(bridgev2)* Fixed sending tombstone when redirecting a portal to another room. -* *(bridgev2)* Fixed removed messages/reactions not being removed from database. -* *(bridgev2)* Fixed race conditions where portal ID changes could result in a - duplicate room being created. -* *(bridgev2/mxmain)* Fixed some types of config fields not being settable with - environment variables. -* *(appservice)* Fixed redundant `mx_registrations` database query on every - request. - -[#466]: https://github.com/mautrix/go/pull/466 -[MSC2545]: https://github.com/matrix-org/matrix-spec-proposals/pull/2545 -[MSC4437]: https://github.com/matrix-org/matrix-spec-proposals/pull/4437 -[MSC4446]: https://github.com/matrix-org/matrix-spec-proposals/pull/4446 - -## v0.26.4 (2026-03-16) - -* **Breaking change *(client)*** Changed request structs that include UIA - (register, upload cross-signing keys, delete devices) to take the auth data - as a type parameter. -* *(crypto)* Changed device key mismatches in Megolm decryption to mark the - message as untrusted instead of failing entirely. -* *(crypto)* Added new column to save origin of received Megolm sessions. -* *(bridgev2)* Added support for setting custom profile fields (e.g. `m.tz`) - for ghosts. -* *(bridgev2/commands)* Added `delete-chat` command to delete chats on the - remote network. -* *(client)* Updated MSC2666 implementation to use stable endpoint. -* *(client)* Stopped logging large (>32 KiB) request bodies. -* *(bridgev2/portal)* Fixed potential deadlock when a portal ID change races - with room creation. -* *(bridgev2/portal)* Fixed the third reaction from Matrix being handled - incorrectly on networks that only allow one reaction per message. -* *(bridgev2/database)* Fixed finding first message in thread in case the thread - contains messages with a lower timestamp than the root message. -* *(bridgev2/commands)* Fixed login QR codes not having appropriate file info. -* *(bridgev2/commands)* Fixed user input steps not working correctly after a - display step. -* *(format/htmlparser)* Fixed generating markdown for code blocks containing - backticks. -* *(federation/eventauth)* Fixed inverted check in ban membership authorization - (thanks to [@timedoutuk] in [#464]). - -[#464]: https://github.com/mautrix/go/pull/464 - -## v0.26.3 (2026-02-16) - -* Bumped minimum Go version to 1.25. -* *(client)* Added fields for sending [MSC4354] sticky events. -* *(bridgev2)* Added automatic message request accepting when sending message. -* *(mediaproxy)* Added support for federation thumbnail endpoint. -* *(crypto/ssss)* Improved support for recovery keys with slightly broken - metadata. -* *(crypto)* Changed key import to call session received callback even for - sessions that already exist in the database. -* *(appservice)* Fixed building websocket URL accidentally using file path - separators instead of always `/`. -* *(crypto)* Fixed key exports not including the `sender_claimed_keys` field. -* *(client)* Fixed incorrect context usage in async uploads. -* *(crypto)* Fixed panic when passing invalid input to megolm message index - parser used for debugging. -* *(bridgev2/provisioning)* Fixed completed or failed logins not being cleaned - up properly. - -[MSC4354]: https://github.com/matrix-org/matrix-spec-proposals/pull/4354 - -## v0.26.2 (2026-01-16) - -* *(bridgev2)* Added chunked portal deletion to avoid database locks when - deleting large portals. -* *(crypto,bridgev2)* Added option to encrypt reaction and reply metadata - as per [MSC4392]. -* *(bridgev2/login)* Added `default_value` for user input fields. -* *(bridgev2)* Added interfaces to let the Matrix connector provide suggested - HTTP client settings and to reset active connections of the network connector. -* *(bridgev2)* Added interface to let network connectors get the provisioning - API HTTP router and add new endpoints. -* *(event)* Added blurhash field to Beeper link preview objects. -* *(event)* Added [MSC4391] support for bot commands. -* *(event)* Dropped [MSC4332] support for bot commands. -* *(client)* Changed media download methods to return an error if the provided - MXC URI is empty. -* *(client)* Stabilized support for [MSC4323]. -* *(bridgev2/matrix)* Fixed `GetEvent` panicking when trying to decrypt events. -* *(bridgev2)* Fixed some deadlocks when room creation happens in parallel with - a portal re-ID call. - -[MSC4391]: https://github.com/matrix-org/matrix-spec-proposals/pull/4391 -[MSC4392]: https://github.com/matrix-org/matrix-spec-proposals/pull/4392 - -## v0.26.1 (2025-12-16) - -* **Breaking change *(mediaproxy)*** Changed `GetMediaResponseFile` to return - the mime type from the callback rather than in the return get media return - value. The callback can now also redirect the caller to a different file. -* *(federation)* Added join/knock/leave functions - (thanks to [@timedoutuk] in [#422]). -* *(federation/eventauth)* Fixed various incorrect checks. -* *(client)* Added backoff for retrying media uploads to external URLs - (with MSC3870). -* *(bridgev2/config)* Added support for overriding config fields using - environment variables. -* *(bridgev2/commands)* Added command to mute chat on remote network. -* *(bridgev2)* Added interface for network connectors to redirect to a different - user ID when handling an invite from Matrix. -* *(bridgev2)* Added interface for signaling message request status of portals. -* *(bridgev2)* Changed portal creation to not backfill unless `CanBackfill` flag - is set in chat info. -* *(bridgev2)* Changed Matrix reaction handling to only delete old reaction if - bridging the new one is successful. -* *(bridgev2/mxmain)* Improved error message when trying to run bridge with - pre-megabridge database when no database migration exists. -* *(bridgev2)* Improved reliability of database migration when enabling split - portals. -* *(bridgev2)* Improved detection of orphaned DM rooms when starting new chats. -* *(bridgev2)* Stopped sending redundant invites when joining ghosts to public - portal rooms. -* *(bridgev2)* Stopped hardcoding room versions in favor of checking - server capabilities to determine appropriate `/createRoom` parameters. - -[#422]: https://github.com/mautrix/go/pull/422 - -## v0.26.0 (2025-11-16) - -* *(client,appservice)* Deprecated `SendMassagedStateEvent` as `SendStateEvent` - has been able to do the same for a while now. -* *(client,federation)* Added size limits for responses to make it safer to send - requests to untrusted servers. -* *(client)* Added wrapper for `/admin/whois` client API - (thanks to [@timedoutuk] in [#411]). -* *(synapseadmin)* Added `force_purge` option to DeleteRoom - (thanks to [@timedoutuk] in [#420]). -* *(statestore)* Added saving join rules for rooms. -* *(bridgev2)* Added optional automatic rollback of room state if bridging the - change to the remote network fails. -* *(bridgev2)* Added management room notices if transient disconnect state - doesn't resolve within 3 minutes. -* *(bridgev2)* Added interface to signal that certain participants couldn't be - invited when creating a group. -* *(bridgev2)* Added `select` type for user input fields in login. -* *(bridgev2)* Added interface to let network connector customize personal - filtering space. -* *(bridgev2/matrix)* Added checks to avoid sending error messages in reply to - other bots. -* *(bridgev2/matrix)* Switched to using [MSC4169] to send redactions whenever - possible. -* *(bridgev2/publicmedia)* Added support for custom path prefixes, file names, - and encrypted files. -* *(bridgev2/commands)* Added command to resync a single portal. -* *(bridgev2/commands)* Added create group command. -* *(bridgev2/config)* Added option to limit maximum number of logins. -* *(bridgev2)* Changed ghost joining to skip unnecessary invite if portal room - is public. -* *(bridgev2/disappear)* Changed read receipt handling to only start - disappearing timers for messages up to the read message (note: may not work in - all cases if the read receipt points at an unknown event). -* *(event/reply)* Changed plaintext reply fallback removal to only happen when - an HTML reply fallback is removed successfully. -* *(bridgev2/matrix)* Fixed unnecessary sleep after registering bot on first run. -* *(crypto/goolm)* Fixed panic when processing certain malformed Olm messages. -* *(federation)* Fixed HTTP method for sending transactions - (thanks to [@timedoutuk] in [#426]). -* *(federation)* Fixed response body being closed even when using `DontReadBody` - parameter. -* *(federation)* Fixed validating auth for requests with query params. -* *(federation/eventauth)* Fixed typo causing restricted joins to not work. - -[MSC4169]: https://github.com/matrix-org/matrix-spec-proposals/pull/4169 -[#411]: github.com/mautrix/go/pull/411 -[#420]: github.com/mautrix/go/pull/420 -[#426]: github.com/mautrix/go/pull/426 - -## v0.25.2 (2025-10-16) - -* **Breaking change *(id)*** Split `UserID.ParseAndValidate` into - `ParseAndValidateRelaxed` and `ParseAndValidateStrict`. Strict is the old - behavior, but most users likely want the relaxed version, as there are real - users whose user IDs aren't valid under the strict rules. -* *(crypto)* Added helper methods for generating and verifying with recovery - keys. -* *(bridgev2/matrix)* Added config option to automatically generate a recovery - key for the bridge bot and self-sign the bridge's device. -* *(bridgev2/matrix)* Added initial support for using appservice/MSC3202 mode - for encryption with standard servers like Synapse. -* *(bridgev2)* Added optional support for implicit read receipts. -* *(bridgev2)* Added interface for deleting chats on remote network. -* *(bridgev2)* Added local enforcement of media duration and size limits. -* *(bridgev2)* Extended event duration logging to log any event taking too long. -* *(bridgev2)* Improved validation in group creation provisioning API. -* *(event)* Added event type constant for poll end events. -* *(client)* Added wrapper for searching user directory. -* *(client)* Improved support for managing [MSC4140] delayed events. -* *(crypto/helper)* Changed default sync handling to not block on waiting for - decryption keys. On initial sync, keys won't be requested at all by default. -* *(crypto)* Fixed olm unwedging not working (regressed in v0.25.1). -* *(bridgev2)* Fixed various bugs with migrating to split portals. -* *(event)* Fixed poll start events having incorrect null `m.relates_to`. -* *(client)* Fixed `RespUserProfile` losing standard fields when re-marshaling. -* *(federation)* Fixed various bugs in event auth. - -## v0.25.1 (2025-09-16) - -* *(client)* Fixed HTTP method of delete devices API call - (thanks to [@fmseals] in [#393]). -* *(client)* Added wrappers for [MSC4323]: User suspension & locking endpoints - (thanks to [@timedoutuk] in [#407]). -* *(client)* Stabilized support for extensible profiles. -* *(client)* Stabilized support for `state_after` in sync. -* *(client)* Removed deprecated MSC2716 requests. -* *(crypto)* Added fallback to ensure `m.relates_to` is always copied even if - the content struct doesn't implement `Relatable`. -* *(crypto)* Changed olm unwedging to ignore newly created sessions if they - haven't been used successfully in either direction. -* *(federation)* Added utilities for generating, parsing, validating and - authorizing PDUs. - * Note: the new PDU code depends on `GOEXPERIMENT=jsonv2` -* *(event)* Added `is_animated` flag from [MSC4230] to file info. -* *(event)* Added types for [MSC4332]: In-room bot commands. -* *(event)* Added missing poll end event type for [MSC3381]. -* *(appservice)* Fixed URLs not being escaped properly when using unix socket - for homeserver connections. -* *(format)* Added more helpers for forming markdown links. -* *(event,bridgev2)* Added support for Beeper's disappearing message state event. -* *(bridgev2)* Redesigned group creation interface and added support in commands - and provisioning API. -* *(bridgev2)* Added GetEvent to Matrix interface to allow network connectors to - get an old event. The method is best effort only, as some configurations don't - allow fetching old events. -* *(bridgev2)* Added shared logic for provisioning that can be reused by the - API, commands and other sources. -* *(bridgev2)* Fixed mentions and URL previews not being copied over when - caption and media are merged. -* *(bridgev2)* Removed config option to change provisioning API prefix, which - had already broken in the previous release. - -[@fmseals]: https://github.com/fmseals -[#393]: https://github.com/mautrix/go/pull/393 -[#407]: https://github.com/mautrix/go/pull/407 -[MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 -[MSC4230]: https://github.com/matrix-org/matrix-spec-proposals/pull/4230 -[MSC4323]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323 -[MSC4332]: https://github.com/matrix-org/matrix-spec-proposals/pull/4332 - -## v0.25.0 (2025-08-16) - -* Bumped minimum Go version to 1.24. -* **Breaking change *(appservice,bridgev2,federation)*** Replaced gorilla/mux - with standard library ServeMux. -* *(client,bridgev2)* Added support for creator power in room v12. -* *(client)* Added option to not set `User-Agent` header for improved Wasm - compatibility. -* *(bridgev2)* Added support for following tombstones. -* *(bridgev2)* Added interface for getting arbitrary state event from Matrix. -* *(bridgev2)* Added batching to disappearing message queue to ensure it doesn't - use too many resources even if there are a large number of messages. -* *(bridgev2/commands)* Added support for canceling QR login with `cancel` - command. -* *(client)* Added option to override HTTP client used for .well-known - resolution. -* *(crypto/backup)* Added method for encrypting key backup session without - private keys. -* *(event->id)* Moved room version type and constants to id package. -* *(bridgev2)* Bots in DM portals will now be added to the functional members - state event to hide them from the room name calculation. -* *(bridgev2)* Changed message delete handling to ignore "delete for me" events - if there are multiple Matrix users in the room. -* *(format/htmlparser)* Changed text processing to collapse multiple spaces into - one when outside `pre`/`code` tags. -* *(format/htmlparser)* Removed link suffix in plaintext output when link text - is only missing protocol part of href. - * e.g. `example.com` will turn into - `example.com` rather than `example.com (https://example.com)` -* *(appservice)* Switched appservice websockets from gorilla/websocket to - coder/websocket. -* *(bridgev2/matrix)* Fixed encryption key sharing not ignoring ghosts properly. -* *(crypto/attachments)* Fixed hash check when decrypting file streams. -* *(crypto)* Removed unnecessary `AlreadyShared` error in `ShareGroupSession`. - The function will now act as if it was successful instead. - -## v0.24.2 (2025-07-16) - -* *(bridgev2)* Added support for return values from portal event handlers. Note - that the return value will always be "queued" unless the event buffer is - disabled. -* *(bridgev2)* Added support for [MSC4144] per-message profile passthrough in - relay mode. -* *(bridgev2)* Added option to auto-reconnect logins after a certain period if - they hit an `UNKNOWN_ERROR` state. -* *(bridgev2)* Added analytics for event handler panics. -* *(bridgev2)* Changed new room creation to hardcode room v11 to avoid v12 rooms - being created before proper support for them can be added. -* *(bridgev2)* Changed queuing events to block instead of dropping events if the - buffer is full. -* *(bridgev2)* Fixed assumption that replies to unknown messages are cross-room. -* *(id)* Fixed server name validation not including ports correctly - (thanks to [@krombel] in [#392]). -* *(federation)* Fixed base64 algorithm in signature generation. -* *(event)* Fixed [MSC4144] fallbacks not being removed from edits. - -[@krombel]: https://github.com/krombel -[#392]: https://github.com/mautrix/go/pull/392 - -## v0.24.1 (2025-06-16) - -* *(commands)* Added framework for using reactions as buttons that execute - command handlers. -* *(client)* Added wrapper for `/relations` endpoints. -* *(client)* Added support for stable version of room summary endpoint. -* *(client)* Fixed parsing URL preview responses where width/height are strings. -* *(federation)* Fixed bugs in server auth. -* *(id)* Added utilities for validating server names. -* *(event)* Fixed incorrect empty `entity` field when sending hashed moderation - policy events. -* *(event)* Added [MSC4293] redact events field to member events. -* *(event)* Added support for fallbacks in [MSC4144] per-message profiles. -* *(format)* Added `MarkdownLink` and `MarkdownMention` utility functions for - generating properly escaped markdown. -* *(synapseadmin)* Added support for synchronous (v1) room delete endpoint. -* *(synapseadmin)* Changed `Client` struct to not embed the `mautrix.Client`. - This is a breaking change if you were relying on accessing non-admin functions - from the admin client. -* *(bridgev2/provisioning)* Fixed `/display_and_wait` not passing through errors - from the network connector properly. -* *(bridgev2/crypto)* Fixed encryption not working if the user's ID had the same - prefix as the bridge ghosts (e.g. `@whatsappbridgeuser:example.com` with a - `@whatsapp_` prefix). -* *(bridgev2)* Fixed portals not being saved after creating a DM portal from a - Matrix DM invite. -* *(bridgev2)* Added config option to determine whether cross-room replies - should be bridged. -* *(appservice)* Fixed `EnsureRegistered` not being called when sending a custom - member event for the controlled user. - -[MSC4293]: https://github.com/matrix-org/matrix-spec-proposals/pull/4293 - -## v0.24.0 (2025-05-16) - -* *(commands)* Added generic framework for implementing bot commands. -* *(client)* Added support for specifying maximum number of HTTP retries using - a context value instead of having to call `MakeFullRequest` manually. -* *(client,federation)* Added methods for fetching room directories. -* *(federation)* Added support for server side of request authentication. -* *(synapseadmin)* Added wrapper for the account suspension endpoint. -* *(format)* Added method for safely wrapping a string in markdown inline code. -* *(crypto)* Added method to import key backup without persisting to database, - to allow the client more control over the process. -* *(bridgev2)* Added viewing chat interface to signal when the user is viewing - a given chat. -* *(bridgev2)* Added option to pass through transaction ID from client when - sending messages to remote network. -* *(crypto)* Fixed unnecessary error log when decrypting dummy events used for - unwedging Olm sessions. -* *(crypto)* Fixed `forwarding_curve25519_key_chain` not being set consistently - when backing up keys. -* *(event)* Fixed marshaling legacy VoIP events with no version field. -* *(bridgev2)* Fixed disappearing message references not being deleted when the - portal is deleted. -* *(bridgev2)* Fixed read receipt bridging not ignoring fake message entries - and causing unnecessary error logs. - -## v0.23.3 (2025-04-16) - -* *(commands)* Added generic command processing framework for bots. -* *(client)* Added `allowed_room_ids` field to room summary responses - (thanks to [@timedoutuk] in [#367]). -* *(bridgev2)* Added support for custom timeouts on outgoing messages which have - to wait for a remote echo. -* *(bridgev2)* Added automatic typing stop event if the ghost user had sent a - typing event before a message. -* *(bridgev2)* The saved management room is now cleared if the user leaves the - room, allowing the next DM to be automatically marked as a management room. -* *(bridge)* Removed deprecated fallback package for bridge statuses. - The status package is now only available under bridgev2. - -[#367]: https://github.com/mautrix/go/pull/367 - -## v0.23.2 (2025-03-16) - -* **Breaking change *(bridge)*** Removed legacy bridge module. -* **Breaking change *(event)*** Changed `m.federate` field in room create event - content to a pointer to allow detecting omitted values. -* *(bridgev2/commands)* Added `set-management-room` command to set a new - management room. -* *(bridgev2/portal)* Changed edit bridging to ignore remote edits if the - original sender on Matrix can't be puppeted. -* *(bridgv2)* Added config option to disable bridging `m.notice` messages. -* *(appservice/http)* Switched access token validation to use constant time - comparisons. -* *(event)* Added support for [MSC3765] rich text topics. -* *(event)* Added fields to policy list event contents for [MSC4204] and - [MSC4205]. -* *(client)* Added method for getting the content of a redacted event using - [MSC2815]. -* *(client)* Added methods for sending and updating [MSC4140] delayed events. -* *(client)* Added support for [MSC4222] in sync payloads. -* *(crypto/cryptohelper)* Switched to using `sqlite3-fk-wal` instead of plain - `sqlite3` by default. -* *(crypto/encryptolm)* Added generic method for encrypting to-device events. -* *(crypto/ssss)* Fixed panic if server-side key metadata is corrupted. -* *(crypto/sqlstore)* Fixed error when marking over 32 thousand device lists - as outdated on SQLite. - -[MSC2815]: https://github.com/matrix-org/matrix-spec-proposals/pull/2815 -[MSC3765]: https://github.com/matrix-org/matrix-spec-proposals/pull/3765 -[MSC4140]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140 -[MSC4204]: https://github.com/matrix-org/matrix-spec-proposals/pull/4204 -[MSC4205]: https://github.com/matrix-org/matrix-spec-proposals/pull/4205 -[MSC4222]: https://github.com/matrix-org/matrix-spec-proposals/pull/4222 - -## v0.23.1 (2025-02-16) - -* *(client)* Added `FullStateEvent` method to get a state event including - metadata (using the `?format=event` query parameter). -* *(client)* Added wrapper method for [MSC4194]'s redact endpoint. -* *(pushrules)* Fixed content rules not considering word boundaries and being - case-sensitive. -* *(crypto)* Fixed bugs that would cause key exports to fail for no reason. -* *(crypto)* Deprecated `ResolveTrust` in favor of `ResolveTrustContext`. -* *(crypto)* Stopped accepting secret shares from unverified devices. -* **Breaking change *(crypto)*** Changed `GetAndVerifyLatestKeyBackupVersion` - to take an optional private key parameter. The method will now trust the - public key if it matches the provided private key even if there are no valid - signatures. -* **Breaking change *(crypto)*** Added context parameter to `IsDeviceTrusted`. - -[MSC4194]: https://github.com/matrix-org/matrix-spec-proposals/pull/4194 - -## v0.23.0 (2025-01-16) - -* **Breaking change *(client)*** Changed `JoinRoom` parameters to allow multiple - `via`s. -* **Breaking change *(bridgev2)*** Updated capability system. - * The return type of `NetworkAPI.GetCapabilities` is now different. - * Media type capabilities are enforced automatically by bridgev2. - * Capabilities are now sent to Matrix rooms using the - `com.beeper.room_features` state event. -* *(client)* Added `GetRoomSummary` to implement [MSC3266]. -* *(client)* Added support for arbitrary profile fields to implement [MSC4133] - (thanks to [@timedoutuk] in [#337]). -* *(crypto)* Started storing olm message hashes to prevent decryption errors - if messages are repeated (e.g. if the app crashes right after decrypting). -* *(crypto)* Improved olm session unwedging to check when the last session was - created instead of only relying on an in-memory map. -* *(crypto/verificationhelper)* Fixed emoji verification not doing cross-signing - properly after a successful verification. -* *(bridgev2/config)* Moved MSC4190 flag from `appservice` to `encryption`. -* *(bridgev2/space)* Fixed failing to add rooms to spaces if the room create - call was made with a temporary context. -* *(bridgev2/commands)* Changed `help` command to hide commands which require - interfaces that aren't implemented by the network connector. -* *(bridgev2/matrixinterface)* Moved deterministic room ID generation to Matrix - connector. -* *(bridgev2)* Fixed service member state event not being set correctly when - creating a DM by inviting a ghost user. -* *(bridgev2)* Fixed `RemoteReactionSync` events replacing all reactions every - time instead of only changed ones. - -[MSC3266]: https://github.com/matrix-org/matrix-spec-proposals/pull/3266 -[MSC4133]: https://github.com/matrix-org/matrix-spec-proposals/pull/4133 -[@timedoutuk]: https://github.com/timedoutuk -[#337]: https://github.com/mautrix/go/pull/337 - -## v0.22.1 (2024-12-16) - -* *(crypto)* Added automatic cleanup when there are too many olm sessions with - a single device. -* *(crypto)* Added helper for getting cached device list with cross-signing - status. -* *(crypto/verificationhelper)* Added interface for persisting the state of - in-progress verifications. -* *(client)* Added `GetMutualRooms` wrapper for [MSC2666]. -* *(client)* Switched `JoinRoom` to use the `via` query param instead of - `server_name` as per [MSC4156]. -* *(bridgev2/commands)* Fixed `pm` command not actually starting the chat. -* *(bridgev2/interface)* Added separate network API interface for starting - chats with a Matrix ghost user. This allows treating internal user IDs - differently than arbitrary user-input strings. -* *(bridgev2/crypto)* Added support for [MSC4190] - (thanks to [@onestacked] in [#288]). - -[MSC2666]: https://github.com/matrix-org/matrix-spec-proposals/pull/2666 -[MSC4156]: https://github.com/matrix-org/matrix-spec-proposals/pull/4156 -[MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190 -[#288]: https://github.com/mautrix/go/pull/288 -[@onestacked]: https://github.com/onestacked - -## v0.22.0 (2024-11-16) - -* *(hicli)* Moved package into gomuks repo. -* *(bridgev2/commands)* Fixed cookie unescaping in login commands. -* *(bridgev2/portal)* Added special `DefaultChatName` constant to explicitly - reset portal names to the default (based on members). -* *(bridgev2/config)* Added options to disable room tag bridging. -* *(bridgev2/database)* Fixed reaction queries not including portal receiver. -* *(appservice)* Updated [MSC2409] stable registration field name from - `push_ephemeral` to `receive_ephemeral`. Homeserver admins must update - existing registrations manually. -* *(format)* Added support for `img` tags. -* *(format/mdext)* Added goldmark extensions for Matrix math and custom emojis. -* *(event/reply)* Removed support for generating reply fallbacks ([MSC2781]). -* *(pushrules)* Added support for `sender_notification_permission` condition - kind (used for `@room` mentions). -* *(crypto)* Added support for `json.RawMessage` in `EncryptMegolmEvent`. -* *(mediaproxy)* Added `GetMediaResponseCallback` and `GetMediaResponseFile` - to write proxied data directly to http response or temp file instead of - having to use an `io.Reader`. -* *(mediaproxy)* Dropped support for legacy media download endpoints. -* *(mediaproxy,bridgev2)* Made interface pass through query parameters. - -[MSC2781]: https://github.com/matrix-org/matrix-spec-proposals/pull/2781 - -## v0.21.1 (2024-10-16) - -* *(bridgev2)* Added more features and fixed bugs. -* *(hicli)* Added more features and fixed bugs. -* *(appservice)* Removed TLS support. A reverse proxy should be used if TLS - is needed. -* *(format/mdext)* Added goldmark extension to fix indented paragraphs when - disabling indented code block parser. -* *(event)* Added `Has` method for `Mentions`. -* *(event)* Added basic support for the unstable version of polls. - -## v0.21.0 (2024-09-16) - -* **Breaking change *(client)*** Dropped support for unauthenticated media. - Matrix v1.11 support is now required from the homeserver, although it's not - enforced using `/versions` as some servers don't advertise it. -* *(bridgev2)* Added more features and fixed bugs. -* *(appservice,crypto)* Added support for using MSC3202 for appservice - encryption. -* *(crypto/olm)* Made everything into an interface to allow side-by-side - testing of libolm and goolm, as well as potentially support vodozemac - in the future. -* *(client)* Fixed requests being retried even after context is canceled. -* *(client)* Added option to move `/sync` request logs to trace level. -* *(error)* Added `Write` and `WithMessage` helpers to `RespError` to make it - easier to use on servers. -* *(event)* Fixed `org.matrix.msc1767.audio` field allowing omitting the - duration and waveform. -* *(id)* Changed `MatrixURI` methods to not panic if the receiver is nil. -* *(federation)* Added limit to response size when fetching `.well-known` files. - -## v0.20.0 (2024-08-16) - -* Bumped minimum Go version to 1.22. -* *(bridgev2)* Added more features and fixed bugs. -* *(event)* Added types for [MSC4144]: Per-message profiles. -* *(federation)* Added implementation of server name resolution and a basic - client for making federation requests. -* *(crypto/ssss)* Changed recovery key/passphrase verify functions to take the - key ID as a parameter to ensure it's correctly set even if the key metadata - wasn't fetched via `GetKeyData`. -* *(format/mdext)* Added goldmark extensions for single-character bold, italic - and strikethrough parsing (as in `*foo*` -> **foo**, `_foo_` -> _foo_ and - `~foo~` -> ~~foo~~) -* *(format)* Changed `RenderMarkdown` et al to always include `m.mentions` in - returned content. The mention list is filled with matrix.to URLs from the - input by default. - -[MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144 - -## v0.19.0 (2024-07-16) - -* Renamed `master` branch to `main`. -* *(bridgev2)* Added more features. -* *(crypto)* Fixed bug with copying `m.relates_to` from wire content to - decrypted content. -* *(mediaproxy)* Added module for implementing simple media repos that proxy - requests elsewhere. -* *(client)* Changed `Members()` to automatically parse event content for all - returned events. -* *(bridge)* Added `/register` call if `/versions` fails with `M_FORBIDDEN`. -* *(crypto)* Fixed `DecryptMegolmEvent` sometimes calling database without - transaction by using the non-context version of `ResolveTrust`. -* *(crypto/attachment)* Implemented `io.Seeker` in `EncryptStream` to allow - using it in retriable HTTP requests. -* *(event)* Added helper method to add user ID to a `Mentions` object. -* *(event)* Fixed default power level for invites - (thanks to [@rudis] in [#250]). -* *(client)* Fixed incorrect warning log in `State()` when state store returns - no error (thanks to [@rudis] in [#249]). -* *(crypto/verificationhelper)* Fixed deadlock when ignoring unknown - cancellation events (thanks to [@rudis] in [#247]). - -[@rudis]: https://github.com/rudis -[#250]: https://github.com/mautrix/go/pull/250 -[#249]: https://github.com/mautrix/go/pull/249 -[#247]: https://github.com/mautrix/go/pull/247 - -### beta.1 (2024-06-16) - -* *(bridgev2)* Added experimental high-level bridge framework. -* *(hicli)* Added experimental high-level client framework. -* **Slightly breaking changes** - * *(crypto)* Added room ID and first known index parameters to - `SessionReceived` callback. - * *(crypto)* Changed `ImportRoomKeyFromBackup` to return the imported - session. - * *(client)* Added `error` parameter to `ResponseHook`. - * *(client)* Changed `Download` to return entire response instead of just an - `io.Reader`. -* *(crypto)* Changed initial olm device sharing to save keys before sharing to - ensure keys aren't accidentally regenerated in case the request fails. -* *(crypto)* Changed `EncryptMegolmEvent` and `ShareGroupSession` to return - more errors instead of only logging and ignoring them. -* *(crypto)* Added option to completely disable megolm ratchet tracking. - * The tracking is meant for bots and bridges which may want to delete old - keys, but for normal clients it's just unnecessary overhead. -* *(crypto)* Changed Megolm session storage methods in `Store` to not take - sender key as parameter. - * This causes a breaking change to the layout of the `MemoryStore` struct. - Using MemoryStore in production is not recommended. -* *(crypto)* Changed `DecryptMegolmEvent` to copy `m.relates_to` in the raw - content too instead of only in the parsed struct. -* *(crypto)* Exported function to parse megolm message index from raw - ciphertext bytes. -* *(crypto/sqlstore)* Fixed schema of `crypto_secrets` table to include - account ID. -* *(crypto/verificationhelper)* Fixed more bugs. -* *(client)* Added `UpdateRequestOnRetry` hook which is called immediately - before retrying a normal HTTP request. -* *(client)* Added support for MSC3916 media download endpoint. - * Support is automatically detected from spec versions. The `SpecVersions` - property can either be filled manually, or `Versions` can be called to - automatically populate the field with the response. -* *(event)* Added constants for known room versions. - -## v0.18.1 (2024-04-16) - -* *(format)* Added a `context.Context` field to HTMLParser's Context struct. -* *(bridge)* Added support for handling join rules, knocks, invites and bans - (thanks to [@maltee1] in [#193] and [#204]). -* *(crypto)* Changed forwarded room key handling to only accept keys with a - lower first known index than the existing session if there is one. -* *(crypto)* Changed key backup restore to assume own device list is up to date - to avoid re-requesting device list for every deleted device that has signed - key backup. -* *(crypto)* Fixed memory cache not being invalidated when storing own - cross-signing keys - -[@maltee1]: https://github.com/maltee1 -[#193]: https://github.com/mautrix/go/pull/193 -[#204]: https://github.com/mautrix/go/pull/204 - -## v0.18.0 (2024-03-16) - -* **Breaking change *(client, bridge, appservice)*** Dropped support for - maulogger. Only zerolog loggers are now provided by default. -* *(bridge)* Fixed upload size limit not having a default if the server - returned no value. -* *(synapseadmin)* Added wrappers for some room and user admin APIs. - (thanks to [@grvn-ht] in [#181]). -* *(crypto/verificationhelper)* Fixed bugs. -* *(crypto)* Fixed key backup uploading doing too much base64. -* *(crypto)* Changed `EncryptMegolmEvent` to return an error if persisting the - megolm session fails. This ensures that database errors won't cause messages - to be sent with duplicate indexes. -* *(crypto)* Changed `GetOrRequestSecret` to use a callback instead of returning - the value directly. This allows validating the value in order to ignore - invalid secrets. -* *(id)* Added `ParseCommonIdentifier` function to parse any Matrix identifier - in the [Common Identifier Format]. -* *(federation)* Added simple key server that passes the federation tester. - -[@grvn-ht]: https://github.com/grvn-ht -[#181]: https://github.com/mautrix/go/pull/181 -[Common Identifier Format]: https://spec.matrix.org/v1.9/appendices/#common-identifier-format - -### beta.1 (2024-02-16) - -* Bumped minimum Go version to 1.21. -* *(bridge)* Bumped minimum Matrix spec version to v1.4. -* **Breaking change *(crypto)*** Deleted old half-broken interactive - verification code and replaced it with a new `verificationhelper`. - * The new verification helper is still experimental. - * Both QR and emoji verification are supported (in theory). -* *(crypto)* Added support for server-side key backup. -* *(crypto)* Added support for receiving and sending secrets like cross-signing - private keys via secret sharing. -* *(crypto)* Added support for tracking which devices megolm sessions were - initially shared to, and allowing re-sharing the keys to those sessions. -* *(client)* Changed cross-signing key upload method to accept a callback for - user-interactive auth instead of only hardcoding password support. -* *(appservice)* Dropped support for legacy non-prefixed appservice paths - (e.g. `/transactions` instead of `/_matrix/app/v1/transactions`). -* *(appservice)* Dropped support for legacy `access_token` authorization in - appservice endpoints. -* *(bridge)* Fixed `RawArgs` field in command events of command state callbacks. -* *(appservice)* Added `CreateFull` helper function for creating an `AppService` - instance with all the mandatory fields set. - -## v0.17.0 (2024-01-16) - -* **Breaking change *(bridge)*** Added raw event to portal membership handling - functions. -* **Breaking change *(everything)*** Added context parameters to all functions - (started by [@recht] in [#144]). -* **Breaking change *(client)*** Moved event source from sync event handler - function parameters to the `Mautrix.EventSource` field inside the event - struct. -* **Breaking change *(client)*** Moved `EventSource` to `event.Source`. -* *(client)* Removed deprecated `OldEventIgnorer`. The non-deprecated version - (`Client.DontProcessOldEvents`) is still available. -* *(crypto)* Added experimental pure Go Olm implementation to replace libolm - (thanks to [@DerLukas15] in [#106]). - * You can use the `goolm` build tag to the new implementation. -* *(bridge)* Added context parameter for bridge command events. -* *(bridge)* Added method to allow custom validation for the entire config. -* *(client)* Changed default syncer to not drop unknown events. - * The syncer will still drop known events if parsing the content fails. - * The behavior can be changed by changing the `ParseErrorHandler` function. -* *(crypto)* Fixed some places using math/rand instead of crypto/rand. - -[@DerLukas15]: https://github.com/DerLukas15 -[@recht]: https://github.com/recht -[#106]: https://github.com/mautrix/go/pull/106 -[#144]: https://github.com/mautrix/go/pull/144 - -## v0.16.2 (2023-11-16) - -* *(event)* Added `Redacts` field to `RedactionEventContent` for room v11+. -* *(event)* Added `ReverseTextToHTML` which reverses the changes made by - `TextToHTML` (i.e. unescapes HTML characters and replaces `
      ` with `\n`). -* *(bridge)* Added global zerologger to ensure all logs go through the bridge - logger. -* *(bridge)* Changed encryption error messages to be sent in a thread if the - message that failed to decrypt was in a thread. - -## v0.16.1 (2023-09-16) - -* **Breaking change *(id)*** Updated user ID localpart encoding to not encode - `+` as per [MSC4009]. -* *(bridge)* Added bridge utility to handle double puppeting logins. - * The utility supports automatic logins with all three current methods - (shared secret, legacy appservice, new appservice). -* *(appservice)* Added warning logs and timeout on appservice event handling. - * Defaults to warning after 30 seconds and timeout 15 minutes after that. - * Timeouts can be adjusted or disabled by setting `ExecSync` variables in the - `EventProcessor`. -* *(crypto/olm)* Added `PkDecryption` wrapper. - -[MSC4009]: https://github.com/matrix-org/matrix-spec-proposals/pull/4009 - -## v0.16.0 (2023-08-16) - -* Bumped minimum Go version to 1.20. -* **Breaking change *(util)*** Moved package to [go.mau.fi/util](https://go.mau.fi/util/) -* *(event)* Removed MSC2716 `historical` field in the `m.room.power_levels` - event content struct. -* *(bridge)* Added `--version-json` flag to print bridge version info as JSON. -* *(appservice)* Added option to use custom transaction handler for websocket mode. - -## v0.15.4 (2023-07-16) - -* *(client)* Deprecated MSC2716 methods and added new Beeper-specific batch - send methods, as upstream MSC2716 support has been abandoned. -* *(client)* Added proper error handling and automatic retries to media - downloads. -* *(crypto, bridge)* Added option to remove all keys that were received before - the automatic ratcheting was implemented (in v0.15.1). -* *(dbutil)* Added `JSON` utility for writing/reading arbitrary JSON objects to - the db conveniently without manually de/serializing. - -## v0.15.3 (2023-06-16) - -* *(synapseadmin)* Added wrappers for some Synapse admin API endpoints. -* *(pushrules)* Implemented new `event_property_is` and `event_property_contains` - push rule condition kinds as per MSC3758 and MSC3966. -* *(bridge)* Moved websocket code from mautrix-imessage to enable all bridges - to use appservice websockets easily. -* *(bridge)* Added retrying for appservice pings. -* *(types)* Removed unstable field for MSC3952 (intentional mentions). -* *(client)* Deprecated `OldEventIgnorer` and added `Client.DontProcessOldEvents` - to replace it. -* *(client)* Added `MoveInviteState` sync handler for moving state events in - the invite section of sync inside the invite event itself. -* *(crypto)* Added option to not rotate keys when devices change. -* *(crypto)* Added additional duplicate message index check if decryption fails - because the keys had been ratcheted forward. -* *(client)* Stabilized support for asynchronous uploads. - * `UnstableCreateMXC` and `UnstableUploadAsync` were renamed to `CreateMXC` - and `UploadAsync` respectively. -* *(util/dbutil)* Added option to use a separate database connection pool for - read-only transactions. - * This is mostly meant for SQLite and it enables read-only transactions that - don't lock the database, even when normal transactions are configured to - acquire a write lock immediately. -* *(util/dbutil)* Enabled caller info in zerolog by default. - -## v0.15.2 (2023-05-16) - -* *(client)* Changed member-fetching methods to clear existing member info in - state store. -* *(client)* Added support for inserting mautrix-go commit hash into default - user agent at compile time. -* *(bridge)* Fixed bridge bot intent not having state store set. -* *(client)* Fixed `RespError` marshaling mutating the `ExtraData` map and - potentially causing panics. -* *(util/dbutil)* Added `DoTxn` method for an easier way to manage database - transactions. -* *(util)* Added a zerolog `CallerMarshalFunc` implementation that includes the - function name. -* *(bridge)* Added error reply to encrypted messages if the bridge isn't - configured to do encryption. - -## v0.15.1 (2023-04-16) - -* *(crypto, bridge)* Added options to automatically ratchet/delete megolm - sessions to minimize access to old messages. -* *(pushrules)* Added method to get entire push rule that matched (instead of - only the list of actions). -* *(pushrules)* Deprecated `NotifySpecified` as there's no reason to read it. -* *(crypto)* Changed `max_age` column in `crypto_megolm_inbound_session` table - to be milliseconds instead of nanoseconds. -* *(util)* Added method for iterating `RingBuffer`. -* *(crypto/cryptohelper)* Changed decryption errors to request session from all - own devices in addition to the sender, instead of only asking the sender. -* *(sqlstatestore)* Fixed `FindSharedRooms` throwing an error when using from - a non-bridge context. -* *(client)* Optimized `AccountDataSyncStore` to not resend save requests if - the sync token didn't change. -* *(types)* Added `Clone()` method for `PowerLevelEventContent`. - -## v0.15.0 (2023-03-16) - -### beta.3 (2023-03-15) - -* **Breaking change *(appservice)*** Removed `Load()` and `AppService.Init()` - functions. The struct should just be created with `Create()` and the relevant - fields should be filled manually. -* **Breaking change *(appservice)*** Removed public `HomeserverURL` field and - replaced it with a `SetHomeserverURL` method. -* *(appservice)* Added support for unix sockets for homeserver URL and - appservice HTTP server. -* *(client)* Changed request logging to log durations as floats instead of - strings (using zerolog's `Dur()`, so the exact output can be configured). -* *(bridge)* Changed zerolog to use nanosecond precision timestamps. -* *(crypto)* Added message index to log after encrypting/decrypting megolm - events, and when failing to decrypt due to duplicate index. -* *(sqlstatestore)* Fixed warning log for rooms that don't have encryption - enabled. - -### beta.2 (2023-03-02) - -* *(bridge)* Fixed building with `nocrypto` tag. -* *(bridge)* Fixed legacy logging config migration not disabling file writer - when `file_name_format` was empty. -* *(bridge)* Added option to require room power level to run commands. -* *(event)* Added structs for [MSC3952]: Intentional Mentions. -* *(util/variationselector)* Added `FullyQualify` method to add necessary emoji - variation selectors without adding all possible ones. - -[MSC3952]: https://github.com/matrix-org/matrix-spec-proposals/pull/3952 - -### beta.1 (2023-02-24) - -* Bumped minimum Go version to 1.19. -* **Breaking changes** - * *(all)* Switched to zerolog for logging. - * The `Client` and `Bridge` structs still include a legacy logger for - backwards compatibility. - * *(client, appservice)* Moved `SQLStateStore` from appservice module to the - top-level (client) module. - * *(client, appservice)* Removed unused `Typing` map in `SQLStateStore`. - * *(client)* Removed unused `SaveRoom` and `LoadRoom` methods in `Storer`. - * *(client, appservice)* Removed deprecated `SendVideo` and `SendImage` methods. - * *(client)* Replaced `AppServiceUserID` field with `SetAppServiceUserID` boolean. - The `UserID` field is used as the value for the query param. - * *(crypto)* Renamed `GobStore` to `MemoryStore` and removed the file saving - features. The data can still be persisted, but the persistence part must be - implemented separately. - * *(crypto)* Removed deprecated `DeviceIdentity` alias - (renamed to `id.Device` long ago). - * *(client)* Removed `Stringifable` interface as it's the same as `fmt.Stringer`. -* *(client)* Renamed `Storer` interface to `SyncStore`. A type alias exists for - backwards-compatibility. -* *(crypto/cryptohelper)* Added package for a simplified crypto interface for clients. -* *(example)* Added e2ee support to example using crypto helper. -* *(client)* Changed default syncer to stop syncing on `M_UNKNOWN_TOKEN` errors. - -## v0.14.0 (2023-02-16) - -* **Breaking change *(format)*** Refactored the HTML parser `Context` to have - more data. -* *(id)* Fixed escaping path components when forming matrix.to URLs - or `matrix:` URIs. -* *(bridge)* Bumped default timeouts for decrypting incoming messages. -* *(bridge)* Added `RawArgs` to commands to allow accessing non-split input. -* *(bridge)* Added `ReplyAdvanced` to commands to allow setting markdown - settings. -* *(event)* Added `notifications` key to `PowerLevelEventContent`. -* *(event)* Changed `SetEdit` to cut off edit fallback if the message is long. -* *(util)* Added `SyncMap` as a simple generic wrapper for a map with a mutex. -* *(util)* Added `ReturnableOnce` as a wrapper for `sync.Once` with a return - value. - -## v0.13.0 (2023-01-16) - -* **Breaking change:** Removed `IsTyping` and `SetTyping` in `appservice.StateStore` - and removed the `TypingStateStore` struct implementing those methods. -* **Breaking change:** Removed legacy fields in Beeper MSS events. -* Added knocked rooms to sync response structs. -* Added wrapper for `/timestamp_to_event` endpoint added in Matrix v1.6. -* Fixed MSC3870 uploads not failing properly after using up the max retry count. -* Fixed parsing non-positive ordered list start positions in HTML parser. - -## v0.12.4 (2022-12-16) - -* Added `SendReceipt` to support private read receipts and thread receipts in - the same function. `MarkReadWithContent` is now deprecated. -* Changed media download methods to return errors if the server returns a - non-2xx status code. -* Removed legacy `sql_store_upgrade.Upgrade` method. Using `store.DB.Upgrade()` - after `NewSQLCryptoStore(...)` is recommended instead (the bridge module does - this automatically). -* Added missing `suggested` field to `m.space.child` content struct. -* Added `device_unused_fallback_key_types` to `/sync` response and appservice - transaction structs. -* Changed `ReqSetReadMarkers` to omit empty fields. -* Changed bridge configs to force `sqlite3-fk-wal` instead of `sqlite3`. -* Updated bridge helper to close database connection when stopping. -* Fixed read receipt and account data endpoints sending `null` instead of an - empty object as the body when content isn't provided. - -## v0.12.3 (2022-11-16) - -* **Breaking change:** Added logging for row iteration in the dbutil package. - This changes the return type of `Query` methods from `*sql.Rows` to a new - `dbutil.Rows` interface. -* Added flag to disable wrapping database upgrades in a transaction (e.g. to - allow setting `PRAGMA`s for advanced table mutations on SQLite). -* Deprecated `MessageEventContent.GetReplyTo` in favor of directly using - `RelatesTo.GetReplyTo`. RelatesTo methods are nil-safe, so checking if - RelatesTo is nil is not necessary for using those methods. -* Added wrapper for space hierarchyendpoint (thanks to [@mgcm] in [#100]). -* Added bridge config option to handle transactions asynchronously. -* Added separate channels for to-device events in appservice transaction - handler to avoid blocking to-device events behind normal events. -* Added `RelatesTo.GetNonFallbackReplyTo` utility method to get the reply event - ID, unless the reply is a thread fallback. -* Added `event.TextToHTML` as an utility method to HTML-escape a string and - replace newlines with `
      `. -* Added check to bridge encryption helper to make sure the e2ee keys are still - on the server. Synapse is known to sometimes lose keys randomly. -* Changed bridge crypto syncer to crash on `M_UNKNOWN_TOKEN` errors instead of - retrying forever pointlessly. -* Fixed verifying signatures of fallback one-time keys. - -[@mgcm]: https://github.com/mgcm -[#100]: https://github.com/mautrix/go/pull/100 - -## v0.12.2 (2022-10-16) - -* Added utility method to redact bridge commands. -* Added thread ID field to read receipts to match Matrix v1.4 changes. -* Added automatic fetching of media repo config at bridge startup to make it - easier for bridges to check homeserver media size limits. -* Added wrapper for the `/register/available` endpoint. -* Added custom user agent to all requests mautrix-go makes. The value can be - customized by changing the `DefaultUserAgent` variable. -* Implemented [MSC3664], [MSC3862] and [MSC3873] in the push rule evaluator. -* Added workaround for potential race conditions in OTK uploads when using - appservice encryption ([MSC3202]). -* Fixed generating registrations to use `.+` instead of `[0-9]+` in the - username regex. -* Fixed panic in megolm session listing methods if the store contains withheld - key entries. -* Fixed missing header in bridge command help messages. - -[MSC3664]: https://github.com/matrix-org/matrix-spec-proposals/pull/3664 -[MSC3862]: https://github.com/matrix-org/matrix-spec-proposals/pull/3862 -[MSC3873]: https://github.com/matrix-org/matrix-spec-proposals/pull/3873 - -## v0.12.1 (2022-09-16) - -* Bumped minimum Go version to 1.18. -* Added `omitempty` for a bunch of fields in response structs to make them more - usable for server implementations. -* Added `util.RandomToken` to generate GitHub-style access tokens with checksums. -* Added utilities to call the push gateway API. -* Added `unread_notifications` and [MSC2654] `unread_count` fields to /sync - response structs. -* Implemented [MSC3870] for uploading and downloading media directly to/from an - external media storage like S3. -* Fixed dbutil database ownership checks on SQLite. -* Fixed typo in unauthorized encryption key withheld code - (`m.unauthorized` -> `m.unauthorised`). -* Fixed [MSC2409] support to have a separate field for to-device events. - -[MSC2654]: https://github.com/matrix-org/matrix-spec-proposals/pull/2654 -[MSC3870]: https://github.com/matrix-org/matrix-spec-proposals/pull/3870 - -## v0.12.0 (2022-08-16) - -* **Breaking change:** Switched `Client.UserTyping` to take a `time.Duration` - instead of raw `int64` milliseconds. -* **Breaking change:** Removed custom reply relation type and switched to using - the wire format (nesting in `m.in_reply_to`). -* Added device ID to appservice OTK count map to match updated [MSC3202]. - This is also a breaking change, but the previous incorrect behavior wasn't - implemented by anything other than mautrix-syncproxy/imessage. -* (There are probably other breaking changes too). -* Added database utility and schema upgrade framework - * Originally from mautrix-whatsapp, but usable for non-bridges too - * Includes connection wrapper to log query durations and mutate queries for - SQLite compatibility (replacing `$x` with `?x`). -* Added bridge utilities similar to mautrix-python. Currently includes: - * Crypto helper - * Startup flow - * Command handling and some standard commands - * Double puppeting things - * Generic parts of config, basic config validation - * Appservice SQL state store -* Added alternative markdown spoiler parsing extension that doesn't support - reasons, but works better otherwise. -* Added Discord underline markdown parsing extension (`_foo_` -> foo). -* Added support for parsing spoilers and color tags in the HTML parser. -* Added support for mutating plain text nodes in the HTML parser. -* Added room version field to the create room request struct. -* Added empty JSON object as default request body for all non-GET requests. -* Added wrapper for `/capabilities` endpoint. -* Added `omitempty` markers for lots of structs to make the structs easier to - use on the server side too. -* Added support for registering to-device event handlers via the default - Syncer's `OnEvent` and `OnEventType` methods. -* Fixed `CreateEventContent` using the wrong field name for the room version - field. -* Fixed `StopSync` not immediately cancelling the sync loop if it was sleeping - after a failed sync. -* Fixed `GetAvatarURL` always returning the current user's avatar instead of - the specified user's avatar (thanks to [@nightmared] in [#83]). -* Improved request logging and added new log when a request finishes. -* Crypto store improvements: - * Deleted devices are now kept in the database. - * Made ValidateMessageIndex atomic. -* Moved `appservice.RandomString` to the `util` package and made it use - `crypto/rand` instead of `math/rand`. -* Significantly improved cross-signing validation code. - * There are now more options for required trust levels, - e.g. you can set `SendKeysMinTrust` to `id.TrustStateCrossSignedTOFU` - to trust the first cross-signing master key seen and require all devices - to be signed by that key. - * Trust state of incoming messages is automatically resolved and stored in - `evt.Mautrix.TrustState`. This can be used to reject incoming messages from - untrusted devices. - -[@nightmared]: https://github.com/nightmared -[#83]: https://github.com/mautrix/go/pull/83 - -## v0.11.1 (2023-01-15) - -* Fixed parsing non-positive ordered list start positions in HTML parser - (backport of the same fix in v0.13.0). - -## v0.11.0 (2022-05-16) - -* Bumped minimum Go version to 1.17. -* Switched from `/r0` to `/v3` paths everywhere. - * The new `v3` paths are implemented since Synapse 1.48, Dendrite 0.6.5, and - Conduit 0.4.0. Servers older than these are no longer supported. -* Switched from blackfriday to goldmark for markdown parsing in the `format` - module and added spoiler syntax. -* Added `EncryptInPlace` and `DecryptInPlace` methods for attachment encryption. - In most cases the plain/ciphertext is not necessary after en/decryption, so - the old `Encrypt` and `Decrypt` are deprecated. -* Added wrapper for `/rooms/.../aliases`. -* Added utility for adding/removing emoji variation selectors to match - recommendations on reactions in Matrix. -* Added support for async media uploads ([MSC2246]). -* Added automatic sleep when receiving 429 error - (thanks to [@ownaginatious] in [#44]). -* Added support for parsing spec version numbers from the `/versions` endpoint. -* Removed unstable prefixed constant used for appservice login. -* Fixed URL encoding not working correctly in some cases. - -[MSC2246]: https://github.com/matrix-org/matrix-spec-proposals/pull/2246 -[@ownaginatious]: https://github.com/ownaginatious -[#44]: https://github.com/mautrix/go/pull/44 - -## v0.10.12 (2022-03-16) - -* Added option to use a different `Client` to send invites in - `IntentAPI.EnsureJoined`. -* Changed `MessageEventContent` struct to omit empty `msgtype`s in the output - JSON, as sticker events shouldn't have that field. -* Fixed deserializing the `thumbnail_file` field in `FileInfo`. -* Fixed bug that broke `SQLCryptoStore.FindDeviceByKey`. - -## v0.10.11 (2022-02-16) - -* Added automatic updating of state store from `IntentAPI` calls. -* Added option to ignore cache in `IntentAPI.EnsureJoined`. -* Added `GetURLPreview` as a wrapper for the `/preview_url` media repo endpoint. -* Moved base58 module inline to avoid pulling in btcd as a dependency. - -## v0.10.10 (2022-01-16) - -* Added event types and content structs for server ACLs and moderation policy - lists (thanks to [@qua3k] in [#59] and [#60]). -* Added optional parameter to `Client.LeaveRoom` to pass a `reason` field. - -[#59]: https://github.com/mautrix/go/pull/59 -[#60]: https://github.com/mautrix/go/pull/60 - -## v0.10.9 (2022-01-04) - -* **Breaking change:** Changed `Messages()` to take a filter as a parameter - instead of using the syncer's filter (thanks to [@qua3k] in [#55] and [#56]). - * The previous filter behavior was completely broken, as it sent a whole - filter instead of just a RoomEventFilter. - * Passing `nil` as the filter is fine and will disable filtering - (which is equivalent to what it did before with the invalid filter). -* Added `Context()` wrapper for the `/context` API (thanks to [@qua3k] in [#54]). -* Added utility for converting media files with ffmpeg. - -[#54]: https://github.com/mautrix/go/pull/54 -[#55]: https://github.com/mautrix/go/pull/55 -[#56]: https://github.com/mautrix/go/pull/56 -[@qua3k]: https://github.com/qua3k - -## v0.10.8 (2021-12-30) - -* Added `OlmSession.Describe()` to wrap `olm_session_describe`. -* Added trace logs to log olm session descriptions when encrypting/decrypting - to-device messages. -* Added space event types and content structs. -* Added support for power level content override field in `CreateRoom`. -* Fixed ordering of olm sessions which would cause an old session to be used in - some cases even after a client created a new session. - -## v0.10.7 (2021-12-16) - -* Changed `Client.RedactEvent` to allow arbitrary fields in redaction request. - -## v0.10.5 (2021-12-06) - -* Fixed websocket disconnection not clearing all pending requests. -* Added `OlmMachine.SendRoomKeyRequest` as a more direct way of sending room - key requests. -* Added automatic Olm session recreation if an incoming message fails to decrypt. -* Changed `Login` to only omit request content from logs if there's a password - or token (appservice logins don't have sensitive content). - -## v0.10.4 (2021-11-25) - -* Added `reason` field to invite and unban requests - (thanks to [@ptman] in [#48]). -* Fixed `AppService.HasWebsocket()` returning `true` even after websocket has - disconnected. - -[@ptman]: https://github.com/ptman -[#48]: https://github.com/mautrix/go/pull/48 - -## v0.10.3 (2021-11-18) - -* Added logs about incoming appservice transactions. -* Added support for message send checkpoints (as HTTP requests, similar to the - bridge state reporting system). - -## v0.10.2 (2021-11-15) - -* Added utility method for finding the first supported login flow matching any - of the given types. -* Updated registering appservice ghosts to use `inhibit_login` flag to prevent - lots of unnecessary access tokens from being created. - * If you want to log in as an appservice ghost, you should use [MSC2778]'s - appservice login (e.g. like [mautrix-whatsapp does for e2be](https://github.com/mautrix/whatsapp/blob/v0.2.1/crypto.go#L143-L149)). - -## v0.10.1 (2021-11-05) - -* Removed direct dependency on `pq` - * In order to use some more efficient queries on postgres, you must set - `crypto.PostgresArrayWrapper = pq.Array` if you want to use both postgres - and e2ee. -* Added temporary hack to ignore state events with the MSC2716 historical flag - (to be removed after [matrix-org/synapse#11265] is merged) -* Added received transaction acknowledgements for websocket appservice - transactions. -* Added automatic fallback to move `prev_content` from top level to the - standard location inside `unsigned`. - -[matrix-org/synapse#11265]: https://github.com/matrix-org/synapse/pull/11265 - -## v0.9.31 (2021-10-27) - -* Added `SetEdit` utility function for `MessageEventContent`. - -## v0.9.30 (2021-10-26) - -* Added wrapper for [MSC2716]'s `/batch_send` endpoint. -* Added `MarshalJSON` method for `Event` struct to prevent empty unsigned - structs from being included in the JSON. - -[MSC2716]: https://github.com/matrix-org/matrix-spec-proposals/pull/2716 - -## v0.9.29 (2021-09-30) - -* Added `client.State` method to get full room state. -* Added bridge info structs and event types ([MSC2346]). -* Made response handling more customizable. -* Fixed type of `AuthType` constants. - -[MSC2346]: https://github.com/matrix-org/matrix-spec-proposals/pull/2346 - -## v0.9.28 (2021-09-30) - -* Added `X-Mautrix-Process-ID` to appservice websocket headers to help debug - issues where multiple instances are connecting to the server at the same time. - -## v0.9.27 (2021-09-23) - -* Fixed Go 1.14 compatibility (broken in v0.9.25). -* Added GitHub actions CI to build, test and check formatting on Go 1.14-1.17. - -## v0.9.26 (2021-09-21) - -* Added default no-op logger to `Client` in order to prevent panic when the - application doesn't set a logger. - -## v0.9.25 (2021-09-19) - -* Disabled logging request JSON for sensitive requests like `/login`, - `/register` and other UIA endpoints. Logging can still be enabled by - setting `MAUTRIX_LOG_SENSITIVE_CONTENT` to `yes`. -* Added option to store new homeserver URL from `/login` response well-known data. -* Added option to stream big sync responses via disk to maybe reduce memory usage. -* Fixed trailing slashes in homeserver URL breaking all requests. - -## v0.9.24 (2021-09-03) - -* Added write deadline for appservice websocket connection. - -## v0.9.23 (2021-08-31) - -* Fixed storing e2ee key withheld events in the SQL store. - -## v0.9.22 (2021-08-30) - -* Updated appservice handler to cache multiple recent transaction IDs - instead of only the most recent one. - -## v0.9.21 (2021-08-25) - -* Added liveness and readiness endpoints to appservices. - * The endpoints are the same as mautrix-python: - `/_matrix/mau/live` and `/_matrix/mau/ready` - * Liveness always returns 200 and an empty JSON object by default, - but it can be turned off by setting `appservice.Live` to `false`. - * Readiness defaults to returning 500, and it can be switched to 200 - by setting `appservice.Ready` to `true`. - -## v0.9.20 (2021-08-19) - -* Added crypto store migration for converting all `VARCHAR(255)` columns - to `TEXT` in Postgres databases. - -## v0.9.19 (2021-08-17) - -* Fixed HTML parser outputting two newlines after paragraph tags. - -## v0.9.18 (2021-08-16) - -* Added new `BuildURL` method that does the same as `Client.BuildBaseURL` - but without requiring the `Client` instance. - -## v0.9.17 (2021-07-25) - -* Fixed handling OTK counts and device lists coming in through the appservice - transaction websocket. -* Updated OlmMachine to ignore OTK counts intended for other devices. - -## v0.9.15 (2021-07-16) - -* Added support for [MSC3202] and the to-device part of [MSC2409] in the - appservice package. -* Added support for sending commands through appservice websocket. -* Changed error message JSON field name in appservice error responses to - conform with standard Matrix errors (`message` -> `error`). - -[MSC3202]: https://github.com/matrix-org/matrix-spec-proposals/pull/3202 - -## v0.9.14 (2021-06-17) - -* Added default implementation of `PillConverter` in HTML parser utility. - -## v0.9.13 (2021-06-15) - -* Added support for parsing and generating encoded matrix.to URLs and `matrix:` URIs ([MSC2312](https://github.com/matrix-org/matrix-doc/pull/2312)). -* Updated HTML parser to use new URI parser for parsing user/room pills. - -## v0.9.12 (2021-05-18) - -* Added new method for sending custom data with read receipts - (not currently a part of the spec). - -## v0.9.11 (2021-05-12) - -* Improved debug log for unsupported event types. -* Added VoIP events to GuessClass. -* Added support for parsing strings in VoIP event version field. - -## v0.9.10 (2021-04-29) - -* Fixed `format.RenderMarkdown()` still allowing HTML when both `allowHTML` - and `allowMarkdown` are `false`. - -## v0.9.9 (2021-04-26) - -* Updated appservice `StartWebsocket` to return websocket close info. - -## v0.9.8 (2021-04-20) - -* Added methods for getting room tags and account data. - -## v0.9.7 (2021-04-19) - -* **Breaking change (crypto):** `SendEncryptedToDevice` now requires an event - type parameter. Previously it only allowed sending events of type - `event.ToDeviceForwardedRoomKey`. -* Added content structs for VoIP events. -* Added global mutex for Olm decryption - (previously it was only used for encryption). - -## v0.9.6 (2021-04-15) - -* Added option to retry all HTTP requests when encountering a HTTP network - error or gateway error response (502/503/504) - * Disabled by default, you need to set the `DefaultHTTPRetries` field in - the `AppService` or `Client` struct to enable. - * Can also be enabled with `FullRequest`s `MaxAttempts` field. - -## v0.9.5 (2021-04-06) - -* Reverted update of `golang.org/x/sys` which broke Go 1.14 / darwin/arm. - -## v0.9.4 (2021-04-06) - -* Switched appservices to using shared `http.Client` instance with a in-memory - cookie jar. - -## v0.9.3 (2021-03-26) - -* Made user agent headers easier to configure. -* Improved logging when receiving weird/unhandled to-device events. - -## v0.9.2 (2021-03-15) - -* Fixed type of presence state constants (thanks to [@babolivier] in [#30]). -* Implemented presence state fetching methods (thanks to [@babolivier] in [#29]). -* Added support for sending and receiving commands via appservice transaction websocket. - -[@babolivier]: https://github.com/babolivier -[#29]: https://github.com/mautrix/go/pull/29 -[#30]: https://github.com/mautrix/go/pull/30 - -## v0.9.1 (2021-03-11) - -* Fixed appservice register request hiding actual errors due to UIA error handling. - -## v0.9.0 (2021-03-04) - -* **Breaking change (manual API requests):** `MakeFullRequest` now takes a - `FullRequest` struct instead of individual parameters. `MakeRequest`'s - parameters are unchanged. -* **Breaking change (manual /sync):** `SyncRequest` now requires a `Context` - parameter. -* **Breaking change (end-to-bridge encryption):** - the `uk.half-shot.msc2778.login.application_service` constant used for - appservice login ([MSC2778]) was renamed from `AuthTypeAppservice` - to `AuthTypeHalfyAppservice`. - * The `AuthTypeAppservice` constant now contains `m.login.application_service`, - which is currently only used for registrations, but will also be used for - login once MSC2778 lands in the spec. -* Fixed appservice registration requests to include `m.login.application_service` - as the `type` (re [matrix-org/synapse#9548]). -* Added wrapper for `/logout/all`. - -[MSC2778]: https://github.com/matrix-org/matrix-spec-proposals/pull/2778 -[matrix-org/synapse#9548]: https://github.com/matrix-org/synapse/pull/9548 - -## v0.8.6 (2021-03-02) - -* Added client-side timeout to `mautrix.Client`'s `http.Client` - (defaults to 3 minutes). -* Updated maulogger to fix bug where plaintext file logs wouldn't have newlines. - -## v0.8.5 (2021-02-26) - -* Fixed potential concurrent map writes in appservice `Client` and `Intent` - methods. - -## v0.8.4 (2021-02-24) - -* Added option to output appservice logs as JSON. -* Added new methods for validating user ID localparts. - -## v0.8.3 (2021-02-21) - -* Allowed empty content URIs in parser -* Added functions for device management endpoints - (thanks to [@edwargix] in [#26]). - -[@edwargix]: https://github.com/edwargix -[#26]: https://github.com/mautrix/go/pull/26 - -## v0.8.2 (2021-02-09) - -* Fixed error when removing the user's avatar. - -## v0.8.1 (2021-02-09) - -* Added AccountDataStore to remove the need for persistent local storage other - than the access token (thanks to [@daenney] in [#23]). -* Added support for receiving appservice transactions over websocket. - See for the server-side implementation. -* Fixed error when removing the room avatar. - -[@daenney]: https://github.com/daenney -[#23]: https://github.com/mautrix/go/pull/23 - -## v0.8.0 (2020-12-24) - -* **Breaking change:** the `RateLimited` field in the `Registration` struct is - now a pointer, so that it can be omitted entirely. -* Merged initial SSSS/cross-signing code by [@nikofil]. Interactive verification - doesn't work, but the other things mostly do. -* Added support for authorization header auth in appservices ([MSC2832]). -* Added support for receiving ephemeral events directly ([MSC2409]). -* Fixed `SendReaction()` and other similar methods in the `Client` struct. -* Fixed crypto cgo code panicking in Go 1.15.3+. -* Fixed olm session locks sometime getting deadlocked. - -[MSC2832]: https://github.com/matrix-org/matrix-spec-proposals/pull/2832 -[MSC2409]: https://github.com/matrix-org/matrix-spec-proposals/pull/2409 -[@nikofil]: https://github.com/nikofil diff --git a/mautrix-patched/LICENSE b/mautrix-patched/LICENSE deleted file mode 100644 index a612ad98..00000000 --- a/mautrix-patched/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/mautrix-patched/README.md b/mautrix-patched/README.md deleted file mode 100644 index b1a2edf8..00000000 --- a/mautrix-patched/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# mautrix-go -[![GoDoc](https://pkg.go.dev/badge/maunium.net/go/mautrix)](https://pkg.go.dev/maunium.net/go/mautrix) - -A Golang Matrix framework. Used by [gomuks](https://gomuks.app), -[go-neb](https://github.com/matrix-org/go-neb), -[mautrix-whatsapp](https://github.com/mautrix/whatsapp) -and others. - -Matrix room: [`#go:maunium.net`](https://matrix.to/#/#go:maunium.net) - -This project is based on [matrix-org/gomatrix](https://github.com/matrix-org/gomatrix). -The original project is licensed under [Apache 2.0](https://github.com/matrix-org/gomatrix/blob/master/LICENSE). - -In addition to the basic client API features the original project has, this framework also has: - -* Appservice support (Intent API like mautrix-python, room state storage, etc) -* End-to-end encryption support (incl. key backup, cross-signing, interactive verification, etc) -* High-level module for building puppeting bridges -* Partial federation module (making requests, PDU processing and event authorization) -* A media proxy server which can be used to expose anything as a Matrix media repo -* Wrapper functions for the Synapse admin API -* Structs for parsing event content -* Helpers for parsing and generating Matrix HTML -* Helpers for handling push rules diff --git a/mautrix-patched/appservice/appservice.go b/mautrix-patched/appservice/appservice.go deleted file mode 100644 index 554a463b..00000000 --- a/mautrix-patched/appservice/appservice.go +++ /dev/null @@ -1,434 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "context" - "fmt" - "net" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "strings" - "sync" - "syscall" - "time" - - "github.com/coder/websocket" - "github.com/rs/zerolog" - "golang.org/x/net/publicsuffix" - "gopkg.in/yaml.v3" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// EventChannelSize is the size for the Events channel in Appservice instances. -var EventChannelSize = 64 -var OTKChannelSize = 64 - -// Create creates a blank appservice instance. -func Create() *AppService { - jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) - as := &AppService{ - Log: zerolog.Nop(), - clients: make(map[id.UserID]*mautrix.Client), - intents: make(map[id.UserID]*IntentAPI), - HTTPClient: &http.Client{Timeout: 180 * time.Second, Jar: jar}, - StateStore: mautrix.NewMemoryStateStore().(StateStore), - Router: http.NewServeMux(), - UserAgent: mautrix.DefaultUserAgent, - txnIDC: NewTransactionIDCache(128), - Live: true, - Ready: false, - ProcessID: getDefaultProcessID(), - - Events: make(chan *event.Event, EventChannelSize), - ToDeviceEvents: make(chan *event.Event, EventChannelSize), - OTKCounts: make(chan *mautrix.OTKCount, OTKChannelSize), - DeviceLists: make(chan *mautrix.DeviceLists, EventChannelSize), - QueryHandler: &QueryHandlerStub{}, - - SpecVersions: &mautrix.RespVersions{}, - - DefaultHTTPRetries: 4, - } - - as.Router.HandleFunc("PUT /_matrix/app/v1/transactions/{txnID}", as.PutTransaction) - as.Router.HandleFunc("GET /_matrix/app/v1/rooms/{roomAlias}", as.GetRoom) - as.Router.HandleFunc("GET /_matrix/app/v1/users/{userID}", as.GetUser) - as.Router.HandleFunc("POST /_matrix/app/v1/ping", as.PostPing) - as.Router.HandleFunc("GET /_matrix/mau/live", as.GetLive) - as.Router.HandleFunc("GET /_matrix/mau/ready", as.GetReady) - - return as -} - -// CreateOpts contains the options for initializing a new [AppService] instance. -type CreateOpts struct { - // Required, the registration file data for this appservice. - Registration *Registration - // Required, the homeserver's server_name. - HomeserverDomain string - // Required, the homeserver URL to connect to. Should be either https://address or unix:path - HomeserverURL string - // Required if you want to use the standard HTTP server, optional for websockets (non-standard) - HostConfig HostConfig - // Optional, defaults to a memory state store - StateStore StateStore -} - -// CreateFull creates a fully configured appservice instance that can be [Start]ed and used directly. -func CreateFull(opts CreateOpts) (*AppService, error) { - if opts.HomeserverDomain == "" { - return nil, fmt.Errorf("missing homeserver domain") - } else if opts.HomeserverURL == "" { - return nil, fmt.Errorf("missing homeserver URL") - } else if opts.Registration == nil { - return nil, fmt.Errorf("missing registration") - } - as := Create() - as.HomeserverDomain = opts.HomeserverDomain - as.Host = opts.HostConfig - as.Registration = opts.Registration - err := as.SetHomeserverURL(opts.HomeserverURL) - if err != nil { - return nil, err - } - if opts.StateStore != nil { - as.StateStore = opts.StateStore - } else { - as.StateStore = mautrix.NewMemoryStateStore().(StateStore) - } - return as, nil -} - -var _ StateStore = (*mautrix.MemoryStateStore)(nil) - -// QueryHandler handles room alias and user ID queries from the homeserver. -type QueryHandler interface { - QueryAlias(alias id.RoomAlias) bool - QueryUser(userID id.UserID) bool -} - -type QueryHandlerStub struct{} - -func (qh *QueryHandlerStub) QueryAlias(alias id.RoomAlias) bool { - return false -} - -func (qh *QueryHandlerStub) QueryUser(userID id.UserID) bool { - return false -} - -type WebsocketHandler func(WebsocketCommand) (ok bool, data any) - -type StateStore interface { - mautrix.StateStore - - IsRegistered(ctx context.Context, userID id.UserID) (bool, error) - MarkRegistered(ctx context.Context, userID id.UserID) error - - GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) - GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) - HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) -} - -// AppService is the main config for all appservices. -// It also serves as the appservice instance struct. -type AppService struct { - HomeserverDomain string - hsURLForClient *url.URL - Host HostConfig - - Registration *Registration - Log zerolog.Logger - - txnIDC *TransactionIDCache - - Events chan *event.Event - ToDeviceEvents chan *event.Event - DeviceLists chan *mautrix.DeviceLists - OTKCounts chan *mautrix.OTKCount - QueryHandler QueryHandler - StateStore StateStore - - Router *http.ServeMux - UserAgent string - server *http.Server - HTTPClient *http.Client - botClient *mautrix.Client - botIntent *IntentAPI - SpecVersions *mautrix.RespVersions - - DefaultHTTPRetries int - - Live bool - Ready bool - - clients map[id.UserID]*mautrix.Client - clientsLock sync.RWMutex - intents map[id.UserID]*IntentAPI - intentsLock sync.RWMutex - - ws *websocket.Conn - StopWebsocket func(error) - websocketHandlers map[string]WebsocketHandler - websocketHandlersLock sync.RWMutex - websocketRequests map[int]chan<- *WebsocketCommand - websocketRequestsLock sync.RWMutex - websocketRequestID int32 - // ProcessID is an identifier sent to the websocket proxy for debugging connections - ProcessID string - - WebsocketTransactionHandler WebsocketTransactionHandler - - DoublePuppetValue string - GetProfile func(userID id.UserID, roomID id.RoomID) *event.MemberEventContent -} - -const DoublePuppetKey = "fi.mau.double_puppet_source" -const DoublePuppetTSKey = "fi.mau.double_puppet_ts" - -func getDefaultProcessID() string { - pid := syscall.Getpid() - uid := syscall.Getuid() - hostname, _ := os.Hostname() - return fmt.Sprintf("%s-%d-%d", hostname, uid, pid) -} - -func (as *AppService) PrepareWebsocket() { - as.websocketHandlersLock.Lock() - defer as.websocketHandlersLock.Unlock() - if as.WebsocketTransactionHandler == nil { - as.WebsocketTransactionHandler = as.defaultHandleWebsocketTransaction - } - if as.websocketHandlers == nil { - as.websocketHandlers = make(map[string]WebsocketHandler, 32) - as.websocketHandlers[WebsocketCommandHTTPProxy] = as.WebsocketHTTPProxy - as.websocketRequests = make(map[int]chan<- *WebsocketCommand) - } -} - -// HostConfig contains info about how to host the appservice. -type HostConfig struct { - // Hostname can be an IP address or an absolute path for a unix socket. - Hostname string `yaml:"hostname"` - // Port is required when Hostname is an IP address, optional for unix sockets - Port uint16 `yaml:"port"` -} - -// Address gets the whole address of the Appservice. -func (hc *HostConfig) Address() string { - return fmt.Sprintf("%s:%d", hc.Hostname, hc.Port) -} - -func (hc *HostConfig) IsUnixSocket() bool { - return strings.HasPrefix(hc.Hostname, "/") -} - -func (hc *HostConfig) IsConfigured() bool { - return hc.IsUnixSocket() || hc.Port != 0 -} - -// Save saves this config into a file at the given path. -func (as *AppService) Save(path string) error { - data, err := yaml.Marshal(as) - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} - -// YAML returns the config in YAML format. -func (as *AppService) YAML() (string, error) { - data, err := yaml.Marshal(as) - if err != nil { - return "", err - } - return string(data), nil -} - -// BotMXID returns the user ID corresponding to the appservice's sender_localpart -func (as *AppService) BotMXID() id.UserID { - if as.botClient != nil { - return as.botClient.UserID - } - return id.NewUserID(as.Registration.SenderLocalpart, as.HomeserverDomain) -} - -func (as *AppService) makeIntent(userID id.UserID) *IntentAPI { - as.intentsLock.Lock() - defer as.intentsLock.Unlock() - - intent, ok := as.intents[userID] - if ok { - return intent - } - - localpart, homeserver, err := userID.Parse() - if err != nil || len(localpart) == 0 || homeserver != as.HomeserverDomain { - if err != nil { - as.Log.Error().Err(err). - Str("user_id", userID.String()). - Msg("Failed to parse user ID") - } else if len(localpart) == 0 { - as.Log.Error().Err(err). - Str("user_id", userID.String()). - Msg("Failed to make intent: localpart is empty") - } else if homeserver != as.HomeserverDomain { - as.Log.Error().Err(err). - Str("user_id", userID.String()). - Str("expected_homeserver", as.HomeserverDomain). - Msg("Failed to make intent: homeserver doesn't match") - } - return nil - } - intent = as.NewIntentAPI(localpart) - as.intents[userID] = intent - return intent -} - -// Intent returns an [IntentAPI] object for the given user ID. -// -// This will return nil if the given user ID has an empty localpart, -// or if the server name is not the same as the appservice's HomeserverDomain. -// It does not currently validate that the given user ID is actually in the -// appservice's namespace. Validation may be added later. -func (as *AppService) Intent(userID id.UserID) *IntentAPI { - if userID == as.BotMXID() { - return as.BotIntent() - } - as.intentsLock.RLock() - intent, ok := as.intents[userID] - as.intentsLock.RUnlock() - if !ok { - return as.makeIntent(userID) - } - return intent -} - -// BotIntent returns an [IntentAPI] object for the appservice's sender_localpart user. -func (as *AppService) BotIntent() *IntentAPI { - if as.botIntent == nil { - as.botIntent = as.makeIntent(as.BotMXID()) - } - return as.botIntent -} - -// SetHomeserverURL updates the appservice's homeserver URL. -// -// Note that this does not affect already-created [IntentAPI] or [mautrix.Client] objects, -// so it should not be called after Intent or Client are called. -func (as *AppService) SetHomeserverURL(homeserverURL string) error { - parsedURL, err := url.Parse(homeserverURL) - if err != nil { - return err - } - copied := *parsedURL - as.hsURLForClient = &copied - if as.hsURLForClient.Scheme == "unix" { - as.hsURLForClient.Scheme = "http" - as.hsURLForClient.Host = "unix" - as.hsURLForClient.Path = "" - } else if as.hsURLForClient.Scheme == "" { - as.hsURLForClient.Scheme = "https" - } - as.hsURLForClient.RawPath = as.hsURLForClient.EscapedPath() - - jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) - as.HTTPClient = &http.Client{Timeout: 180 * time.Second, Jar: jar} - if parsedURL.Scheme == "unix" { - as.HTTPClient.Transport = &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", parsedURL.Path) - }, - } - } - return nil -} - -// NewMautrixClient creates a new [mautrix.Client] instance for the given user ID. -// -// This does not do any validation, and it does not cache the client. -// Usually you should prefer [AppService.Client] or [AppService.Intent] over this method. -func (as *AppService) NewMautrixClient(userID id.UserID) *mautrix.Client { - return &mautrix.Client{ - HomeserverURL: as.hsURLForClient, - UserID: userID, - SetAppServiceUserID: true, - AccessToken: as.Registration.AppToken, - UserAgent: as.UserAgent, - StateStore: as.StateStore, - Log: as.Log.With().Stringer("as_user_id", userID).Logger(), - Client: as.HTTPClient, - DefaultHTTPRetries: as.DefaultHTTPRetries, - SpecVersions: as.SpecVersions, - } -} - -// NewExternalMautrixClient creates a new [mautrix.Client] instance for an external user, -// with a token and homeserver URL separate from the main appservice. -// -// This is primarily meant to facilitate double puppeting in bridges, and is used by [bridge.doublePuppetUtil]. -// Non-bridge appservices will likely not need this. -func (as *AppService) NewExternalMautrixClient(userID id.UserID, token string, homeserverURL string) (*mautrix.Client, error) { - client := as.NewMautrixClient(userID) - client.AccessToken = token - client.SetAppServiceUserID = false - if homeserverURL != "" { - client.Client = &http.Client{Timeout: 180 * time.Second} - var err error - client.HomeserverURL, err = mautrix.ParseAndNormalizeBaseURL(homeserverURL) - if err != nil { - return nil, err - } - } - return client, nil -} - -func (as *AppService) makeClient(userID id.UserID) *mautrix.Client { - as.clientsLock.Lock() - defer as.clientsLock.Unlock() - - client, ok := as.clients[userID] - if !ok { - client = as.NewMautrixClient(userID) - as.clients[userID] = client - } - return client -} - -// Client returns the [mautrix.Client] instance for the given user ID. -// -// Unlike [AppService.Intent], this does not do any validation, and will always return a value. -// Usually you should prefer creating intents and using intent methods over direct client methods. -// You can always access the client inside an intent with [IntentAPI.Client]. -func (as *AppService) Client(userID id.UserID) *mautrix.Client { - if userID == as.BotMXID() { - return as.BotClient() - } - as.clientsLock.RLock() - client, ok := as.clients[userID] - as.clientsLock.RUnlock() - if !ok { - return as.makeClient(userID) - } - return client -} - -// BotClient returns the [mautrix.Client] instance for the appservice's sender_localpart user. -// -// Like with the generic Client method, [AppService.BotIntent] should be preferred over this. -func (as *AppService) BotClient() *mautrix.Client { - if as.botClient == nil { - as.botClient = as.makeClient(as.BotMXID()) - } - return as.botClient -} diff --git a/mautrix-patched/appservice/appservice_test.go b/mautrix-patched/appservice/appservice_test.go deleted file mode 100644 index eace1668..00000000 --- a/mautrix-patched/appservice/appservice_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package appservice - -import ( - "context" - "fmt" - "net" - "net/http" - "net/http/httptest" - "path" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestClient_UnixSocket(t *testing.T) { - - tmpDir := t.TempDir() - socket := path.Join(tmpDir, "socket") - - l, err := net.Listen("unix", socket) - assert.NoError(t, err) - ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, ` -{ - "device_id": "ABC1234", - "user_id": "@joe:example.org" -}`) - })) - - ts.Listener.Close() - ts.Listener = l - ts.Start() - defer ts.Close() - as := Create() - as.Registration = &Registration{} - err = as.SetHomeserverURL(fmt.Sprintf("unix://%s", socket)) - assert.NoError(t, err) - client := as.Client("user1") - resp, err := client.Whoami(context.Background()) - assert.NoError(t, err) - assert.Equal(t, "@joe:example.org", string(resp.UserID)) -} diff --git a/mautrix-patched/appservice/eventprocessor.go b/mautrix-patched/appservice/eventprocessor.go deleted file mode 100644 index 4cd2ce4e..00000000 --- a/mautrix-patched/appservice/eventprocessor.go +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "context" - "encoding/json" - "runtime/debug" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" -) - -type ExecMode uint8 - -const ( - AsyncHandlers ExecMode = iota - AsyncLoop - Sync -) - -type EventHandler = func(ctx context.Context, evt *event.Event) -type OTKHandler = func(ctx context.Context, otk *mautrix.OTKCount) -type DeviceListHandler = func(ctx context.Context, lists *mautrix.DeviceLists, since string) - -type EventProcessor struct { - ExecMode ExecMode - - ExecSyncWarnTime time.Duration - ExecSyncTimeout time.Duration - - as *AppService - stop chan struct{} - handlers map[event.Type][]EventHandler - - otkHandlers []OTKHandler - deviceListHandlers []DeviceListHandler -} - -func NewEventProcessor(as *AppService) *EventProcessor { - return &EventProcessor{ - ExecMode: AsyncHandlers, - as: as, - stop: make(chan struct{}, 1), - handlers: make(map[event.Type][]EventHandler), - - ExecSyncWarnTime: 30 * time.Second, - ExecSyncTimeout: 15 * time.Minute, - - otkHandlers: make([]OTKHandler, 0), - deviceListHandlers: make([]DeviceListHandler, 0), - } -} - -func (ep *EventProcessor) On(evtType event.Type, handler EventHandler) { - handlers, ok := ep.handlers[evtType] - if !ok { - handlers = []EventHandler{handler} - } else { - handlers = append(handlers, handler) - } - ep.handlers[evtType] = handlers -} - -func (ep *EventProcessor) PrependHandler(evtType event.Type, handler EventHandler) { - handlers, ok := ep.handlers[evtType] - if !ok { - handlers = []EventHandler{handler} - } else { - handlers = append([]EventHandler{handler}, handlers...) - } - ep.handlers[evtType] = handlers -} - -func (ep *EventProcessor) OnOTK(handler OTKHandler) { - ep.otkHandlers = append(ep.otkHandlers, handler) -} - -func (ep *EventProcessor) OnDeviceList(handler DeviceListHandler) { - ep.deviceListHandlers = append(ep.deviceListHandlers, handler) -} - -func (ep *EventProcessor) recoverFunc(data interface{}) { - if err := recover(); err != nil { - d, _ := json.Marshal(data) - ep.as.Log.Error(). - Str(zerolog.ErrorStackFieldName, string(debug.Stack())). - Interface(zerolog.ErrorFieldName, err). - Str("event_content", string(d)). - Msg("Panic in Matrix event handler") - } -} - -func (ep *EventProcessor) callHandler(ctx context.Context, handler EventHandler, evt *event.Event) { - defer ep.recoverFunc(evt) - handler(ctx, evt) -} - -func (ep *EventProcessor) callOTKHandler(ctx context.Context, handler OTKHandler, otk *mautrix.OTKCount) { - defer ep.recoverFunc(otk) - handler(ctx, otk) -} - -func (ep *EventProcessor) callDeviceListHandler(ctx context.Context, handler DeviceListHandler, dl *mautrix.DeviceLists) { - defer ep.recoverFunc(dl) - handler(ctx, dl, "") -} - -func (ep *EventProcessor) DispatchOTK(ctx context.Context, otk *mautrix.OTKCount) { - for _, handler := range ep.otkHandlers { - go ep.callOTKHandler(ctx, handler, otk) - } -} - -func (ep *EventProcessor) DispatchDeviceList(ctx context.Context, dl *mautrix.DeviceLists) { - for _, handler := range ep.deviceListHandlers { - go ep.callDeviceListHandler(ctx, handler, dl) - } -} - -func (ep *EventProcessor) Dispatch(ctx context.Context, evt *event.Event) { - handlers, ok := ep.handlers[evt.Type] - if !ok { - return - } - switch ep.ExecMode { - case AsyncHandlers: - for _, handler := range handlers { - go ep.callHandler(ctx, handler, evt) - } - case AsyncLoop: - go func() { - for _, handler := range handlers { - ep.callHandler(ctx, handler, evt) - } - }() - case Sync: - if ep.ExecSyncWarnTime == 0 && ep.ExecSyncTimeout == 0 { - for _, handler := range handlers { - ep.callHandler(ctx, handler, evt) - } - return - } - doneChan := make(chan struct{}) - go func() { - for _, handler := range handlers { - ep.callHandler(ctx, handler, evt) - } - close(doneChan) - }() - select { - case <-doneChan: - return - case <-time.After(ep.ExecSyncWarnTime): - log := ep.as.Log.With(). - Str("event_id", evt.ID.String()). - Str("event_type", evt.Type.String()). - Logger() - log.Warn().Msg("Handling event in appservice transaction channel is taking long") - select { - case <-doneChan: - return - case <-time.After(ep.ExecSyncTimeout): - log.Error().Msg("Giving up waiting for event handler") - } - } - } -} -func (ep *EventProcessor) startEvents(ctx context.Context) { - for { - select { - case evt := <-ep.as.Events: - ep.Dispatch(ctx, evt) - case <-ep.stop: - return - } - } -} - -func (ep *EventProcessor) startEncryption(ctx context.Context) { - for { - select { - case evt := <-ep.as.ToDeviceEvents: - ep.Dispatch(ctx, evt) - case otk := <-ep.as.OTKCounts: - ep.DispatchOTK(ctx, otk) - case dl := <-ep.as.DeviceLists: - ep.DispatchDeviceList(ctx, dl) - case <-ep.stop: - return - } - } -} - -func (ep *EventProcessor) Start(ctx context.Context) { - go ep.startEvents(ctx) - go ep.startEncryption(ctx) -} - -func (ep *EventProcessor) Stop() { - close(ep.stop) -} diff --git a/mautrix-patched/appservice/http.go b/mautrix-patched/appservice/http.go deleted file mode 100644 index 27ce6288..00000000 --- a/mautrix-patched/appservice/http.go +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "context" - "encoding/json" - "errors" - "io" - "net" - "net/http" - "strings" - "syscall" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exhttp" - "go.mau.fi/util/exstrings" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// Start starts the HTTP server that listens for calls from the Matrix homeserver. -func (as *AppService) Start() { - as.server = &http.Server{ - Handler: as.Router, - } - var err error - if as.Host.IsUnixSocket() { - err = as.listenUnix() - } else { - as.server.Addr = as.Host.Address() - err = as.listenTCP() - } - if err != nil && !errors.Is(err, http.ErrServerClosed) { - as.Log.Error().Err(err).Msg("Error in HTTP listener") - } else { - as.Log.Debug().Msg("HTTP listener stopped") - } -} - -func (as *AppService) listenUnix() error { - socket := as.Host.Hostname - _ = syscall.Unlink(socket) - defer func() { - _ = syscall.Unlink(socket) - }() - listener, err := net.Listen("unix", socket) - if err != nil { - return err - } - as.Log.Info().Str("socket", socket).Msg("Starting unix socket HTTP listener") - return as.server.Serve(listener) -} - -func (as *AppService) listenTCP() error { - as.Log.Info().Str("address", as.server.Addr).Msg("Starting HTTP listener") - return as.server.ListenAndServe() -} - -func (as *AppService) Stop() { - if as.server == nil { - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = as.server.Shutdown(ctx) - as.server = nil -} - -// CheckServerToken checks if the given request originated from the Matrix homeserver. -func (as *AppService) CheckServerToken(w http.ResponseWriter, r *http.Request) (isValid bool) { - authHeader := r.Header.Get("Authorization") - if !strings.HasPrefix(authHeader, "Bearer ") { - mautrix.MMissingToken.WithMessage("Missing access token").Write(w) - } else if !exstrings.ConstantTimeEqual(authHeader[len("Bearer "):], as.Registration.ServerToken) { - mautrix.MUnknownToken.WithMessage("Invalid access token").Write(w) - } else { - isValid = true - } - return -} - -// PutTransaction handles a /transactions PUT call from the homeserver. -func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) { - if !as.CheckServerToken(w, r) { - return - } - - txnID := r.PathValue("txnID") - if len(txnID) == 0 { - mautrix.MInvalidParam.WithMessage("Missing transaction ID").Write(w) - return - } - defer r.Body.Close() - body, err := io.ReadAll(r.Body) - if err != nil || len(body) == 0 { - mautrix.MNotJSON.WithMessage("Failed to read response body").Write(w) - return - } - log := as.Log.With().Str("transaction_id", txnID).Logger() - // Don't use request context, handling shouldn't be stopped even if the request times out - ctx := context.Background() - ctx = log.WithContext(ctx) - if as.txnIDC.IsProcessed(txnID) { - // Duplicate transaction ID: no-op - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - log.Debug().Msg("Ignoring duplicate transaction") - return - } - - var txn Transaction - err = json.Unmarshal(body, &txn) - if err != nil { - log.Error().Err(err).Msg("Failed to parse transaction content") - mautrix.MBadJSON.WithMessage("Failed to parse transaction content").Write(w) - } else { - as.handleTransaction(ctx, txnID, &txn) - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - } -} - -func (as *AppService) handleTransaction(ctx context.Context, id string, txn *Transaction) { - log := zerolog.Ctx(ctx) - log.Debug().Object("content", txn).Msg("Starting handling of transaction") - if as.Registration.EphemeralEvents { - if txn.EphemeralEvents != nil { - as.handleEvents(ctx, txn.EphemeralEvents, event.EphemeralEventType) - } else if txn.MSC2409EphemeralEvents != nil { - as.handleEvents(ctx, txn.MSC2409EphemeralEvents, event.EphemeralEventType) - } - if txn.ToDeviceEvents != nil { - as.handleEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) - } else if txn.MSC2409ToDeviceEvents != nil { - as.handleEvents(ctx, txn.MSC2409ToDeviceEvents, event.ToDeviceEventType) - } - } - as.handleEvents(ctx, txn.Events, event.UnknownEventType) - if txn.DeviceLists != nil { - as.handleDeviceLists(ctx, txn.DeviceLists) - } else if txn.MSC3202DeviceLists != nil { - as.handleDeviceLists(ctx, txn.MSC3202DeviceLists) - } - if txn.DeviceOTKCount != nil { - as.handleOTKCounts(ctx, txn.DeviceOTKCount) - } else if txn.MSC3202DeviceOTKCount != nil { - as.handleOTKCounts(ctx, txn.MSC3202DeviceOTKCount) - } - as.txnIDC.MarkProcessed(id) - log.Debug().Msg("Finished dispatching events from transaction") -} - -func (as *AppService) handleOTKCounts(ctx context.Context, otks OTKCountMap) { - for userID, devices := range otks { - for deviceID, otkCounts := range devices { - otkCounts.UserID = userID - otkCounts.DeviceID = deviceID - select { - case as.OTKCounts <- &otkCounts: - default: - zerolog.Ctx(ctx).Warn(). - Str("user_id", userID.String()). - Msg("Dropped OTK count update for user because channel is full") - } - } - } -} - -func (as *AppService) handleDeviceLists(ctx context.Context, dl *mautrix.DeviceLists) { - select { - case as.DeviceLists <- dl: - default: - zerolog.Ctx(ctx).Warn().Msg("Dropped device list update because channel is full") - } -} - -func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, defaultTypeClass event.TypeClass) { - log := zerolog.Ctx(ctx) - for _, evt := range evts { - evt.Mautrix.ReceivedAt = time.Now() - if defaultTypeClass != event.UnknownEventType { - if defaultTypeClass == event.EphemeralEventType { - evt.Mautrix.EventSource = event.SourceEphemeral - } else if defaultTypeClass == event.ToDeviceEventType { - evt.Mautrix.EventSource = event.SourceToDevice - } - evt.Type.Class = defaultTypeClass - } else if evt.StateKey != nil { - evt.Mautrix.EventSource = event.SourceTimeline & event.SourceJoin - evt.Type.Class = event.StateEventType - } else { - evt.Mautrix.EventSource = event.SourceTimeline - evt.Type.Class = event.MessageEventType - } - err := evt.Content.ParseRaw(evt.Type) - if errors.Is(err, event.ErrUnsupportedContentType) { - log.Debug().Stringer("event_id", evt.ID).Msg("Not parsing content of unsupported event") - } else if err != nil { - log.Warn().Err(err). - Str("event_id", evt.ID.String()). - Str("event_type", evt.Type.Type). - Str("event_type_class", evt.Type.Class.Name()). - Msg("Failed to parse content of event") - } - - if evt.Type.IsState() { - mautrix.UpdateStateStore(ctx, as.StateStore, evt) - } - var ch chan *event.Event - if evt.Type.Class == event.ToDeviceEventType { - ch = as.ToDeviceEvents - } else { - ch = as.Events - } - select { - case ch <- evt: - default: - log.Warn(). - Str("event_id", evt.ID.String()). - Str("event_type", evt.Type.Type). - Str("event_type_class", evt.Type.Class.Name()). - Msg("Event channel is full") - ch <- evt - } - } -} - -// GetRoom handles a /rooms GET call from the homeserver. -func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) { - if !as.CheckServerToken(w, r) { - return - } - - roomAlias := id.RoomAlias(r.PathValue("roomAlias")) - ok := as.QueryHandler.QueryAlias(roomAlias) - if ok { - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - } else { - mautrix.MNotFound.WithMessage("Alias not found").Write(w) - } -} - -// GetUser handles a /users GET call from the homeserver. -func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) { - if !as.CheckServerToken(w, r) { - return - } - - userID := id.UserID(r.PathValue("userID")) - ok := as.QueryHandler.QueryUser(userID) - if ok { - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - } else { - mautrix.MNotFound.WithMessage("User not found").Write(w) - } -} - -func (as *AppService) PostPing(w http.ResponseWriter, r *http.Request) { - if !as.CheckServerToken(w, r) { - return - } - body, err := io.ReadAll(r.Body) - if err != nil || len(body) == 0 || !json.Valid(body) { - mautrix.MNotJSON.WithMessage("Invalid or missing request body").Write(w) - return - } - - var txn mautrix.ReqAppservicePing - _ = json.Unmarshal(body, &txn) - as.Log.Debug().Str("txn_id", txn.TxnID).Msg("Received ping from homeserver") - - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) -} - -func (as *AppService) GetLive(w http.ResponseWriter, r *http.Request) { - if as.Live { - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - } else { - exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError) - } -} - -func (as *AppService) GetReady(w http.ResponseWriter, r *http.Request) { - if as.Ready { - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - } else { - exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError) - } -} diff --git a/mautrix-patched/appservice/intent.go b/mautrix-patched/appservice/intent.go deleted file mode 100644 index 73e3f5ff..00000000 --- a/mautrix-patched/appservice/intent.go +++ /dev/null @@ -1,562 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "sync" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type IntentAPI struct { - *mautrix.Client - bot *mautrix.Client - as *AppService - Localpart string - UserID id.UserID - - registerLock sync.Mutex - - IsCustomPuppet bool - Registered bool -} - -func (as *AppService) NewIntentAPI(localpart string) *IntentAPI { - userID := id.NewUserID(localpart, as.HomeserverDomain) - bot := as.BotClient() - if userID == bot.UserID { - bot = nil - } - return &IntentAPI{ - Client: as.Client(userID), - bot: bot, - as: as, - Localpart: localpart, - UserID: userID, - - IsCustomPuppet: false, - } -} - -func (intent *IntentAPI) Register(ctx context.Context) error { - _, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{ - Username: intent.Localpart, - Type: mautrix.AuthTypeAppservice, - InhibitLogin: true, - }, nil) - return err -} - -func (intent *IntentAPI) EnsureRegistered(ctx context.Context) error { - if intent.IsCustomPuppet || intent.Registered { - return nil - } - intent.registerLock.Lock() - defer intent.registerLock.Unlock() - isRegistered, err := intent.as.StateStore.IsRegistered(ctx, intent.UserID) - if err != nil { - return fmt.Errorf("failed to check if user is registered: %w", err) - } else if isRegistered { - intent.Registered = true - return nil - } - - err = intent.Register(ctx) - if err != nil && !errors.Is(err, mautrix.MUserInUse) { - return fmt.Errorf("failed to ensure registered: %w", err) - } - err = intent.as.StateStore.MarkRegistered(ctx, intent.UserID) - if err != nil { - return fmt.Errorf("failed to mark user as registered in state store: %w", err) - } - intent.Registered = true - return nil -} - -type EnsureJoinedParams struct { - IgnoreCache bool - BotOverride *mautrix.Client - Via []string -} - -func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...EnsureJoinedParams) error { - var params EnsureJoinedParams - if len(extra) > 1 { - panic("invalid number of extra parameters") - } else if len(extra) == 1 { - params = extra[0] - } - if intent.as.StateStore.IsInRoom(ctx, roomID, intent.UserID) && !params.IgnoreCache { - return nil - } - - err := intent.EnsureRegistered(ctx) - if err != nil { - return fmt.Errorf("failed to ensure joined: %w", err) - } - - var resp *mautrix.RespJoinRoom - if len(params.Via) > 0 { - resp, err = intent.JoinRoom(ctx, roomID.String(), &mautrix.ReqJoinRoom{Via: params.Via}) - } else { - resp, err = intent.JoinRoomByID(ctx, roomID) - } - if err != nil { - bot := intent.bot - if params.BotOverride != nil { - bot = params.BotOverride - } - if !errors.Is(err, mautrix.MForbidden) || bot == nil { - return fmt.Errorf("failed to ensure joined: %w", err) - } - var inviteErr error - if intent.IsCustomPuppet { - _, inviteErr = bot.SendStateEvent(ctx, roomID, event.StateMember, intent.UserID.String(), &event.Content{ - Raw: map[string]any{ - "fi.mau.will_auto_accept": true, - }, - Parsed: &event.MemberEventContent{ - Membership: event.MembershipInvite, - }, - }) - } else { - _, inviteErr = bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{ - UserID: intent.UserID, - }) - } - if inviteErr != nil { - return fmt.Errorf("failed to invite in ensure joined: %w", inviteErr) - } - resp, err = intent.JoinRoomByID(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to ensure joined after invite: %w", err) - } - } - err = intent.as.StateStore.SetMembership(ctx, resp.RoomID, intent.UserID, event.MembershipJoin) - if err != nil { - return fmt.Errorf("failed to set membership in state store: %w", err) - } - return nil -} - -func (intent *IntentAPI) IsDoublePuppet() bool { - return intent.IsCustomPuppet && intent.as.DoublePuppetValue != "" -} - -func (intent *IntentAPI) AddDoublePuppetValue(into any) any { - return intent.AddDoublePuppetValueWithTS(into, 0) -} - -func (intent *IntentAPI) AddDoublePuppetValueWithTS(into any, ts int64) any { - if !intent.IsDoublePuppet() { - return into - } - // Only use ts deduplication feature with appservice double puppeting - if !intent.SetAppServiceUserID { - ts = 0 - } - switch val := into.(type) { - case *map[string]any: - if *val == nil { - valNonPtr := make(map[string]any) - *val = valNonPtr - } - (*val)[DoublePuppetKey] = intent.as.DoublePuppetValue - if ts != 0 { - (*val)[DoublePuppetTSKey] = ts - } - return val - case map[string]any: - val[DoublePuppetKey] = intent.as.DoublePuppetValue - if ts != 0 { - val[DoublePuppetTSKey] = ts - } - return val - case *event.Content: - if val.Raw == nil { - val.Raw = make(map[string]any) - } - val.Raw[DoublePuppetKey] = intent.as.DoublePuppetValue - if ts != 0 { - val.Raw[DoublePuppetTSKey] = ts - } - return val - case event.Content: - if val.Raw == nil { - val.Raw = make(map[string]any) - } - val.Raw[DoublePuppetKey] = intent.as.DoublePuppetValue - if ts != 0 { - val.Raw[DoublePuppetTSKey] = ts - } - return val - default: - content := &event.Content{ - Raw: map[string]any{ - DoublePuppetKey: intent.as.DoublePuppetValue, - }, - Parsed: val, - } - if ts != 0 { - content.Raw[DoublePuppetTSKey] = ts - } - return content - } -} - -func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - contentJSON = intent.AddDoublePuppetValue(contentJSON) - return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...) -} - -// Deprecated: use SendMessageEvent with mautrix.ReqSendEvent.Timestamp instead -func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { - return intent.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) -} - -func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { - if eventType != event.StateMember || stateKey != string(intent.UserID) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - } else if err := intent.EnsureRegistered(ctx); err != nil { - return nil, err - } - contentJSON = intent.AddDoublePuppetValue(contentJSON) - return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...) -} - -// Deprecated: use SendStateEvent with mautrix.ReqSendEvent.Timestamp instead -func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) { - return intent.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, mautrix.ReqSendEvent{Timestamp: ts}) -} - -func (intent *IntentAPI) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return err - } - return intent.Client.StateEvent(ctx, roomID, eventType, stateKey, outContent) -} - -func (intent *IntentAPI) State(ctx context.Context, roomID id.RoomID) (mautrix.RoomStateMap, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - return intent.Client.State(ctx, roomID) -} - -func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID id.RoomID, target id.UserID, membership event.Membership, reason string, extraContent ...map[string]interface{}) (*mautrix.RespSendEvent, error) { - content := &event.MemberEventContent{ - Membership: membership, - Reason: reason, - } - memberContent, err := intent.as.StateStore.TryGetMember(ctx, roomID, target) - if err != nil { - return nil, fmt.Errorf("failed to get old member content from state store: %w", err) - } else if memberContent == nil { - if intent.as.GetProfile != nil { - memberContent = intent.as.GetProfile(target, roomID) - } - if memberContent == nil { - profile, err := intent.GetProfile(ctx, target) - if err != nil { - intent.Log.Debug().Err(err). - Str("target_user_id", target.String()). - Str("membership", string(membership)). - Msg("Failed to get profile to fill new membership event") - } else { - content.Displayname = profile.DisplayName - content.AvatarURL = profile.AvatarURL.CUString() - } - } - } - if memberContent != nil { - content.Displayname = memberContent.Displayname - content.AvatarURL = memberContent.AvatarURL - } - var extra map[string]interface{} - if len(extraContent) > 0 { - extra = extraContent[0] - } - return intent.SendStateEvent(ctx, roomID, event.StateMember, target.String(), &event.Content{ - Parsed: content, - Raw: extra, - }) -} - -func (intent *IntentAPI) JoinRoomByID(ctx context.Context, roomID id.RoomID, extraContent ...map[string]interface{}) (resp *mautrix.RespJoinRoom, err error) { - if intent.IsCustomPuppet || len(extraContent) > 0 { - _, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipJoin, "", extraContent...) - return &mautrix.RespJoinRoom{RoomID: roomID}, err - } - return intent.Client.JoinRoomByID(ctx, roomID) -} - -func (intent *IntentAPI) LeaveRoom(ctx context.Context, roomID id.RoomID, extra ...interface{}) (resp *mautrix.RespLeaveRoom, err error) { - var extraContent map[string]interface{} - leaveReq := &mautrix.ReqLeave{} - for _, item := range extra { - switch val := item.(type) { - case map[string]interface{}: - extraContent = val - case *mautrix.ReqLeave: - leaveReq = val - } - } - if intent.IsCustomPuppet || extraContent != nil { - _, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipLeave, leaveReq.Reason, extraContent) - return &mautrix.RespLeaveRoom{}, err - } - return intent.Client.LeaveRoom(ctx, roomID, leaveReq) -} - -func (intent *IntentAPI) InviteUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqInviteUser, extraContent ...map[string]interface{}) (resp *mautrix.RespInviteUser, err error) { - if intent.IsCustomPuppet || len(extraContent) > 0 { - _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipInvite, req.Reason, extraContent...) - return &mautrix.RespInviteUser{}, err - } - return intent.Client.InviteUser(ctx, roomID, req) -} - -func (intent *IntentAPI) KickUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqKickUser, extraContent ...map[string]interface{}) (resp *mautrix.RespKickUser, err error) { - if intent.IsCustomPuppet || len(extraContent) > 0 { - _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...) - return &mautrix.RespKickUser{}, err - } - return intent.Client.KickUser(ctx, roomID, req) -} - -func (intent *IntentAPI) BanUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespBanUser, err error) { - if intent.IsCustomPuppet || len(extraContent) > 0 { - _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipBan, req.Reason, extraContent...) - return &mautrix.RespBanUser{}, err - } - return intent.Client.BanUser(ctx, roomID, req) -} - -func (intent *IntentAPI) UnbanUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqUnbanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespUnbanUser, err error) { - if intent.IsCustomPuppet || len(extraContent) > 0 { - _, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...) - return &mautrix.RespUnbanUser{}, err - } - return intent.Client.UnbanUser(ctx, roomID, req) -} - -func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id.UserID) *event.MemberEventContent { - member, err := intent.as.StateStore.TryGetMember(ctx, roomID, userID) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err). - Str("room_id", roomID.String()). - Str("user_id", userID.String()). - Msg("Failed to get member from state store") - } - if member == nil { - _ = intent.StateEvent(ctx, roomID, event.StateMember, string(userID), &member) - } - return member -} - -func (intent *IntentAPI) FillPowerLevelCreateEvent(ctx context.Context, roomID id.RoomID, pl *event.PowerLevelsEventContent) error { - if pl.CreateEvent != nil { - return nil - } - var err error - pl.CreateEvent, err = intent.StateStore.GetCreate(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to get create event from cache: %w", err) - } else if pl.CreateEvent != nil { - return nil - } - pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "") - if err != nil { - return fmt.Errorf("failed to get create event from server: %w", err) - } - return nil -} - -func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) { - pl, err = intent.as.StateStore.GetPowerLevels(ctx, roomID) - if err != nil { - err = fmt.Errorf("failed to get cached power levels: %w", err) - return - } - if pl == nil { - pl = &event.PowerLevelsEventContent{} - err = intent.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl) - if err != nil { - return - } - } - if pl.CreateEvent == nil { - pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "") - } - return -} - -func (intent *IntentAPI) SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) (resp *mautrix.RespSendEvent, err error) { - return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &levels) -} - -func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, level int) (*mautrix.RespSendEvent, error) { - pl, err := intent.PowerLevels(ctx, roomID) - if err != nil { - return nil, err - } - - if pl.EnsureUserLevelAs(intent.UserID, userID, level) { - return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl) - } - return nil, nil -} - -func (intent *IntentAPI) SendText(ctx context.Context, roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - return intent.Client.SendText(ctx, roomID, text) -} - -func (intent *IntentAPI) SendNotice(ctx context.Context, roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - return intent.Client.SendNotice(ctx, roomID, text) -} - -func (intent *IntentAPI) RedactEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, extra ...mautrix.ReqRedact) (*mautrix.RespSendEvent, error) { - if err := intent.EnsureJoined(ctx, roomID); err != nil { - return nil, err - } - var req mautrix.ReqRedact - if len(extra) > 0 { - req = extra[0] - } - intent.AddDoublePuppetValue(&req.Extra) - return intent.Client.RedactEvent(ctx, roomID, eventID, req) -} - -func (intent *IntentAPI) SetRoomName(ctx context.Context, roomID id.RoomID, roomName string) (*mautrix.RespSendEvent, error) { - return intent.SendStateEvent(ctx, roomID, event.StateRoomName, "", map[string]interface{}{ - "name": roomName, - }) -} - -func (intent *IntentAPI) SetRoomAvatar(ctx context.Context, roomID id.RoomID, avatarURL id.ContentURI) (*mautrix.RespSendEvent, error) { - return intent.SendStateEvent(ctx, roomID, event.StateRoomAvatar, "", map[string]interface{}{ - "url": avatarURL.String(), - }) -} - -func (intent *IntentAPI) SetRoomTopic(ctx context.Context, roomID id.RoomID, topic string) (*mautrix.RespSendEvent, error) { - return intent.SendStateEvent(ctx, roomID, event.StateTopic, "", map[string]interface{}{ - "topic": topic, - }) -} - -func (intent *IntentAPI) UploadMedia(ctx context.Context, data mautrix.ReqUploadMedia) (*mautrix.RespMediaUpload, error) { - if err := intent.EnsureRegistered(ctx); err != nil { - return nil, err - } - return intent.Client.UploadMedia(ctx, data) -} - -func (intent *IntentAPI) UploadAsync(ctx context.Context, data mautrix.ReqUploadMedia) (*mautrix.RespCreateMXC, error) { - if err := intent.EnsureRegistered(ctx); err != nil { - return nil, err - } - return intent.Client.UploadAsync(ctx, data) -} - -func (intent *IntentAPI) SetDisplayName(ctx context.Context, displayName string) error { - if err := intent.EnsureRegistered(ctx); err != nil { - return err - } - resp, err := intent.Client.GetOwnDisplayName(ctx) - if err != nil { - return fmt.Errorf("failed to check current displayname: %w", err) - } else if resp.DisplayName == displayName { - // No need to update - return nil - } - return intent.Client.SetDisplayName(ctx, displayName) -} - -func (intent *IntentAPI) SetAvatarURL(ctx context.Context, avatarURL id.ContentURI) error { - if err := intent.EnsureRegistered(ctx); err != nil { - return err - } - resp, err := intent.Client.GetOwnAvatarURL(ctx) - if err != nil { - return fmt.Errorf("failed to check current avatar URL: %w", err) - } else if resp.FileID == avatarURL.FileID && resp.Homeserver == avatarURL.Homeserver { - // No need to update - return nil - } - if !avatarURL.IsEmpty() && !intent.SpecVersions.Supports(mautrix.BeeperFeatureHungry) { - // Some homeservers require the avatar to be downloaded before setting it - resp, _ := intent.Download(ctx, avatarURL) - if resp != nil { - _ = resp.Body.Close() - } - } - return intent.Client.SetAvatarURL(ctx, avatarURL) -} - -func (intent *IntentAPI) SetProfileField(ctx context.Context, key string, value any) error { - if err := intent.EnsureRegistered(ctx); err != nil { - return err - } - return intent.Client.SetProfileField(ctx, key, value) -} - -func (intent *IntentAPI) UnstableOverwriteProfile(ctx context.Context, data any) (err error) { - if err := intent.EnsureRegistered(ctx); err != nil { - return err - } - return intent.Client.UnstableOverwriteProfile(ctx, data) -} - -func (intent *IntentAPI) BeeperUpdateProfile(ctx context.Context, data any) error { - if err := intent.EnsureRegistered(ctx); err != nil { - return err - } - return intent.Client.BeeperUpdateProfile(ctx, data) -} - -func (intent *IntentAPI) Whoami(ctx context.Context) (*mautrix.RespWhoami, error) { - if err := intent.EnsureRegistered(ctx); err != nil { - return nil, err - } - return intent.Client.Whoami(ctx) -} - -func (intent *IntentAPI) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error { - if !intent.as.StateStore.IsInvited(ctx, roomID, userID) { - _, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{ - UserID: userID, - }) - if httpErr, ok := err.(mautrix.HTTPError); ok && - httpErr.RespError != nil && - (strings.Contains(httpErr.RespError.Err, "is already in the room") || strings.Contains(httpErr.RespError.Err, "is already joined to room")) { - return nil - } - return err - } - return nil -} diff --git a/mautrix-patched/appservice/ping.go b/mautrix-patched/appservice/ping.go deleted file mode 100644 index 8ea3c7ac..00000000 --- a/mautrix-patched/appservice/ping.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" -) - -func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context) bool { - var pingResp *mautrix.RespAppservicePing - var txnID string - var retryCount int - var err error - const maxRetries = 6 - for { - txnID = intent.TxnID() - pingResp, err = intent.AppservicePing(ctx, intent.as.Registration.ID, txnID) - if err == nil { - break - } - var httpErr mautrix.HTTPError - var pingErrBody string - if errors.As(err, &httpErr) && httpErr.RespError != nil { - if val, ok := httpErr.RespError.ExtraData["body"].(string); ok { - pingErrBody = strings.TrimSpace(val) - } - } - outOfRetries := retryCount >= maxRetries - level := zerolog.ErrorLevel - if outOfRetries { - level = zerolog.FatalLevel - } - evt := zerolog.Ctx(ctx).WithLevel(level).Err(err).Str("txn_id", txnID) - if pingErrBody != "" { - bodyBytes := []byte(pingErrBody) - if json.Valid(bodyBytes) { - evt.RawJSON("body", bodyBytes) - } else { - evt.Str("body", pingErrBody) - } - } - if outOfRetries { - evt.Msg("Homeserver -> appservice connection is not working") - zerolog.Ctx(ctx).Info().Msg("See https://docs.mau.fi/faq/as-ping for more info") - return false - } - evt.Msg("Homeserver -> appservice connection is not working, retrying in 5 seconds...") - time.Sleep(5 * time.Second) - retryCount++ - } - zerolog.Ctx(ctx).Debug(). - Str("txn_id", txnID). - Int64("duration_ms", pingResp.DurationMS). - Msg("Homeserver -> appservice connection works") - return true -} diff --git a/mautrix-patched/appservice/protocol.go b/mautrix-patched/appservice/protocol.go deleted file mode 100644 index 7c493bcb..00000000 --- a/mautrix-patched/appservice/protocol.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "fmt" - "strings" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type OTKCountMap = map[id.UserID]map[id.DeviceID]mautrix.OTKCount -type FallbackKeyMap = map[id.UserID]map[id.DeviceID][]id.KeyAlgorithm - -// Transaction contains a list of events. -type Transaction struct { - Events []*event.Event `json:"events"` - EphemeralEvents []*event.Event `json:"ephemeral,omitempty"` - ToDeviceEvents []*event.Event `json:"to_device,omitempty"` - - DeviceLists *mautrix.DeviceLists `json:"device_lists,omitempty"` - DeviceOTKCount OTKCountMap `json:"device_one_time_keys_count,omitempty"` - FallbackKeys FallbackKeyMap `json:"device_unused_fallback_key_types,omitempty"` - - MSC2409EphemeralEvents []*event.Event `json:"de.sorunome.msc2409.ephemeral,omitempty"` - MSC2409ToDeviceEvents []*event.Event `json:"de.sorunome.msc2409.to_device,omitempty"` - MSC3202DeviceLists *mautrix.DeviceLists `json:"org.matrix.msc3202.device_lists,omitempty"` - MSC3202DeviceOTKCount OTKCountMap `json:"org.matrix.msc3202.device_one_time_keys_count,omitempty"` - MSC3202FallbackKeys FallbackKeyMap `json:"org.matrix.msc3202.device_unused_fallback_key_types,omitempty"` -} - -func (txn *Transaction) MarshalZerologObject(ctx *zerolog.Event) { - ctx.Int("pdu", len(txn.Events)) - if txn.EphemeralEvents != nil { - ctx.Int("edu", len(txn.EphemeralEvents)) - } else if txn.MSC2409EphemeralEvents != nil { - ctx.Int("unstable_edu", len(txn.MSC2409EphemeralEvents)) - } - if txn.ToDeviceEvents != nil { - ctx.Int("to_device", len(txn.ToDeviceEvents)) - } else if txn.MSC2409ToDeviceEvents != nil { - ctx.Int("unstable_to_device", len(txn.MSC2409ToDeviceEvents)) - } - if len(txn.DeviceOTKCount) > 0 { - ctx.Int("otk_count_users", len(txn.DeviceOTKCount)) - } else if len(txn.MSC3202DeviceOTKCount) > 0 { - ctx.Int("unstable_otk_count_users", len(txn.MSC3202DeviceOTKCount)) - } - if txn.DeviceLists != nil { - ctx.Int("device_changes", len(txn.DeviceLists.Changed)) - } else if txn.MSC3202DeviceLists != nil { - ctx.Int("unstable_device_changes", len(txn.MSC3202DeviceLists.Changed)) - } - if txn.FallbackKeys != nil { - ctx.Int("fallback_key_users", len(txn.FallbackKeys)) - } else if txn.MSC3202FallbackKeys != nil { - ctx.Int("unstable_fallback_key_users", len(txn.MSC3202FallbackKeys)) - } -} - -func (txn *Transaction) ContentString() string { - var parts []string - if len(txn.Events) > 0 { - parts = append(parts, fmt.Sprintf("%d PDUs", len(txn.Events))) - } - if len(txn.EphemeralEvents) > 0 { - parts = append(parts, fmt.Sprintf("%d EDUs", len(txn.EphemeralEvents))) - } else if len(txn.MSC2409EphemeralEvents) > 0 { - parts = append(parts, fmt.Sprintf("%d EDUs (unstable)", len(txn.MSC2409EphemeralEvents))) - } - if len(txn.ToDeviceEvents) > 0 { - parts = append(parts, fmt.Sprintf("%d to-device events", len(txn.ToDeviceEvents))) - } else if len(txn.MSC2409ToDeviceEvents) > 0 { - parts = append(parts, fmt.Sprintf("%d to-device events (unstable)", len(txn.MSC2409ToDeviceEvents))) - } - if len(txn.DeviceOTKCount) > 0 { - parts = append(parts, fmt.Sprintf("OTK counts for %d users", len(txn.DeviceOTKCount))) - } else if len(txn.MSC3202DeviceOTKCount) > 0 { - parts = append(parts, fmt.Sprintf("OTK counts for %d users (unstable)", len(txn.MSC3202DeviceOTKCount))) - } - if txn.DeviceLists != nil { - parts = append(parts, fmt.Sprintf("%d device list changes", len(txn.DeviceLists.Changed))) - } else if txn.MSC3202DeviceLists != nil { - parts = append(parts, fmt.Sprintf("%d device list changes (unstable)", len(txn.MSC3202DeviceLists.Changed))) - } - if txn.FallbackKeys != nil { - parts = append(parts, fmt.Sprintf("unused fallback key counts for %d users", len(txn.FallbackKeys))) - } else if txn.MSC3202FallbackKeys != nil { - parts = append(parts, fmt.Sprintf("unused fallback key counts for %d users (unstable)", len(txn.MSC3202FallbackKeys))) - } - return strings.Join(parts, ", ") -} - -// EventListener is a function that receives events. -type EventListener func(evt *event.Event) diff --git a/mautrix-patched/appservice/registration.go b/mautrix-patched/appservice/registration.go deleted file mode 100644 index 54eff716..00000000 --- a/mautrix-patched/appservice/registration.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "os" - "regexp" - - "go.mau.fi/util/random" - "gopkg.in/yaml.v3" -) - -// Registration contains the data in a Matrix appservice registration. -// See https://spec.matrix.org/v1.2/application-service-api/#registration -type Registration struct { - ID string `yaml:"id" json:"id"` - URL string `yaml:"url" json:"url"` - AppToken string `yaml:"as_token" json:"as_token"` - ServerToken string `yaml:"hs_token" json:"hs_token"` - SenderLocalpart string `yaml:"sender_localpart" json:"sender_localpart"` - RateLimited *bool `yaml:"rate_limited,omitempty" json:"rate_limited,omitempty"` - Namespaces Namespaces `yaml:"namespaces" json:"namespaces"` - Protocols []string `yaml:"protocols,omitempty" json:"protocols,omitempty"` - - SoruEphemeralEvents bool `yaml:"de.sorunome.msc2409.push_ephemeral,omitempty" json:"de.sorunome.msc2409.push_ephemeral,omitempty"` - EphemeralEvents bool `yaml:"receive_ephemeral,omitempty" json:"receive_ephemeral,omitempty"` - MSC3202 bool `yaml:"org.matrix.msc3202,omitempty" json:"org.matrix.msc3202,omitempty"` - MSC4190 bool `yaml:"io.element.msc4190,omitempty" json:"io.element.msc4190,omitempty"` -} - -// CreateRegistration creates a Registration with random appservice and homeserver tokens. -func CreateRegistration() *Registration { - return &Registration{ - AppToken: random.String(64), - ServerToken: random.String(64), - } -} - -// LoadRegistration loads a YAML file and turns it into a Registration. -func LoadRegistration(path string) (*Registration, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - reg := &Registration{} - err = yaml.Unmarshal(data, reg) - if err != nil { - return nil, err - } - return reg, nil -} - -// Save saves this Registration into a file at the given path. -func (reg *Registration) Save(path string) error { - data, err := yaml.Marshal(reg) - if err != nil { - return err - } - return os.WriteFile(path, data, 0600) -} - -// YAML returns the registration in YAML format. -func (reg *Registration) YAML() (string, error) { - data, err := yaml.Marshal(reg) - if err != nil { - return "", err - } - return string(data), nil -} - -// Namespaces contains the three areas that appservices can reserve parts of. -type Namespaces struct { - UserIDs NamespaceList `yaml:"users,omitempty" json:"users,omitempty"` - RoomAliases NamespaceList `yaml:"aliases,omitempty" json:"aliases,omitempty"` - RoomIDs NamespaceList `yaml:"rooms,omitempty" json:"rooms,omitempty"` -} - -// Namespace is a reserved namespace in any area. -type Namespace struct { - Regex string `yaml:"regex" json:"regex"` - Exclusive bool `yaml:"exclusive" json:"exclusive"` -} - -type NamespaceList []Namespace - -func (nsl *NamespaceList) Register(regex *regexp.Regexp, exclusive bool) { - ns := Namespace{ - Regex: regex.String(), - Exclusive: exclusive, - } - if nsl == nil { - *nsl = []Namespace{ns} - } else { - *nsl = append(*nsl, ns) - } -} diff --git a/mautrix-patched/appservice/txnid.go b/mautrix-patched/appservice/txnid.go deleted file mode 100644 index 213703c5..00000000 --- a/mautrix-patched/appservice/txnid.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import "sync" - -type TransactionIDCache struct { - array []string - arrayPtr int - hash map[string]struct{} - lock sync.RWMutex -} - -func NewTransactionIDCache(size int) *TransactionIDCache { - return &TransactionIDCache{ - array: make([]string, size), - hash: make(map[string]struct{}), - } -} - -func (txnIDC *TransactionIDCache) IsProcessed(txnID string) bool { - txnIDC.lock.RLock() - _, exists := txnIDC.hash[txnID] - txnIDC.lock.RUnlock() - return exists -} - -func (txnIDC *TransactionIDCache) MarkProcessed(txnID string) { - txnIDC.lock.Lock() - txnIDC.hash[txnID] = struct{}{} - if txnIDC.array[txnIDC.arrayPtr] != "" { - for i := 0; i < len(txnIDC.array)/8; i++ { - delete(txnIDC.hash, txnIDC.array[txnIDC.arrayPtr+i]) - txnIDC.array[txnIDC.arrayPtr+i] = "" - } - } - txnIDC.array[txnIDC.arrayPtr] = txnID - txnIDC.lock.Unlock() -} diff --git a/mautrix-patched/appservice/websocket.go b/mautrix-patched/appservice/websocket.go deleted file mode 100644 index ef65e65a..00000000 --- a/mautrix-patched/appservice/websocket.go +++ /dev/null @@ -1,455 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package appservice - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strings" - "sync" - "sync/atomic" - - "github.com/coder/websocket" - "github.com/rs/zerolog" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - - "maunium.net/go/mautrix" -) - -type WebsocketRequest struct { - ReqID int `json:"id,omitempty"` - Command string `json:"command"` - Data any `json:"data"` -} - -type WebsocketCommand struct { - ReqID int `json:"id,omitempty"` - Command string `json:"command"` - Data json.RawMessage `json:"data"` - - Ctx context.Context `json:"-"` -} - -func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest { - if wsc.ReqID == 0 || wsc.Command == "response" || wsc.Command == "error" { - return nil - } - cmd := "response" - if !ok { - cmd = "error" - } - if err, isError := data.(error); isError { - var errorData json.RawMessage - var jsonErr error - unwrappedErr := err - var prefixMessage string - for unwrappedErr != nil { - errorData, jsonErr = json.Marshal(unwrappedErr) - if len(errorData) > 2 && jsonErr == nil { - prefixMessage = strings.Replace(err.Error(), unwrappedErr.Error(), "", 1) - prefixMessage = strings.TrimRight(prefixMessage, ": ") - break - } - unwrappedErr = errors.Unwrap(unwrappedErr) - } - if errorData != nil { - if !gjson.GetBytes(errorData, "message").Exists() { - errorData, _ = sjson.SetBytes(errorData, "message", err.Error()) - } // else: marshaled error contains a message already - } else { - errorData, _ = sjson.SetBytes(nil, "message", err.Error()) - } - if len(prefixMessage) > 0 { - errorData, _ = sjson.SetBytes(errorData, "prefix_message", prefixMessage) - } - data = errorData - } - return &WebsocketRequest{ - ReqID: wsc.ReqID, - Command: cmd, - Data: data, - } -} - -type WebsocketTransaction struct { - Status string `json:"status"` - TxnID string `json:"txn_id"` - Transaction -} - -type WebsocketTransactionResponse struct { - TxnID string `json:"txn_id"` -} - -type WebsocketMessage struct { - WebsocketTransaction - WebsocketCommand -} - -const ( - WebsocketCloseConnReplaced websocket.StatusCode = 4001 - WebsocketCloseTxnNotAcknowledged websocket.StatusCode = 4002 -) - -type MeowWebsocketCloseCode string - -const ( - MeowServerShuttingDown MeowWebsocketCloseCode = "server_shutting_down" - MeowConnectionReplaced MeowWebsocketCloseCode = "conn_replaced" - MeowTxnNotAcknowledged MeowWebsocketCloseCode = "transactions_not_acknowledged" -) - -var ( - ErrWebsocketManualStop = errors.New("the websocket was disconnected manually") - ErrWebsocketOverridden = errors.New("a new call to StartWebsocket overrode the previous connection") - ErrWebsocketUnknownError = errors.New("an unknown error occurred") - - ErrWebsocketNotConnected = errors.New("websocket not connected") - ErrWebsocketClosed = errors.New("websocket closed before response received") -) - -func (mwcc MeowWebsocketCloseCode) String() string { - switch mwcc { - case MeowServerShuttingDown: - return "the server is shutting down" - case MeowConnectionReplaced: - return "the connection was replaced by another client" - case MeowTxnNotAcknowledged: - return "transactions were not acknowledged" - default: - return string(mwcc) - } -} - -type CloseCommand struct { - Code websocket.StatusCode `json:"-"` - Command string `json:"command"` - Status MeowWebsocketCloseCode `json:"status"` -} - -func (cc CloseCommand) Error() string { - return fmt.Sprintf("websocket: close %d: %s", cc.Code, cc.Status.String()) -} - -func parseCloseError(err error) error { - var closeError websocket.CloseError - if !errors.As(err, &closeError) { - return err - } - var closeCommand CloseCommand - closeCommand.Code = closeError.Code - closeCommand.Command = "disconnect" - if len(closeError.Reason) > 0 { - jsonErr := json.Unmarshal([]byte(closeError.Reason), &closeCommand) - if jsonErr != nil { - return err - } - } - if len(closeCommand.Status) == 0 { - if closeCommand.Code == WebsocketCloseConnReplaced { - closeCommand.Status = MeowConnectionReplaced - } else if closeCommand.Code == websocket.StatusServiceRestart { - closeCommand.Status = MeowServerShuttingDown - } - } - return &closeCommand -} - -func (as *AppService) HasWebsocket() bool { - return as.ws != nil -} - -func (as *AppService) SendWebsocket(ctx context.Context, cmd *WebsocketRequest) error { - ws := as.ws - if cmd == nil { - return nil - } else if ws == nil { - return ErrWebsocketNotConnected - } - wr, err := ws.Writer(ctx, websocket.MessageText) - if err != nil { - return err - } - err = json.NewEncoder(wr).Encode(cmd) - if err != nil { - _ = wr.Close() - return err - } - return wr.Close() -} - -func (as *AppService) clearWebsocketResponseWaiters() { - as.websocketRequestsLock.Lock() - for _, waiter := range as.websocketRequests { - waiter <- &WebsocketCommand{Command: "__websocket_closed"} - } - as.websocketRequests = make(map[int]chan<- *WebsocketCommand) - as.websocketRequestsLock.Unlock() -} - -func (as *AppService) addWebsocketResponseWaiter(reqID int, waiter chan<- *WebsocketCommand) { - as.websocketRequestsLock.Lock() - as.websocketRequests[reqID] = waiter - as.websocketRequestsLock.Unlock() -} - -func (as *AppService) removeWebsocketResponseWaiter(reqID int, waiter chan<- *WebsocketCommand) { - as.websocketRequestsLock.Lock() - existingWaiter, ok := as.websocketRequests[reqID] - if ok && existingWaiter == waiter { - delete(as.websocketRequests, reqID) - } - close(waiter) - as.websocketRequestsLock.Unlock() -} - -type ErrorResponse struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func (er *ErrorResponse) Error() string { - return fmt.Sprintf("%s: %s", er.Code, er.Message) -} - -func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response any) error { - cmd.ReqID = int(atomic.AddInt32(&as.websocketRequestID, 1)) - respChan := make(chan *WebsocketCommand, 1) - as.addWebsocketResponseWaiter(cmd.ReqID, respChan) - defer as.removeWebsocketResponseWaiter(cmd.ReqID, respChan) - err := as.SendWebsocket(ctx, cmd) - if err != nil { - return err - } - select { - case resp := <-respChan: - if resp.Command == "__websocket_closed" { - return ErrWebsocketClosed - } else if resp.Command == "error" { - var respErr ErrorResponse - err = json.Unmarshal(resp.Data, &respErr) - if err != nil { - return fmt.Errorf("failed to parse error JSON: %w", err) - } - return &respErr - } else if response != nil { - err = json.Unmarshal(resp.Data, &response) - if err != nil { - return fmt.Errorf("failed to parse response JSON: %w", err) - } - return nil - } else { - return nil - } - case <-ctx.Done(): - return ctx.Err() - } -} - -func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, any) { - zerolog.Ctx(cmd.Ctx).Warn().Msg("No handler for websocket command") - return false, fmt.Errorf("unknown request type") -} - -func (as *AppService) SetWebsocketCommandHandler(cmd string, handler WebsocketHandler) { - as.websocketHandlersLock.Lock() - as.websocketHandlers[cmd] = handler - as.websocketHandlersLock.Unlock() -} - -type WebsocketTransactionHandler func(ctx context.Context, msg WebsocketMessage) (bool, any) - -func (as *AppService) defaultHandleWebsocketTransaction(ctx context.Context, msg WebsocketMessage) (bool, any) { - if msg.TxnID == "" || !as.txnIDC.IsProcessed(msg.TxnID) { - as.handleTransaction(ctx, msg.TxnID, &msg.Transaction) - } else { - zerolog.Ctx(ctx).Debug(). - Object("content", &msg.Transaction). - Msg("Ignoring duplicate transaction") - } - return true, &WebsocketTransactionResponse{TxnID: msg.TxnID} -} - -func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error), ws *websocket.Conn) { - defer stopFunc(ErrWebsocketUnknownError) - for { - msgType, reader, err := ws.Reader(ctx) - if err != nil { - as.Log.Debug().Err(err).Msg("Error getting reader from websocket") - stopFunc(parseCloseError(err)) - return - } else if msgType != websocket.MessageText { - as.Log.Debug().Msg("Ignoring non-text message from websocket") - continue - } - data, err := io.ReadAll(reader) - if err != nil { - as.Log.Debug().Err(err).Msg("Error reading data from websocket") - stopFunc(parseCloseError(err)) - return - } - var msg WebsocketMessage - err = json.Unmarshal(data, &msg) - if err != nil { - as.Log.Debug().Err(err).Msg("Error parsing JSON received from websocket") - stopFunc(parseCloseError(err)) - return - } - with := as.Log.With(). - Int("req_id", msg.ReqID). - Str("ws_command", msg.Command) - if msg.TxnID != "" { - with = with.Str("transaction_id", msg.TxnID) - } - log := with.Logger() - ctx := log.WithContext(ctx) - if msg.Command == "" || msg.Command == "transaction" { - ok, resp := as.WebsocketTransactionHandler(ctx, msg) - go func() { - err := as.SendWebsocket(ctx, msg.MakeResponse(ok, resp)) - if err != nil { - log.Warn().Err(err).Msg("Failed to send response to websocket transaction") - } else { - log.Debug().Msg("Sent response to transaction") - } - }() - } else if msg.Command == "connect" { - log.Debug().Msg("Websocket connect confirmation received") - } else if msg.Command == "response" || msg.Command == "error" { - as.websocketRequestsLock.RLock() - respChan, ok := as.websocketRequests[msg.ReqID] - if ok { - select { - case respChan <- &msg.WebsocketCommand: - default: - log.Warn().Msg("Failed to handle response: channel didn't accept response") - } - } else { - log.Warn().Msg("Dropping response to unknown request ID") - } - as.websocketRequestsLock.RUnlock() - } else { - log.Debug().Msg("Received websocket command") - as.websocketHandlersLock.RLock() - handler, ok := as.websocketHandlers[msg.Command] - as.websocketHandlersLock.RUnlock() - if !ok { - handler = as.unknownCommandHandler - } - go func() { - okResp, data := handler(msg.WebsocketCommand) - err := as.SendWebsocket(ctx, msg.MakeResponse(okResp, data)) - if err != nil { - log.Error().Err(err).Msg("Failed to send response to websocket command") - } else if okResp { - log.Debug().Msg("Sent success response to websocket command") - } else { - log.Debug().Msg("Sent error response to websocket command") - } - }() - } - } -} - -func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConnect func()) error { - var parsed *url.URL - if baseURL != "" { - var err error - parsed, err = url.Parse(baseURL) - if err != nil { - return fmt.Errorf("failed to parse URL: %w", err) - } - } else { - copiedURL := *as.hsURLForClient - parsed = &copiedURL - } - parsed.Path = path.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync") - if parsed.Scheme == "http" { - parsed.Scheme = "ws" - } else if parsed.Scheme == "https" { - parsed.Scheme = "wss" - } - ws, resp, err := websocket.Dial(ctx, parsed.String(), &websocket.DialOptions{ - HTTPClient: as.HTTPClient, - HTTPHeader: http.Header{ - "Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)}, - "User-Agent": []string{as.BotClient().UserAgent}, - - "X-Mautrix-Process-ID": []string{as.ProcessID}, - "X-Mautrix-Websocket-Version": []string{"3"}, - }, - }) - if resp != nil && resp.StatusCode >= 400 { - var errResp mautrix.RespError - err = json.NewDecoder(resp.Body).Decode(&errResp) - if err != nil { - return fmt.Errorf("websocket request returned HTTP %d with non-JSON body", resp.StatusCode) - } else { - return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrCode, resp.StatusCode, errResp.Err) - } - } else if err != nil { - return fmt.Errorf("failed to open websocket: %w", err) - } - if as.StopWebsocket != nil { - as.StopWebsocket(ErrWebsocketOverridden) - } - closeChan := make(chan error) - closeChanOnce := sync.Once{} - stopFunc := func(err error) { - closeChanOnce.Do(func() { - select { - case closeChan <- err: - default: - as.Log.Warn(). - AnErr("close_error", err). - Msg("Nothing is reading on close channel") - closeChan <- err - as.Log.Warn().Msg("Websocket close completed after being stuck") - } - }) - } - ws.SetReadLimit(50 * 1024 * 1024) - as.ws = ws - as.StopWebsocket = stopFunc - as.PrepareWebsocket() - as.Log.Debug().Msg("Appservice transaction websocket opened") - - go as.consumeWebsocket(ctx, stopFunc, ws) - - var onConnectDone atomic.Bool - if onConnect != nil { - go func() { - onConnect() - onConnectDone.Store(true) - }() - } else { - onConnectDone.Store(true) - } - - closeErr := <-closeChan - if !onConnectDone.Load() { - as.Log.Warn().Msg("Websocket closed before onConnect returned, things may explode") - } - - if as.ws == ws { - as.clearWebsocketResponseWaiters() - as.ws = nil - } - - err = ws.Close(websocket.StatusGoingAway, "") - if err != nil { - as.Log.Warn().Err(err).Msg("Error closing websocket") - } - return closeErr -} diff --git a/mautrix-patched/appservice/wshttp.go b/mautrix-patched/appservice/wshttp.go deleted file mode 100644 index 60741e67..00000000 --- a/mautrix-patched/appservice/wshttp.go +++ /dev/null @@ -1,98 +0,0 @@ -package appservice - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/url" -) - -const WebsocketCommandHTTPProxy = "http_proxy" - -type HTTPProxyRequest struct { - Method string `json:"method"` - Path string `json:"path"` - Query string `json:"query"` - Headers http.Header `json:"headers"` - Body json.RawMessage `json:"body"` - - EscapedPath bool `json:"escaped_path,omitempty"` -} - -type HTTPProxyResponse struct { - Status int `json:"status"` - Headers http.Header `json:"headers"` - Body json.RawMessage `json:"body"` - - bodyBuf bytes.Buffer -} - -func (p *HTTPProxyResponse) Header() http.Header { - return p.Headers -} - -func (p *HTTPProxyResponse) Write(bytes []byte) (int, error) { - if p.Status == 0 { - p.Status = http.StatusOK - } - return p.bodyBuf.Write(bytes) -} - -func (p *HTTPProxyResponse) WriteHeader(statusCode int) { - p.Status = statusCode -} - -func (as *AppService) WebsocketHTTPProxy(cmd WebsocketCommand) (bool, interface{}) { - var req HTTPProxyRequest - if err := json.Unmarshal(cmd.Data, &req); err != nil { - return false, fmt.Errorf("failed to parse proxy request: %w", err) - } - if cmd.Ctx == nil { - cmd.Ctx = context.Background() - } - reqURLStruct := &url.URL{ - Scheme: "http", - Host: "localhost", - Path: req.Path, - RawQuery: req.Query, - } - if req.EscapedPath { - reqURLStruct.RawPath = req.Path - var err error - reqURLStruct.Path, err = url.PathUnescape(req.Path) - if err != nil { - return false, fmt.Errorf("failed to unescape request path: %w", err) - } - } - httpReq, err := http.NewRequestWithContext(cmd.Ctx, req.Method, reqURLStruct.String(), bytes.NewReader(req.Body)) - if err != nil { - return false, fmt.Errorf("failed to create fake HTTP request: %w", err) - } - httpReq.RequestURI = req.Path - if req.Query != "" { - httpReq.RequestURI += "?" + req.Query - } - httpReq.RemoteAddr = "websocket" - httpReq.Header = req.Headers - - var resp HTTPProxyResponse - resp.Headers = make(http.Header) - - as.Router.ServeHTTP(&resp, httpReq) - - if resp.bodyBuf.Len() > 0 { - bodyData := resp.bodyBuf.Bytes() - if json.Valid(bodyData) { - resp.Body = bodyData - } else { - resp.Body = make([]byte, 2+base64.RawStdEncoding.EncodedLen(len(bodyData))) - resp.Body[0] = '"' - base64.RawStdEncoding.Encode(resp.Body[1:], bodyData) - resp.Body[len(resp.Body)-1] = '"' - } - } - return true, &resp -} diff --git a/mautrix-patched/beeperstream/crypto.go b/mautrix-patched/beeperstream/crypto.go deleted file mode 100644 index f4af80e2..00000000 --- a/mautrix-patched/beeperstream/crypto.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2026 Batuhan İçöz -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package beeperstream - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - - "go.mau.fi/util/jsonbytes" - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type innerPayload struct { - Type string `json:"type"` - Content json.RawMessage `json:"content"` -} - -func makeStreamKey() jsonbytes.UnpaddedBytes { - return random.Bytes(32) -} - -func newStreamGCM(key []byte) (cipher.AEAD, error) { - if len(key) != 32 { - return nil, fmt.Errorf("invalid stream key length %d", len(key)) - } - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - return cipher.NewGCM(block) -} - -func deriveStreamID(key []byte, roomID id.RoomID, eventID id.EventID) string { - mac := hmac.New(sha256.New, key) - mac.Write([]byte(roomID)) - mac.Write([]byte(eventID)) - return base64.RawStdEncoding.EncodeToString(mac.Sum(nil)) -} - -func encryptLogicalEvent(logicalType event.Type, payload json.RawMessage, roomID id.RoomID, eventID id.EventID, key []byte) (*event.Content, error) { - if roomID == "" || eventID == "" { - return nil, fmt.Errorf("missing beeper stream identifiers") - } - gcm, err := newStreamGCM(key) - if err != nil { - return nil, err - } - plaintext, err := json.Marshal(innerPayload{ - Type: logicalType.Type, - Content: payload, - }) - if err != nil { - return nil, err - } - iv := random.Bytes(gcm.NonceSize()) - ciphertext := gcm.Seal(nil, iv, plaintext, nil) - return &event.Content{ - Parsed: &event.EncryptedEventContent{ - Algorithm: id.AlgorithmBeeperStreamV1, - StreamID: deriveStreamID(key, roomID, eventID), - IV: iv, - BeeperStreamCiphertext: ciphertext, - }, - }, nil -} - -func decryptLogicalEvent(content *event.Content, key []byte) (event.Type, json.RawMessage, error) { - gcm, err := newStreamGCM(key) - if err != nil { - return event.Type{}, nil, err - } - encrypted := content.AsEncrypted() - if encrypted.Algorithm == "" && content != nil && content.Parsed == nil && len(content.VeryRaw) > 0 { - if err = content.ParseRaw(event.ToDeviceEncrypted); err != nil { - return event.Type{}, nil, err - } - encrypted = content.AsEncrypted() - } - if len(encrypted.IV) != gcm.NonceSize() { - return event.Type{}, nil, fmt.Errorf("invalid beeper stream IV length %d", len(encrypted.IV)) - } - plaintext, err := gcm.Open(nil, encrypted.IV, encrypted.BeeperStreamCiphertext, nil) - if err != nil { - return event.Type{}, nil, err - } - var payload innerPayload - if err = json.Unmarshal(plaintext, &payload); err != nil { - return event.Type{}, nil, err - } - switch payload.Type { - case event.ToDeviceBeeperStreamSubscribe.Type: - return event.ToDeviceBeeperStreamSubscribe, payload.Content, nil - case event.ToDeviceBeeperStreamUpdate.Type: - return event.ToDeviceBeeperStreamUpdate, payload.Content, nil - default: - return event.Type{}, nil, fmt.Errorf("unknown beeper stream event type %q", payload.Type) - } -} diff --git a/mautrix-patched/beeperstream/helper.go b/mautrix-patched/beeperstream/helper.go deleted file mode 100644 index 672ec994..00000000 --- a/mautrix-patched/beeperstream/helper.go +++ /dev/null @@ -1,462 +0,0 @@ -// Copyright (c) 2026 Batuhan İçöz -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package beeperstream - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -const ( - // DefaultDescriptorExpiry is the default lifetime of a beeper stream descriptor. - DefaultDescriptorExpiry = 30 * time.Minute - // DefaultSubscribeExpiry is the default lifetime of a beeper stream subscription. - DefaultSubscribeExpiry = 5 * time.Minute - - defaultRenewInterval = 30 * time.Second - cleanupGrace = 30 * time.Second - pendingSubscribeTTL = 5 * time.Second - maxPendingSubscriptions = 64 - maxStreamUpdatesPerStream = 1024 -) - -type Helper struct { - client *mautrix.Client - log zerolog.Logger - - lock sync.RWMutex - publishedStreams map[streamKey]*publishedStream - encryptedPubl map[string]streamKey - subscriptions map[streamKey]*subscription - encryptedSubs map[string]streamKey - - pendingLock sync.Mutex - pendingSubscribe []pendingSubscribeEvent - - initLock sync.Mutex - initialized bool - closed atomic.Bool - - now func() time.Time -} - -type streamKey struct { - roomID id.RoomID - eventID id.EventID -} - -func New(client *mautrix.Client) (*Helper, error) { - if client == nil { - return nil, fmt.Errorf("beeper stream client is nil") - } - return &Helper{ - client: client, - log: client.Log.With().Str("component", "beeper_stream").Logger(), - publishedStreams: make(map[streamKey]*publishedStream), - encryptedPubl: make(map[string]streamKey), - subscriptions: make(map[streamKey]*subscription), - encryptedSubs: make(map[string]streamKey), - now: time.Now, - }, nil -} - -// Init attaches beeper stream handling to a normal /sync-based client. -func (h *Helper) Init() error { - if h == nil { - return fmt.Errorf("beeper stream helper is nil") - } else if h.closed.Load() { - return fmt.Errorf("beeper stream helper is closed") - } - h.initLock.Lock() - defer h.initLock.Unlock() - if h.initialized { - return nil - } - syncer, ok := h.client.Syncer.(mautrix.ExtensibleSyncer) - if !ok { - return fmt.Errorf("the client syncer must implement ExtensibleSyncer") - } - h.registerIngressAdapter(syncer.OnEventType) - h.initialized = true - return nil -} - -// InitAppservice attaches beeper stream handling to an appservice event processor. -func (h *Helper) InitAppservice(ep interface { - On(event.Type, mautrix.EventHandler) -}) error { - if h == nil { - return fmt.Errorf("beeper stream helper is nil") - } else if h.closed.Load() { - return fmt.Errorf("beeper stream helper is closed") - } else if ep == nil { - return fmt.Errorf("beeper stream appservice event processor is nil") - } - h.initLock.Lock() - defer h.initLock.Unlock() - if h.initialized { - return nil - } - h.registerIngressAdapter(ep.On) - h.initialized = true - return nil -} - -func (h *Helper) registerIngressAdapter( - on func(event.Type, mautrix.EventHandler), -) { - on(event.ToDeviceBeeperStreamSubscribe, h.handleSubscribeEvent) - on(event.ToDeviceBeeperStreamUpdate, h.handleIngressEvent) - on(event.ToDeviceEncrypted, h.handleIngressEvent) -} - -func (h *Helper) Close() error { - if h == nil || !h.closed.CompareAndSwap(false, true) { - return nil - } - - h.lock.Lock() - for key, sub := range h.subscriptions { - sub.cancel() - delete(h.subscriptions, key) - } - clear(h.encryptedSubs) - for key, state := range h.publishedStreams { - if state.cleanup != nil { - state.cleanup.Stop() - } - delete(h.publishedStreams, key) - } - clear(h.encryptedPubl) - h.lock.Unlock() - - h.pendingLock.Lock() - h.pendingSubscribe = nil - h.pendingLock.Unlock() - - return nil -} - -func (h *Helper) NewDescriptor(ctx context.Context, roomID id.RoomID, streamType string) (*event.BeeperStreamInfo, error) { - if h == nil { - return nil, fmt.Errorf("beeper stream helper is nil") - } else if h.closed.Load() { - return nil, fmt.Errorf("beeper stream helper is closed") - } else if roomID == "" || streamType == "" { - return nil, fmt.Errorf("missing beeper stream descriptor request fields") - } - client, err := h.requireClient(false) - if err != nil { - return nil, err - } - info := &event.BeeperStreamInfo{ - UserID: client.UserID, - DeviceID: client.DeviceID, - Type: streamType, - ExpiryMS: DefaultDescriptorExpiry.Milliseconds(), - } - if h.isEncrypted(ctx, roomID) { - info.Encryption = &event.BeeperStreamEncryptionInfo{ - Algorithm: id.AlgorithmBeeperStreamV1, - Key: makeStreamKey(), - } - } - return info, nil -} - -func (h *Helper) HandleSyncResponse(ctx context.Context, resp *mautrix.RespSync) []*event.Event { - if h == nil || resp == nil { - return nil - } - var normalized []*event.Event - for _, evt := range resp.ToDevice.Events { - prepareToDeviceEvent(evt) - normalized = append(normalized, h.handleEvent(ctx, evt)...) - } - return normalized -} - -func (h *Helper) handleIngressEvent(ctx context.Context, evt *event.Event) { - h.handleEvent(ctx, evt) -} - -func prepareToDeviceEvent(evt *event.Event) { - if evt == nil { - return - } - evt.Type.Class = event.ToDeviceEventType - if len(evt.Content.VeryRaw) > 0 && evt.Content.Raw == nil { - _ = json.Unmarshal(evt.Content.VeryRaw, &evt.Content.Raw) - } - if evt.Content.Parsed != nil || len(evt.Content.VeryRaw) == 0 { - return - } - err := evt.Content.ParseRaw(evt.Type) - if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { - evt.Content.Parsed = nil - } -} - -func (h *Helper) handleEvent(ctx context.Context, evt *event.Event) []*event.Event { - if h == nil || evt == nil || h.closed.Load() || h.isForDifferentTarget(evt) { - return nil - } - switch evt.Type { - case event.ToDeviceBeeperStreamSubscribe: - h.handleSubscribeEvent(ctx, evt) - case event.ToDeviceBeeperStreamUpdate: - return expandStreamUpdateEvent(evt) - case event.ToDeviceEncrypted: - return h.handleEncryptedEvent(ctx, evt) - } - return nil -} - -func (h *Helper) handleEncryptedEvent(ctx context.Context, evt *event.Event) []*event.Event { - content := evt.Content.AsEncrypted() - if content.Algorithm != id.AlgorithmBeeperStreamV1 { - return nil - } - if content.StreamID == "" || len(content.BeeperStreamCiphertext) == 0 { - return nil - } - h.lock.RLock() - publishedKey, hasPublished := h.encryptedPubl[content.StreamID] - published := h.publishedStreams[publishedKey] - subKey, hasSub := h.encryptedSubs[content.StreamID] - sub := h.subscriptions[subKey] - h.lock.RUnlock() - if hasPublished && published != nil { - return h.handleEncryptedForPublisher(ctx, evt, publishedKey, published) - } - if hasSub && sub != nil { - return h.handleEncryptedForSubscriber(ctx, evt, subKey, sub) - } - h.queuePendingSubscribe(ctx, evt) - return nil -} - -func decryptedLogicalEvent(ctx context.Context, evt *event.Event, key []byte, expectedKey streamKey, expectedTypes ...event.Type) *event.Event { - encrypted := evt.Content.AsEncrypted() - logicalType, payload, err := decryptLogicalEvent(&evt.Content, key) - if err != nil { - zerolog.Ctx(ctx).Debug().Err(err). - Str("stream_id", encrypted.StreamID). - Msg("Failed to decrypt beeper stream event") - return nil - } - if len(expectedTypes) > 0 && !containsType(expectedTypes, logicalType) { - return nil - } - parsed, err := contentFromRawJSON(payload) - if err != nil || parsed.ParseRaw(logicalType) != nil { - return nil - } - if !validateLogicalRouting(parsed, logicalType, expectedKey.roomID, expectedKey.eventID) { - return nil - } - normalized := *evt - normalized.RoomID = expectedKey.roomID - normalized.Type = logicalType - normalized.Type.Class = event.ToDeviceEventType - normalized.Content = *parsed - return &normalized -} - -func rawMapFromContent(content *event.Content) (map[string]any, error) { - if content == nil { - return map[string]any{}, nil - } - if content.Raw != nil { - return maps.Clone(content.Raw), nil - } - if len(content.VeryRaw) == 0 { - return map[string]any{}, nil - } - var raw map[string]any - err := json.Unmarshal(content.VeryRaw, &raw) - if raw == nil { - raw = make(map[string]any) - } - return raw, err -} - -func contentFromRawMap(raw map[string]any) (*event.Content, error) { - if raw == nil { - raw = make(map[string]any) - } - veryRaw, err := json.Marshal(raw) - if err != nil { - return nil, err - } - return &event.Content{VeryRaw: veryRaw, Raw: maps.Clone(raw)}, nil -} - -func contentFromRawJSON(veryRaw json.RawMessage) (*event.Content, error) { - content := &event.Content{VeryRaw: append(json.RawMessage(nil), veryRaw...)} - if len(content.VeryRaw) == 0 { - content.VeryRaw = []byte("{}") - } - if err := json.Unmarshal(content.VeryRaw, &content.Raw); err != nil { - return nil, err - } - if content.Raw == nil { - content.Raw = make(map[string]any) - } - return content, nil -} - -func marshalContent(content *event.Content) (json.RawMessage, error) { - if content == nil { - return json.RawMessage(`{}`), nil - } - raw, err := content.MarshalJSON() - if err != nil { - return nil, err - } - return append(json.RawMessage(nil), raw...), nil -} - -func normalizeUpdateContent(roomID id.RoomID, eventID id.EventID, content map[string]any) (*event.Content, error) { - if roomID == "" || eventID == "" { - return nil, fmt.Errorf("missing beeper stream identifiers") - } - raw := maps.Clone(content) - if _, ok := raw["room_id"]; ok { - return nil, fmt.Errorf("beeper stream payload may not override room_id") - } else if _, ok := raw["event_id"]; ok { - return nil, fmt.Errorf("beeper stream payload may not override event_id") - } - raw["room_id"] = roomID - raw["event_id"] = eventID - return contentFromRawMap(raw) -} - -func stripUpdateRouting(content map[string]any) map[string]any { - raw := maps.Clone(content) - delete(raw, "room_id") - delete(raw, "event_id") - delete(raw, "updates") - return raw -} - -func expandStreamUpdateEvent(evt *event.Event) []*event.Event { - if evt == nil { - return nil - } - update := evt.Content.AsBeeperStreamUpdate() - if len(update.Updates) == 0 { - return []*event.Event{evt} - } - expanded := make([]*event.Event, 0, len(update.Updates)) - for _, delta := range update.Updates { - content, err := normalizeUpdateContent(update.RoomID, update.EventID, delta) - if err != nil { - return nil - } - if err = content.ParseRaw(event.ToDeviceBeeperStreamUpdate); err != nil { - return nil - } - normalized := *evt - normalized.Content = *content - expanded = append(expanded, &normalized) - } - return expanded -} - -func containsType(types []event.Type, want event.Type) bool { - for _, candidate := range types { - if candidate == want { - return true - } - } - return false -} - -func validateLogicalRouting(content *event.Content, evtType event.Type, roomID id.RoomID, eventID id.EventID) bool { - switch evtType { - case event.ToDeviceBeeperStreamSubscribe: - subscribe := content.AsBeeperStreamSubscribe() - return subscribe.RoomID == roomID && subscribe.EventID == eventID - case event.ToDeviceBeeperStreamUpdate: - update := content.AsBeeperStreamUpdate() - return update.RoomID == roomID && update.EventID == eventID - default: - return false - } -} - -func (h *Helper) isEncrypted(ctx context.Context, roomID id.RoomID) bool { - if h == nil || h.client == nil || h.client.StateStore == nil { - return false - } - encrypted, err := h.client.StateStore.IsEncrypted(ctx, roomID) - return err == nil && encrypted -} - -func (h *Helper) isForDifferentTarget(evt *event.Event) bool { - if h == nil || h.client == nil || evt == nil { - return false - } - return (evt.ToUserID != "" && evt.ToUserID != h.client.UserID) || - (evt.ToDeviceID != "" && evt.ToDeviceID != h.client.DeviceID) -} - -func (h *Helper) requireClient(requireDevice bool) (*mautrix.Client, error) { - if h == nil || h.client == nil { - return nil, fmt.Errorf("beeper stream helper doesn't have a client") - } else if h.client.UserID == "" { - return nil, fmt.Errorf("beeper stream client isn't logged in") - } else if requireDevice && h.client.DeviceID == "" { - return nil, fmt.Errorf("beeper stream client doesn't have a device ID") - } - return h.client, nil -} - -func resolveSubscribeExpiry(descriptor *event.BeeperStreamInfo, fallback time.Duration) time.Duration { - if fallback <= 0 { - fallback = DefaultSubscribeExpiry - } - if descriptor != nil && descriptor.ExpiryMS > 0 { - return min(time.Duration(descriptor.ExpiryMS)*time.Millisecond, fallback) - } - return fallback -} - -func resolveMaxBufferedUpdates(descriptor *event.BeeperStreamInfo) int { - if descriptor != nil && descriptor.MaxBufferedUpdates > 0 { - return descriptor.MaxBufferedUpdates - } - return maxStreamUpdatesPerStream -} - -func descriptorEqual(a, b *event.BeeperStreamInfo) bool { - switch { - case a == nil || b == nil: - return a == b - case a.UserID != b.UserID || a.DeviceID != b.DeviceID || a.Type != b.Type || a.ExpiryMS != b.ExpiryMS || a.MaxBufferedUpdates != b.MaxBufferedUpdates: - return false - case a.Encryption == nil || b.Encryption == nil: - return a.Encryption == b.Encryption - default: - return a.Encryption.Algorithm == b.Encryption.Algorithm && - bytes.Equal(a.Encryption.Key, b.Encryption.Key) - } -} diff --git a/mautrix-patched/beeperstream/helper_test.go b/mautrix-patched/beeperstream/helper_test.go deleted file mode 100644 index 7ae7eb3f..00000000 --- a/mautrix-patched/beeperstream/helper_test.go +++ /dev/null @@ -1,824 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package beeperstream - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -const ( - testStreamRoomID id.RoomID = "!room:example.com" - testStreamEventID id.EventID = "$event" - testStreamType = "com.beeper.llm" - testStreamBotUserID id.UserID = "@bot:example.com" - testStreamSubscriberID id.UserID = "@alice:example.com" - testStreamSubscriberDev id.DeviceID = "SUBDEVICE" - testStreamPublisherDev id.DeviceID = "PUBLISHER" - testStreamDeltaKey = "com.beeper.llm.deltas" -) - -type capturedSendToDeviceRequest struct { - path string - body []byte -} - -type sendToDeviceRecorder struct { - requests chan capturedSendToDeviceRequest -} - -type testEventProcessor struct { - handlers map[event.Type][]func(context.Context, *event.Event) -} - -func newTestEventProcessor() *testEventProcessor { - return &testEventProcessor{ - handlers: make(map[event.Type][]func(context.Context, *event.Event)), - } -} - -func (ep *testEventProcessor) On(evtType event.Type, handler func(context.Context, *event.Event)) { - ep.handlers[evtType] = append(ep.handlers[evtType], handler) -} - -func (ep *testEventProcessor) Dispatch(ctx context.Context, evt *event.Event) { - for _, handler := range ep.handlers[evt.Type] { - handler(ctx, evt) - } -} - -func newSendToDeviceRecorderServer(t *testing.T) (*httptest.Server, *sendToDeviceRecorder) { - t.Helper() - recorder := &sendToDeviceRecorder{ - requests: make(chan capturedSendToDeviceRequest, 8), - } - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPut, r.Method) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - recorder.requests <- capturedSendToDeviceRequest{ - path: r.URL.Path, - body: body, - } - _ = json.NewEncoder(w).Encode(map[string]any{}) - })) - t.Cleanup(ts.Close) - return ts, recorder -} - -func (r *sendToDeviceRecorder) next(t *testing.T) capturedSendToDeviceRequest { - t.Helper() - select { - case req := <-r.requests: - return req - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for sendToDevice request") - return capturedSendToDeviceRequest{} - } -} - -func (r *sendToDeviceRecorder) rawContent(t *testing.T, req capturedSendToDeviceRequest, userID id.UserID, deviceID id.DeviceID) json.RawMessage { - t.Helper() - var payload struct { - Messages map[string]map[string]json.RawMessage `json:"messages"` - } - require.NoError(t, json.Unmarshal(req.body, &payload)) - require.Contains(t, payload.Messages, string(userID), "missing target user in request") - require.Contains(t, payload.Messages[string(userID)], string(deviceID), "missing target device in request") - return payload.Messages[string(userID)][string(deviceID)] -} - -func newTestStreamClient(t *testing.T, homeserverURL string, userID id.UserID, deviceID id.DeviceID) *mautrix.Client { - t.Helper() - client, err := mautrix.NewClient(homeserverURL, userID, "access-token") - require.NoError(t, err) - client.DeviceID = deviceID - client.StateStore = mautrix.NewMemoryStateStore() - return client -} - -func newTestHelper(t *testing.T, client *mautrix.Client) *Helper { - t.Helper() - helper, err := New(client) - require.NoError(t, err) - return helper -} - -func newTestPublishContent(delta string) map[string]any { - return map[string]any{ - testStreamDeltaKey: []map[string]any{{"delta": delta}}, - } -} - -func newTestSubscribeEvent(toUserID id.UserID, toDeviceID id.DeviceID) *event.Event { - return &event.Event{ - Sender: testStreamSubscriberID, - ToUserID: toUserID, - ToDeviceID: toDeviceID, - Type: event.ToDeviceBeeperStreamSubscribe, - Content: event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ - RoomID: testStreamRoomID, - EventID: testStreamEventID, - DeviceID: testStreamSubscriberDev, - ExpiryMS: 60_000, - }}, - } -} - -func newTestEncryptedSubscribeEvent(t *testing.T, descriptor *event.BeeperStreamInfo) *event.Event { - t.Helper() - payload, err := marshalContent(&event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ - RoomID: testStreamRoomID, - EventID: testStreamEventID, - DeviceID: testStreamSubscriberDev, - ExpiryMS: 60_000, - }}) - require.NoError(t, err) - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamSubscribe, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) - require.NoError(t, err) - return &event.Event{ - Sender: testStreamSubscriberID, - ToUserID: testStreamBotUserID, - ToDeviceID: testStreamPublisherDev, - Type: event.ToDeviceEncrypted, - Content: *encContent, - } -} - -func newTestDescriptor(encrypted bool) *event.BeeperStreamInfo { - descriptor := &event.BeeperStreamInfo{ - UserID: testStreamBotUserID, - DeviceID: testStreamPublisherDev, - Type: testStreamType, - ExpiryMS: DefaultDescriptorExpiry.Milliseconds(), - } - if encrypted { - descriptor.Encryption = &event.BeeperStreamEncryptionInfo{ - Algorithm: id.AlgorithmBeeperStreamV1, - Key: makeStreamKey(), - } - } - return descriptor -} - -func newTestEncryptedUpdateEvent(t *testing.T, descriptor *event.BeeperStreamInfo) *event.Event { - t.Helper() - content, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("hello")) - require.NoError(t, err) - payload, err := marshalContent(content) - require.NoError(t, err) - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) - require.NoError(t, err) - return &event.Event{ - Sender: testStreamBotUserID, - Type: event.ToDeviceEncrypted, - Content: *encContent, - } -} - -func rawifyEventContent(t *testing.T, evt *event.Event) *event.Event { - t.Helper() - require.NotNil(t, evt) - raw, err := evt.Content.MarshalJSON() - require.NoError(t, err) - return &event.Event{ - Sender: evt.Sender, - Type: evt.Type, - ToUserID: evt.ToUserID, - ToDeviceID: evt.ToDeviceID, - RoomID: evt.RoomID, - ID: evt.ID, - Content: event.Content{ - VeryRaw: raw, - }, - } -} - -func decodeJSONMap(t *testing.T, data []byte) map[string]any { - t.Helper() - var parsed map[string]any - require.NoError(t, json.Unmarshal(data, &parsed)) - return parsed -} - -func assertStreamUpdateMap(t *testing.T, parsed map[string]any) { - t.Helper() - require.Equal(t, string(testStreamRoomID), fmt.Sprint(parsed["room_id"])) - require.Equal(t, string(testStreamEventID), fmt.Sprint(parsed["event_id"])) - require.Contains(t, parsed, testStreamDeltaKey) -} - -func assertBatchedStreamUpdateMap(t *testing.T, parsed map[string]any, wantDeltas ...string) { - t.Helper() - require.Equal(t, string(testStreamRoomID), fmt.Sprint(parsed["room_id"])) - require.Equal(t, string(testStreamEventID), fmt.Sprint(parsed["event_id"])) - updates, ok := parsed["updates"].([]any) - require.True(t, ok) - require.Len(t, updates, len(wantDeltas)) - for i, want := range wantDeltas { - update, ok := updates[i].(map[string]any) - require.True(t, ok) - deltas, ok := update[testStreamDeltaKey].([]any) - require.True(t, ok) - require.Len(t, deltas, 1) - delta, ok := deltas[0].(map[string]any) - require.True(t, ok) - require.Equal(t, want, fmt.Sprint(delta["delta"])) - } -} - -func TestHelperNewDescriptor(t *testing.T) { - client := newTestStreamClient(t, "", testStreamBotUserID, testStreamPublisherDev) - require.NoError(t, client.StateStore.SetEncryptionEvent(context.Background(), testStreamRoomID, &event.EncryptionEventContent{ - Algorithm: id.AlgorithmMegolmV1, - })) - - info, err := newTestHelper(t, client).NewDescriptor(context.Background(), testStreamRoomID, testStreamType) - require.NoError(t, err) - require.Equal(t, testStreamBotUserID, info.UserID) - require.Equal(t, testStreamPublisherDev, info.DeviceID) - require.NotNil(t, info.Encryption) -} - -func TestHelperSubscribeTargetsDescriptorDevice(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(false) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.subscribe/") - _ = recorder.rawContent(t, req, testStreamBotUserID, testStreamPublisherDev) -} - -func TestHelperPublishAndUnregister(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) - require.NoError(t, err) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) - - require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") - assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) - - streams.Unregister(testStreamRoomID, testStreamEventID) - require.Error(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) -} - -func TestHelperRegisterSupportsLatestOnlyReplayBuffer(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(false) - descriptor.MaxBufferedUpdates = 1 - - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) - - require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) - - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") - rawContent := decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev)) - assertStreamUpdateMap(t, rawContent) - deltas, ok := rawContent[testStreamDeltaKey].([]any) - require.True(t, ok) - require.Len(t, deltas, 1) - delta, ok := deltas[0].(map[string]any) - require.True(t, ok) - require.Equal(t, "bye", fmt.Sprint(delta["delta"])) - - select { - case extra := <-recorder.requests: - t.Fatalf("unexpected extra replay update: %s", extra.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperReplayPendingSubscribeBatchesBufferedUpdates(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) - - require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) - - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") - assertBatchedStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev)), "hello", "bye") - - select { - case extra := <-recorder.requests: - t.Fatalf("unexpected extra replay update: %s", extra.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperReplayPendingSubscribeOnRegister(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) - - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") - assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) -} - -func TestHelperReplayPendingEncryptedSubscribeOnRegister(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.Nil(t, streams.handleEvent(context.Background(), newTestEncryptedSubscribeEvent(t, descriptor))) - - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/m.room.encrypted/") - rawContent := recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev) - var encContent event.Content - require.NoError(t, json.Unmarshal(rawContent, &encContent)) - require.NoError(t, encContent.ParseRaw(event.ToDeviceEncrypted)) - require.Equal(t, deriveStreamID(descriptor.Encryption.Key, testStreamRoomID, testStreamEventID), encContent.AsEncrypted().StreamID) - logicalType, payloadContent, err := decryptLogicalEvent(&encContent, descriptor.Encryption.Key) - require.NoError(t, err) - require.Equal(t, event.ToDeviceBeeperStreamUpdate, logicalType) - var parsed map[string]any - require.NoError(t, json.Unmarshal(payloadContent, &parsed)) - assertStreamUpdateMap(t, parsed) -} - -func TestHelperReplayPendingEncryptedSubscribeBatchesBufferedUpdates(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("bye"))) - - require.Nil(t, streams.handleEvent(context.Background(), newTestEncryptedSubscribeEvent(t, descriptor))) - - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/m.room.encrypted/") - rawContent := recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev) - var encContent event.Content - require.NoError(t, json.Unmarshal(rawContent, &encContent)) - require.NoError(t, encContent.ParseRaw(event.ToDeviceEncrypted)) - logicalType, payloadContent, err := decryptLogicalEvent(&encContent, descriptor.Encryption.Key) - require.NoError(t, err) - require.Equal(t, event.ToDeviceBeeperStreamUpdate, logicalType) - var parsed map[string]any - require.NoError(t, json.Unmarshal(payloadContent, &parsed)) - assertBatchedStreamUpdateMap(t, parsed, "hello", "bye") -} - -func TestHelperHandleSyncResponseForwardsBridgeSubscribe(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) - require.NoError(t, err) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) - - streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ - newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev), - }}, - }) - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") - assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) -} - -func TestHelperHandleSyncResponseIgnoresWrongDevice(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) - require.NoError(t, err) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) - - streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ - newTestSubscribeEvent(testStreamBotUserID, "OTHERDEVICE"), - }}, - }) - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - select { - case req := <-recorder.requests: - t.Fatalf("unexpected sendToDevice request: %s", req.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperInitAppserviceForwardsEncryptedBridgeSubscribe(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - processor := newTestEventProcessor() - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.InitAppservice(processor)) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - - processor.Dispatch(context.Background(), newTestEncryptedSubscribeEvent(t, descriptor)) - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/m.room.encrypted/") - rawContent := recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev) - var encContent event.Content - require.NoError(t, json.Unmarshal(rawContent, &encContent)) - require.NoError(t, encContent.ParseRaw(event.ToDeviceEncrypted)) - require.Equal(t, deriveStreamID(descriptor.Encryption.Key, testStreamRoomID, testStreamEventID), encContent.AsEncrypted().StreamID) - logicalType, payloadContent, err := decryptLogicalEvent(&encContent, descriptor.Encryption.Key) - require.NoError(t, err) - require.Equal(t, event.ToDeviceBeeperStreamUpdate, logicalType) - var parsed map[string]any - require.NoError(t, json.Unmarshal(payloadContent, &parsed)) - assertStreamUpdateMap(t, parsed) -} - -func TestHelperHandleSyncResponseIgnoresWrongUser(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) - require.NoError(t, err) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) - - streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ - newTestSubscribeEvent("@other:example.com", testStreamPublisherDev), - }}, - }) - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - select { - case req := <-recorder.requests: - t.Fatalf("unexpected sendToDevice request: %s", req.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperHandleSyncResponseIgnoresWrongDeviceWithoutUserID(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - info, err := streams.NewDescriptor(context.Background(), testStreamRoomID, testStreamType) - require.NoError(t, err) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, info)) - - streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ - newTestSubscribeEvent("", "OTHERDEVICE"), - }}, - }) - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - select { - case req := <-recorder.requests: - t.Fatalf("unexpected sendToDevice request: %s", req.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperHandleSyncResponseReturnsNormalizedEvents(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{newTestEncryptedUpdateEvent(t, descriptor)}}, - }) - - require.Len(t, normalized, 1) - require.Equal(t, event.ToDeviceBeeperStreamUpdate, normalized[0].Type) - require.Equal(t, testStreamRoomID, normalized[0].RoomID) - update := normalized[0].Content.AsBeeperStreamUpdate() - require.Equal(t, testStreamRoomID, update.RoomID) - require.Equal(t, testStreamEventID, update.EventID) - require.Contains(t, normalized[0].Content.Raw, testStreamDeltaKey) -} - -func TestHelperHandleSyncResponseParsesRawEncryptedEvents(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - rawEvt := rawifyEventContent(t, newTestEncryptedUpdateEvent(t, descriptor)) - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{rawEvt}}, - }) - - require.Len(t, normalized, 1) - require.Equal(t, event.ToDeviceBeeperStreamUpdate, normalized[0].Type) - update := normalized[0].Content.AsBeeperStreamUpdate() - require.Equal(t, testStreamRoomID, update.RoomID) - require.Equal(t, testStreamEventID, update.EventID) -} - -func TestHelperHandleSyncResponseExpandsBatchedEncryptedReplay(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - first, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("hello")) - require.NoError(t, err) - second, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("bye")) - require.NoError(t, err) - batched, err := makeReplayUpdateContent([]*event.Content{first, second}) - require.NoError(t, err) - payload, err := marshalContent(batched) - require.NoError(t, err) - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) - require.NoError(t, err) - - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ - Sender: testStreamBotUserID, - Type: event.ToDeviceEncrypted, - Content: *encContent, - }}}, - }) - - require.Len(t, normalized, 2) - for i, want := range []string{"hello", "bye"} { - require.Equal(t, event.ToDeviceBeeperStreamUpdate, normalized[i].Type) - update := normalized[i].Content.AsBeeperStreamUpdate() - require.Equal(t, testStreamRoomID, update.RoomID) - require.Equal(t, testStreamEventID, update.EventID) - raw := normalized[i].Content.Raw - deltas, ok := raw[testStreamDeltaKey].([]any) - require.True(t, ok) - require.Len(t, deltas, 1) - delta, ok := deltas[0].(map[string]any) - require.True(t, ok) - require.Equal(t, want, fmt.Sprint(delta["delta"])) - } -} - -func TestHelperInitIsIdempotent(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(false) - - require.NoError(t, streams.Init()) - require.NoError(t, streams.Init()) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - - syncer := client.Syncer.(*mautrix.DefaultSyncer) - syncer.ProcessResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{ - newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev), - }}, - }, "") - - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - req := recorder.next(t) - require.Contains(t, req.path, "/sendToDevice/com.beeper.stream.update/") - assertStreamUpdateMap(t, decodeJSONMap(t, recorder.rawContent(t, req, testStreamSubscriberID, testStreamSubscriberDev))) - - select { - case extra := <-recorder.requests: - t.Fatalf("unexpected duplicate sendToDevice request after double init: %s", extra.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperRejectsMismatchedEncryptedRouting(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - update, err := normalizeUpdateContent(testStreamRoomID, id.EventID("$other"), newTestPublishContent("hello")) - require.NoError(t, err) - payload, err := marshalContent(update) - require.NoError(t, err) - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) - require.NoError(t, err) - - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ - Sender: testStreamBotUserID, - Type: event.ToDeviceEncrypted, - Content: *encContent, - }}}, - }) - require.Empty(t, normalized) -} - -func TestHelperRejectsUnknownEncryptedLogicalType(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - payload, err := marshalContent(&event.Content{Raw: newTestPublishContent("hello")}) - require.NoError(t, err) - encContent, err := encryptLogicalEvent(event.Type{Type: "com.beeper.stream.unknown", Class: event.ToDeviceEventType}, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) - require.NoError(t, err) - - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ - Sender: testStreamBotUserID, - Type: event.ToDeviceEncrypted, - Content: *encContent, - }}}, - }) - require.Empty(t, normalized) -} - -func TestHelperRejectsEncryptedEventMissingStreamID(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - evt := newTestEncryptedUpdateEvent(t, descriptor) - evt.Content.AsEncrypted().StreamID = "" - - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{evt}}, - }) - require.Empty(t, normalized) -} - -func TestHelperRejectsOldEncryptedRoutingShape(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - descriptor := newTestDescriptor(true) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, descriptor)) - t.Cleanup(func() { streams.Unsubscribe(testStreamRoomID, testStreamEventID) }) - _ = recorder.next(t) - - evt := newTestEncryptedUpdateEvent(t, descriptor) - raw, err := evt.Content.MarshalJSON() - require.NoError(t, err) - - var parsed map[string]any - require.NoError(t, json.Unmarshal(raw, &parsed)) - delete(parsed, "stream_id") - parsed["room_id"] = string(testStreamRoomID) - parsed["event_id"] = string(testStreamEventID) - oldShape, err := json.Marshal(parsed) - require.NoError(t, err) - - normalized := streams.HandleSyncResponse(context.Background(), &mautrix.RespSync{ - ToDevice: mautrix.SyncEventsList{Events: []*event.Event{{ - Sender: testStreamBotUserID, - Type: event.ToDeviceEncrypted, - Content: event.Content{ - VeryRaw: oldShape, - }, - }}}, - }) - require.Empty(t, normalized) -} - -func TestHelperPendingSubscribeExpiresBeforeRegister(t *testing.T) { - ts, recorder := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - now := time.Unix(1_000, 0) - streams.now = func() time.Time { return now } - - require.Nil(t, streams.handleEvent(context.Background(), newTestSubscribeEvent(testStreamBotUserID, testStreamPublisherDev))) - - now = now.Add(pendingSubscribeTTL + time.Second) - require.NoError(t, streams.Register(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) - require.NoError(t, streams.Publish(context.Background(), testStreamRoomID, testStreamEventID, newTestPublishContent("hello"))) - - select { - case req := <-recorder.requests: - t.Fatalf("unexpected sendToDevice request: %s", req.path) - case <-time.After(200 * time.Millisecond): - } -} - -func TestHelperPendingSubscribeQueueTrim(t *testing.T) { - client := newTestStreamClient(t, "", testStreamBotUserID, testStreamPublisherDev) - streams := newTestHelper(t, client) - - for i := 0; i < maxPendingSubscriptions+1; i++ { - evt := &event.Event{ - Sender: testStreamSubscriberID, - Type: event.ToDeviceBeeperStreamSubscribe, - Content: event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ - RoomID: id.RoomID(testStreamRoomID + id.RoomID(fmt.Sprintf("-%d", i))), - EventID: testStreamEventID, - DeviceID: testStreamSubscriberDev, - ExpiryMS: 60_000, - }}, - } - streams.queuePendingSubscribe(context.Background(), evt) - } - - require.Len(t, streams.pendingSubscribe, maxPendingSubscriptions) - require.Equal(t, id.RoomID(testStreamRoomID+"-1"), streams.pendingSubscribe[0].key.roomID) -} - -func TestHelperCloseCancelsSubscriptions(t *testing.T) { - ts, _ := newSendToDeviceRecorderServer(t) - client := newTestStreamClient(t, ts.URL, testStreamSubscriberID, "RECEIVER") - streams := newTestHelper(t, client) - - require.NoError(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) - require.Len(t, streams.subscriptions, 1) - - require.NoError(t, streams.Close()) - require.True(t, streams.closed.Load()) - require.Empty(t, streams.subscriptions) - require.Error(t, streams.Subscribe(context.Background(), testStreamRoomID, testStreamEventID, newTestDescriptor(false))) -} - -func TestDecryptLogicalEventRejectsInvalidIV(t *testing.T) { - descriptor := newTestDescriptor(true) - content, err := normalizeUpdateContent(testStreamRoomID, testStreamEventID, newTestPublishContent("hello")) - require.NoError(t, err) - payload, err := marshalContent(content) - require.NoError(t, err) - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, testStreamRoomID, testStreamEventID, descriptor.Encryption.Key) - require.NoError(t, err) - - enc := encContent.AsEncrypted() - enc.IV = []byte("invalid") - _, _, err = decryptLogicalEvent(encContent, descriptor.Encryption.Key) - require.Error(t, err) -} diff --git a/mautrix-patched/beeperstream/publisher.go b/mautrix-patched/beeperstream/publisher.go deleted file mode 100644 index aabc0dd5..00000000 --- a/mautrix-patched/beeperstream/publisher.go +++ /dev/null @@ -1,429 +0,0 @@ -// Copyright (c) 2026 Batuhan İçöz -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package beeperstream - -import ( - "context" - "fmt" - "slices" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type subscriber struct { - userID id.UserID - deviceID id.DeviceID -} - -type publishedStream struct { - descriptor *event.BeeperStreamInfo - streamID string - maxBufferedUpdates int - updates []*event.Content - - subscribers map[subscriber]time.Time - inactive bool - cleanup *time.Timer - lastEviction time.Time -} - -type pendingSubscribeEvent struct { - key streamKey - streamID string - evt *event.Event - receivedAt time.Time -} - -func (h *Helper) Register(ctx context.Context, roomID id.RoomID, eventID id.EventID, descriptor *event.BeeperStreamInfo) error { - if h == nil { - return fmt.Errorf("beeper stream helper is nil") - } else if h.closed.Load() { - return fmt.Errorf("beeper stream helper is closed") - } else if err := descriptor.Validate(); err != nil { - return err - } - key := streamKey{roomID: roomID, eventID: eventID} - state := &publishedStream{ - descriptor: descriptor.Clone(), - maxBufferedUpdates: resolveMaxBufferedUpdates(descriptor), - subscribers: make(map[subscriber]time.Time), - } - if descriptor.Encryption != nil { - state.streamID = deriveStreamID(descriptor.Encryption.Key, roomID, eventID) - } - h.lock.Lock() - if existing := h.publishedStreams[key]; existing != nil { - if descriptorEqual(existing.descriptor, state.descriptor) { - h.lock.Unlock() - h.replayPendingSubscribe(ctx, key) - return nil - } - if existing.streamID != "" { - delete(h.encryptedPubl, existing.streamID) - } - if existing.cleanup != nil { - existing.cleanup.Stop() - } - } - h.publishedStreams[key] = state - if state.streamID != "" { - h.encryptedPubl[state.streamID] = key - } - h.lock.Unlock() - h.replayPendingSubscribe(ctx, key) - return nil -} - -func (h *Helper) Unregister(roomID id.RoomID, eventID id.EventID) { - if h == nil || h.closed.Load() { - return - } - key := streamKey{roomID: roomID, eventID: eventID} - h.lock.Lock() - state := h.publishedStreams[key] - if state == nil { - h.lock.Unlock() - return - } - state.inactive = true - state.subscribers = nil - if state.streamID != "" { - delete(h.encryptedPubl, state.streamID) - } - if state.cleanup != nil { - state.cleanup.Stop() - } - state.cleanup = time.AfterFunc(cleanupGrace, func() { - h.lock.Lock() - delete(h.publishedStreams, key) - h.lock.Unlock() - }) - h.lock.Unlock() -} - -func streamUpdateIdentifiers(content *event.Content) (*event.BeeperStreamUpdateEventContent, error) { - update := content.AsBeeperStreamUpdate() - if update.RoomID != "" && update.EventID != "" { - return update, nil - } - raw, err := rawMapFromContent(content) - if err != nil { - return nil, err - } - return &event.BeeperStreamUpdateEventContent{ - RoomID: id.RoomID(fmt.Sprint(raw["room_id"])), - EventID: id.EventID(fmt.Sprint(raw["event_id"])), - }, nil -} - -func (h *Helper) Publish(ctx context.Context, roomID id.RoomID, eventID id.EventID, delta map[string]any) error { - if h == nil { - return fmt.Errorf("beeper stream helper is nil") - } else if h.closed.Load() { - return fmt.Errorf("beeper stream helper is closed") - } - update, err := normalizeUpdateContent(roomID, eventID, delta) - if err != nil { - return err - } - key := streamKey{roomID: roomID, eventID: eventID} - h.lock.Lock() - state := h.publishedStreams[key] - if state == nil { - h.lock.Unlock() - return fmt.Errorf("beeper stream %s/%s not found", roomID, eventID) - } else if state.inactive { - h.lock.Unlock() - return fmt.Errorf("beeper stream %s/%s is inactive", roomID, eventID) - } - state.updates = append(state.updates, update) - if len(state.updates) > state.maxBufferedUpdates { - state.updates = state.updates[len(state.updates)-state.maxBufferedUpdates:] - } - descriptor := state.descriptor.Clone() - subscribers := state.activeSubscribers(h.now()) - h.lock.Unlock() - return h.sendUpdate(ctx, descriptor, update, subscribers) -} - -func (h *Helper) handleSubscribeEvent(ctx context.Context, evt *event.Event) { - subscribe := evt.Content.AsBeeperStreamSubscribe() - if subscribe.RoomID == "" || subscribe.EventID == "" { - return - } - if h.handleSubscribe(ctx, evt.Sender, subscribe) { - return - } - h.queuePendingSubscribe(ctx, evt) -} - -func (h *Helper) handleEncryptedForPublisher(ctx context.Context, evt *event.Event, key streamKey, state *publishedStream) []*event.Event { - if state.descriptor == nil || state.descriptor.Encryption == nil { - return nil - } - normalized := decryptedLogicalEvent(ctx, evt, state.descriptor.Encryption.Key, key, event.ToDeviceBeeperStreamSubscribe) - if normalized == nil { - return nil - } - h.handleSubscribeEvent(ctx, normalized) - return nil -} - -func (h *Helper) handleSubscribe(ctx context.Context, sender id.UserID, subscribe *event.BeeperStreamSubscribeEventContent) bool { - if subscribe == nil { - return false - } - key := streamKey{roomID: subscribe.RoomID, eventID: subscribe.EventID} - h.lock.Lock() - state := h.publishedStreams[key] - if state == nil { - h.lock.Unlock() - return false - } else if state.inactive { - h.lock.Unlock() - return true - } - expiry := resolveSubscribeExpiry(state.descriptor, time.Duration(subscribe.ExpiryMS)*time.Millisecond) - sub := subscriber{userID: sender, deviceID: subscribe.DeviceID} - state.subscribers[sub] = h.now().Add(expiry) - descriptor := state.descriptor.Clone() - updates := slices.Clone(state.updates) - h.lock.Unlock() - - if err := h.sendReplayUpdates(ctx, descriptor, updates, []subscriber{sub}); err != nil { - h.lock.Lock() - state = h.publishedStreams[key] - if state != nil { - delete(state.subscribers, sub) - } - h.lock.Unlock() - return true - } - return true -} - -func makeReplayUpdateContent(updates []*event.Content) (*event.Content, error) { - if len(updates) == 0 { - return nil, nil - } else if len(updates) == 1 { - return updates[0], nil - } - updateInfo, err := streamUpdateIdentifiers(updates[0]) - if err != nil { - return nil, err - } - batched := make([]map[string]any, 0, len(updates)) - for _, update := range updates { - raw, err := rawMapFromContent(update) - if err != nil { - return nil, err - } - batched = append(batched, stripUpdateRouting(raw)) - } - return contentFromRawMap(map[string]any{ - "room_id": updateInfo.RoomID, - "event_id": updateInfo.EventID, - "updates": batched, - }) -} - -func (h *Helper) sendReplayUpdates(ctx context.Context, descriptor *event.BeeperStreamInfo, updates []*event.Content, subscribers []subscriber) error { - content, err := makeReplayUpdateContent(updates) - if err != nil || content == nil { - return err - } - return h.sendUpdate(ctx, descriptor, content, subscribers) -} - -func (h *Helper) sendUpdate(ctx context.Context, descriptor *event.BeeperStreamInfo, update *event.Content, subscribers []subscriber) error { - if len(subscribers) == 0 { - return nil - } - client, err := h.requireClient(false) - if err != nil { - return err - } - eventType := event.ToDeviceBeeperStreamUpdate - content := update - if descriptor != nil && descriptor.Encryption != nil { - updateInfo, err := streamUpdateIdentifiers(update) - if err != nil { - return err - } - payload, err := marshalContent(update) - if err != nil { - return err - } - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamUpdate, payload, updateInfo.RoomID, updateInfo.EventID, descriptor.Encryption.Key) - if err != nil { - return err - } - eventType = event.ToDeviceEncrypted - content = encContent - } - req := &mautrix.ReqSendToDevice{ - Messages: make(map[id.UserID]map[id.DeviceID]*event.Content, len(subscribers)), - } - for _, sub := range subscribers { - if req.Messages[sub.userID] == nil { - req.Messages[sub.userID] = make(map[id.DeviceID]*event.Content) - } - req.Messages[sub.userID][sub.deviceID] = content - } - _, err = client.SendToDevice(ctx, eventType, req) - return err -} - -func (h *Helper) queuePendingSubscribe(ctx context.Context, evt *event.Event) { - key, streamID, ok := pendingSubscribeKey(evt) - if !ok || h.closed.Load() { - return - } - now := h.now() - h.pendingLock.Lock() - h.pendingSubscribe = append(h.pendingSubscribe, pendingSubscribeEvent{ - key: key, - streamID: streamID, - evt: evt, - receivedAt: now, - }) - pendingCount := len(h.pendingSubscribe) - if pendingCount > maxPendingSubscriptions { - h.pendingSubscribe = h.pendingSubscribe[len(h.pendingSubscribe)-maxPendingSubscriptions:] - pendingCount = len(h.pendingSubscribe) - } - h.pendingLock.Unlock() - h.log.Debug(). - Int("pending_subscribes", pendingCount). - Stringer("sender", evt.Sender). - Str("event_type", evt.Type.Type). - Msg("Queued subscribe for possible future beeper stream registration") -} - -func (h *Helper) replayPendingSubscribe(ctx context.Context, key streamKey) { - now := h.now() - h.lock.RLock() - state := h.publishedStreams[key] - streamID := "" - if state != nil { - streamID = state.streamID - } - h.lock.RUnlock() - h.pendingLock.Lock() - if len(h.pendingSubscribe) == 0 { - h.pendingLock.Unlock() - return - } - var replay []pendingSubscribeEvent - filtered := h.pendingSubscribe[:0] - for _, candidate := range h.pendingSubscribe { - if candidate.evt == nil || now.Sub(candidate.receivedAt) > pendingSubscribeTTL { - continue - } - if candidate.key == key || (streamID != "" && candidate.streamID == streamID) { - replay = append(replay, candidate) - continue - } - filtered = append(filtered, candidate) - } - h.pendingSubscribe = filtered - h.pendingLock.Unlock() - if len(replay) == 0 { - return - } - var failed []pendingSubscribeEvent - for _, candidate := range replay { - if !h.tryPendingSubscribe(ctx, candidate) { - failed = append(failed, candidate) - } - } - if len(failed) == 0 { - return - } - h.pendingLock.Lock() - h.pendingSubscribe = append(h.pendingSubscribe, failed...) - if len(h.pendingSubscribe) > maxPendingSubscriptions { - h.pendingSubscribe = h.pendingSubscribe[len(h.pendingSubscribe)-maxPendingSubscriptions:] - } - h.pendingLock.Unlock() -} - -func (h *Helper) tryPendingSubscribe(ctx context.Context, candidate pendingSubscribeEvent) bool { - switch candidate.evt.Type { - case event.ToDeviceBeeperStreamSubscribe: - subscribe := candidate.evt.Content.AsBeeperStreamSubscribe() - if subscribe.RoomID == "" || subscribe.EventID == "" { - return false - } - return h.handleSubscribe(ctx, candidate.evt.Sender, subscribe) - case event.ToDeviceEncrypted: - content := candidate.evt.Content.AsEncrypted() - if content.Algorithm != id.AlgorithmBeeperStreamV1 { - return false - } - if content.StreamID == "" || len(content.BeeperStreamCiphertext) == 0 { - return false - } - h.lock.RLock() - key, ok := h.encryptedPubl[content.StreamID] - state := h.publishedStreams[key] - h.lock.RUnlock() - if !ok || state == nil { - return false - } - h.handleEncryptedForPublisher(ctx, candidate.evt, key, state) - return true - default: - return false - } -} - -func pendingSubscribeKey(evt *event.Event) (streamKey, string, bool) { - if evt == nil { - return streamKey{}, "", false - } - switch evt.Type { - case event.ToDeviceBeeperStreamSubscribe: - content := evt.Content.AsBeeperStreamSubscribe() - if content.RoomID == "" || content.EventID == "" { - return streamKey{}, "", false - } - return streamKey{roomID: content.RoomID, eventID: content.EventID}, "", true - case event.ToDeviceEncrypted: - content := evt.Content.AsEncrypted() - if content.Algorithm != id.AlgorithmBeeperStreamV1 { - return streamKey{}, "", false - } - if content.StreamID == "" || len(content.BeeperStreamCiphertext) == 0 { - return streamKey{}, "", false - } - return streamKey{}, content.StreamID, true - default: - return streamKey{}, "", false - } -} - -func (state *publishedStream) activeSubscribers(now time.Time) []subscriber { - var active []subscriber - doEvict := now.Sub(state.lastEviction) >= cleanupGrace - for sub, expiry := range state.subscribers { - if now.After(expiry) { - if doEvict { - delete(state.subscribers, sub) - } - continue - } - active = append(active, sub) - } - if doEvict { - state.lastEviction = now - } - return active -} diff --git a/mautrix-patched/beeperstream/receiver.go b/mautrix-patched/beeperstream/receiver.go deleted file mode 100644 index 23319137..00000000 --- a/mautrix-patched/beeperstream/receiver.go +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) 2026 Batuhan İçöz -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package beeperstream - -import ( - "context" - "fmt" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type subscription struct { - key streamKey - streamID string - descriptor *event.BeeperStreamInfo - cancel context.CancelFunc -} - -func (h *Helper) Subscribe(ctx context.Context, roomID id.RoomID, eventID id.EventID, descriptor *event.BeeperStreamInfo) error { - if h == nil { - return fmt.Errorf("beeper stream helper is nil") - } else if h.closed.Load() { - return fmt.Errorf("beeper stream helper is closed") - } else if err := descriptor.Validate(); err != nil { - return err - } - key := streamKey{roomID: roomID, eventID: eventID} - h.lock.Lock() - if existing := h.subscriptions[key]; existing != nil { - if descriptorEqual(existing.descriptor, descriptor) { - h.lock.Unlock() - return nil - } - if existing.streamID != "" { - delete(h.encryptedSubs, existing.streamID) - } - existing.cancel() - delete(h.subscriptions, key) - } - subCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - sub := &subscription{ - key: key, - descriptor: descriptor.Clone(), - cancel: cancel, - } - if descriptor.Encryption != nil { - sub.streamID = deriveStreamID(descriptor.Encryption.Key, roomID, eventID) - } - h.subscriptions[key] = sub - if sub.streamID != "" { - h.encryptedSubs[sub.streamID] = key - } - h.lock.Unlock() - - go h.runSubscriptionLoop(subCtx, sub) - return nil -} - -func (h *Helper) Unsubscribe(roomID id.RoomID, eventID id.EventID) { - if h == nil || h.closed.Load() { - return - } - key := streamKey{roomID: roomID, eventID: eventID} - h.lock.Lock() - sub := h.subscriptions[key] - if sub != nil { - delete(h.subscriptions, key) - if sub.streamID != "" { - delete(h.encryptedSubs, sub.streamID) - } - } - h.lock.Unlock() - if sub != nil { - sub.cancel() - } -} - -func (h *Helper) runSubscriptionLoop(ctx context.Context, sub *subscription) { - expiry := resolveSubscribeExpiry(sub.descriptor, DefaultSubscribeExpiry) - renewInterval := max(expiry/2, defaultRenewInterval) - if err := h.sendSubscribe(ctx, sub.key, sub.descriptor, expiry); err != nil && ctx.Err() == nil { - h.log.Warn().Err(err). - Stringer("room_id", sub.key.roomID). - Stringer("event_id", sub.key.eventID). - Msg("Failed to send initial beeper stream subscribe") - } - ticker := time.NewTicker(renewInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if err := h.sendSubscribe(ctx, sub.key, sub.descriptor, expiry); err != nil && ctx.Err() == nil { - h.log.Warn().Err(err). - Stringer("room_id", sub.key.roomID). - Stringer("event_id", sub.key.eventID). - Msg("Failed to renew beeper stream subscribe") - } - } - } -} - -func (h *Helper) sendSubscribe(ctx context.Context, key streamKey, descriptor *event.BeeperStreamInfo, expiry time.Duration) error { - client, err := h.requireClient(true) - if err != nil { - return err - } - subscribeContent := &event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ - RoomID: key.roomID, - EventID: key.eventID, - DeviceID: client.DeviceID, - ExpiryMS: expiry.Milliseconds(), - }} - eventType := event.ToDeviceBeeperStreamSubscribe - content := subscribeContent - targetDevice := id.DeviceID("*") - if descriptor.DeviceID != "" { - targetDevice = descriptor.DeviceID - } - if descriptor.Encryption != nil { - payload, err := marshalContent(subscribeContent) - if err != nil { - return err - } - encContent, err := encryptLogicalEvent(event.ToDeviceBeeperStreamSubscribe, payload, key.roomID, key.eventID, descriptor.Encryption.Key) - if err != nil { - return err - } - eventType = event.ToDeviceEncrypted - content = encContent - } - _, err = client.SendToDevice(ctx, eventType, &mautrix.ReqSendToDevice{ - Messages: map[id.UserID]map[id.DeviceID]*event.Content{ - descriptor.UserID: { - targetDevice: content, - }, - }, - }) - return err -} - -func (h *Helper) handleEncryptedForSubscriber(ctx context.Context, evt *event.Event, key streamKey, sub *subscription) []*event.Event { - if sub.descriptor.Encryption == nil { - return nil - } - normalized := decryptedLogicalEvent(ctx, evt, sub.descriptor.Encryption.Key, key, event.ToDeviceBeeperStreamUpdate) - if normalized == nil { - return nil - } - update := normalized.Content.AsBeeperStreamUpdate() - if update.RoomID != sub.key.roomID || update.EventID != sub.key.eventID { - return nil - } - if normalized.Sender != sub.descriptor.UserID { - h.log.Warn(). - Stringer("sender", normalized.Sender). - Stringer("expected_user_id", sub.descriptor.UserID). - Stringer("room_id", update.RoomID). - Stringer("event_id", update.EventID). - Msg("Encrypted beeper stream update from unexpected sender, dropping") - return nil - } - return expandStreamUpdateEvent(normalized) -} diff --git a/mautrix-patched/bridgev2/backfillqueue.go b/mautrix-patched/bridgev2/backfillqueue.go deleted file mode 100644 index 33c87541..00000000 --- a/mautrix-patched/bridgev2/backfillqueue.go +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "errors" - "fmt" - "runtime/debug" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2/database" -) - -const BackfillMinBackoffAfterRoomCreate = 1 * time.Minute -const BackfillQueueErrorBackoff = 1 * time.Minute -const BackfillQueueMaxEmptyBackoff = 10 * time.Minute - -func (br *Bridge) WakeupBackfillQueue(manualTask ...*ManualBackfill) { - if br.IsStopping() { - for _, task := range manualTask { - if task.DoneCallback != nil { - task.DoneCallback(errBackfillQueueStopped) - } - } - return - } - if !br.Config.Backfill.Queue.Enabled { - for _, task := range manualTask { - go task.addLogAndDo(task.Portal.Log.WithContext(br.BackgroundCtx)) - } - return - } - for _, task := range manualTask { - br.manualBackfills <- task - } - select { - case br.wakeupBackfillQueue <- struct{}{}: - default: - } -} - -type ManualBackfill struct { - Source *UserLogin - Portal *Portal - Data *FetchMessagesResponse - - DoneCallback func(error) -} - -var errBackfillQueueStopped = errors.New("backfill queue stopped") - -func (br *Bridge) flushManualBackfillQueue() { - for { - select { - case manualTask := <-br.manualBackfills: - if manualTask.DoneCallback != nil { - manualTask.DoneCallback(errBackfillQueueStopped) - } - default: - return - } - } -} - -func (br *Bridge) RunBackfillQueue() { - if !br.Config.Backfill.Queue.Enabled || !br.Config.Backfill.Enabled { - return - } - log := br.Log.With().Str("component", "backfill queue").Logger() - if !br.Matrix.GetCapabilities().BatchSending { - log.Warn().Msg("Backfill queue is enabled in config, but Matrix server doesn't support batch sending") - return - } - ctx, cancel := context.WithCancel(log.WithContext(context.Background())) - br.stopBackfillQueue.Clear() - stopChan := br.stopBackfillQueue.GetChan() - go func() { - <-stopChan - cancel() - }() - batchDelay := time.Duration(br.Config.Backfill.Queue.BatchDelay) * time.Second - log.Info().Stringer("batch_delay", batchDelay).Msg("Backfill queue starting") - noTasksFoundCount := 0 - for { - nextDelay := batchDelay - if noTasksFoundCount > 0 { - extraDelay := batchDelay * time.Duration(noTasksFoundCount) - nextDelay += min(BackfillQueueMaxEmptyBackoff, extraDelay) - } - select { - case <-br.wakeupBackfillQueue: - noTasksFoundCount = 0 - case <-stopChan: - log.Info().Msg("Stopping backfill queue") - br.flushManualBackfillQueue() - return - case <-time.After(nextDelay): - } - select { - case manualTask := <-br.manualBackfills: - manualTask.addLogAndDo(ctx) - default: - backfillTask, err := br.DB.BackfillTask.GetNext(ctx) - if err != nil { - log.Err(err).Msg("Failed to get next backfill queue entry") - time.Sleep(BackfillQueueErrorBackoff) - continue - } else if backfillTask != nil { - backfillTask.FromQueue = true - br.DoBackfillTask(ctx, backfillTask) - noTasksFoundCount = 0 - } - } - } -} - -func (mt *ManualBackfill) addLogAndDo(ctx context.Context) { - log := zerolog.Ctx(ctx).With(). - Object("portal_key", mt.Portal.PortalKey). - Str("login_id", string(mt.Source.ID)). - Str("task_type", "manual"). - Logger() - ctx = log.WithContext(ctx) - mt.Do(ctx) -} - -func (mt *ManualBackfill) Do(ctx context.Context) { - log := zerolog.Ctx(ctx) - var completed bool - var err error - if !mt.Portal.backfillLock.TryLock() { - log.Warn().Msg("Backfill already in progress") - mt.Portal.backfillLock.Lock() - } - defer mt.Portal.backfillLock.Unlock() - defer func() { - if !completed { - if mt.DoneCallback != nil { - if mt.Portal.nextBackfillDoneCallback != nil { - mt.Portal.nextBackfillDoneCallback(errors.New("done callback overridden")) - } - mt.Portal.nextBackfillDoneCallback = mt.DoneCallback - mt.DoneCallback = nil - } - return - } - if mt.DoneCallback != nil { - mt.DoneCallback(err) - } - if mt.Portal.nextBackfillDoneCallback != nil { - mt.Portal.nextBackfillDoneCallback(err) - mt.Portal.nextBackfillDoneCallback = nil - } - }() - var task *database.BackfillTask - updateTask := false - task, err = mt.Portal.Bridge.DB.BackfillTask.GetNextForPortal(ctx, mt.Portal.PortalKey, mt.Data != nil) - if err != nil { - log.Err(err).Msg("Failed to get backfill task from database") - } else if task == nil { - log.Warn().Msg("No backfill task found for portal") - } else if err = mt.Portal.Bridge.DB.BackfillTask.MarkDispatched(ctx, task); err != nil { - log.Err(err).Msg("Failed to mark backfill task as dispatched") - } else if completed, err = mt.Portal.doBackfillTask(ctx, mt.Source, task, mt.Data); err != nil { - log.Err(err).Msg("Failed to do backwards backfill from event") - updateTask = errors.Is(err, errNoMessagesLeftAfterCutoff) - } else { - log.Debug().Bool("completed", completed).Msg("Finished backfill from event") - updateTask = true - } - if updateTask { - err = mt.Portal.Bridge.DB.BackfillTask.Update(ctx, task) - if err != nil { - log.Err(err).Msg("Failed to update backfill task in database after backfill") - } - } -} - -func (br *Bridge) DoBackfillTask(ctx context.Context, task *database.BackfillTask) { - log := zerolog.Ctx(ctx).With(). - Object("portal_key", task.PortalKey). - Str("login_id", string(task.UserLoginID)). - Logger() - defer func() { - err := recover() - if err != nil { - logEvt := log.Error(). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()) - if realErr, ok := err.(error); ok { - logEvt = logEvt.Err(realErr) - } else { - logEvt = logEvt.Any(zerolog.ErrorFieldName, err) - } - logEvt.Msg("Panic in backfill queue") - } - }() - ctx = log.WithContext(ctx) - err := br.DB.BackfillTask.MarkDispatched(ctx, task) - if err != nil { - log.Err(err).Msg("Failed to mark backfill task as dispatched") - time.Sleep(BackfillQueueErrorBackoff) - return - } - updateTask := true - completed, err := br.getPortalAndDoBackfillTask(ctx, task) - if err != nil { - log.Err(err).Msg("Failed to do backfill task") - updateTask = errors.Is(err, errNoMessagesLeftAfterCutoff) - time.Sleep(BackfillQueueErrorBackoff) - } else if completed { - log.Info(). - Int("batch_count", task.BatchCount). - Bool("is_done", task.IsDone). - Msg("Backfill task completed successfully") - } else { - log.Info(). - Int("batch_count", task.BatchCount). - Bool("is_done", task.IsDone). - Msg("Backfill task not completed") - } - if updateTask { - err = br.DB.BackfillTask.Update(ctx, task) - if err != nil { - log.Err(err).Msg("Failed to update backfill task") - time.Sleep(BackfillQueueErrorBackoff) - } - } -} - -func (portal *Portal) deleteBackfillQueueTaskIfRoomDoesNotExist(ctx context.Context) bool { - // Acquire the room create lock to ensure that task deletion doesn't race with room creation - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID == "" { - zerolog.Ctx(ctx).Debug().Msg("Portal for backfill task doesn't exist, deleting entry") - err := portal.Bridge.DB.BackfillTask.Delete(ctx, portal.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to delete backfill task after portal wasn't found") - } - return true - } - return false -} - -func (br *Bridge) getPortalAndDoBackfillTask(ctx context.Context, task *database.BackfillTask) (bool, error) { - log := zerolog.Ctx(ctx) - portal, err := br.GetExistingPortalByKey(ctx, task.PortalKey) - if err != nil { - return false, fmt.Errorf("failed to get portal for backfill task: %w", err) - } else if portal == nil { - log.Warn().Msg("Portal not found for backfill task") - err = br.DB.BackfillTask.Delete(ctx, task.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to delete backfill task after portal wasn't found") - time.Sleep(BackfillQueueErrorBackoff) - } - return false, nil - } else if portal.MXID == "" { - portal.deleteBackfillQueueTaskIfRoomDoesNotExist(ctx) - return false, nil - } - login, err := br.GetExistingUserLoginByID(ctx, task.UserLoginID) - if err != nil { - return false, fmt.Errorf("failed to get user login for backfill task: %w", err) - } else if login == nil || !login.Client.IsLoggedIn() { - if login == nil { - log.Warn().Msg("User login not found for backfill task") - } else { - log.Warn().Msg("User login not logged in for backfill task") - } - logins, err := br.GetUserLoginsInPortal(ctx, portal.PortalKey) - if err != nil { - return false, fmt.Errorf("failed to get user portals for backfill task: %w", err) - } else if len(logins) == 0 { - log.Debug().Msg("No user logins found for backfill task") - task.NextDispatchMinTS = database.BackfillNextDispatchNever - if login == nil { - task.UserLoginID = "" - } - return false, nil - } - if login == nil { - task.UserLoginID = "" - } - foundLogin := false - for _, login = range logins { - if login.Client.IsLoggedIn() { - foundLogin = true - task.UserLoginID = login.ID - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("overridden_login_id", string(login.ID)) - }) - log.Debug().Msg("Found user login for backfill task") - break - } - } - if !foundLogin { - log.Debug().Msg("No logged in user logins found for backfill task") - task.NextDispatchMinTS = database.BackfillNextDispatchNever - return false, nil - } - } - if task.BatchCount < 0 { - var msgCount int - msgCount, err = br.DB.Message.CountMessagesInPortal(ctx, task.PortalKey) - if err != nil { - return false, fmt.Errorf("failed to count messages in portal: %w", err) - } - task.BatchCount = msgCount / br.Config.Backfill.Queue.BatchSize - log.Debug(). - Int("message_count", msgCount). - Int("batch_count", task.BatchCount). - Msg("Calculated existing batch count") - } - if !portal.backfillLock.TryLock() { - zerolog.Ctx(ctx).Warn().Msg("Backfill already in progress") - portal.backfillLock.Lock() - } - defer portal.backfillLock.Unlock() - return portal.doBackfillTask(ctx, login, task, nil) -} - -func (portal *Portal) doBackfillTask(ctx context.Context, source *UserLogin, task *database.BackfillTask, resp *FetchMessagesResponse) (bool, error) { - maxBatches := portal.Bridge.Config.Backfill.Queue.MaxBatches - api, ok := source.Client.(BackfillingNetworkAPI) - if !ok { - return false, fmt.Errorf("network API does not support backfilling") - } - limiterAPI, ok := api.(BackfillingNetworkAPIWithLimits) - if ok { - maxBatches = limiterAPI.GetBackfillMaxBatchCount(ctx, portal, task) - } - if maxBatches < 0 || maxBatches > task.BatchCount { - pending, err := portal.doBackwardsBackfill(ctx, source, task, resp) - if err != nil { - return false, fmt.Errorf("failed to backfill: %w", err) - } else if pending { - return false, nil - } - task.BatchCount++ - } else { - zerolog.Ctx(ctx).Debug(). - Int("max_batches", maxBatches). - Int("batch_count", task.BatchCount). - Msg("Not actually backfilling: max batches reached") - } - task.IsDone = task.IsDone || (maxBatches > 0 && task.BatchCount >= maxBatches) - task.QueueDone = task.IsDone || task.QueueDone - batchDelay := time.Duration(portal.Bridge.Config.Backfill.Queue.BatchDelay) * time.Second - task.CompletedAt = time.Now() - task.NextDispatchMinTS = task.CompletedAt.Add(batchDelay) - return true, nil -} diff --git a/mautrix-patched/bridgev2/bridge.go b/mautrix-patched/bridgev2/bridge.go deleted file mode 100644 index 9b51a88a..00000000 --- a/mautrix-patched/bridgev2/bridge.go +++ /dev/null @@ -1,470 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "fmt" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exhttp" - "go.mau.fi/util/exsync" - - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/id" -) - -type CommandProcessor interface { - Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *User, message string, replyTo id.EventID) -} - -type Bridge struct { - ID networkid.BridgeID - DB *database.Database - Log zerolog.Logger - - Matrix MatrixConnector - Bot MatrixAPI - Network NetworkConnector - Commands CommandProcessor - Config *bridgeconfig.BridgeConfig - - DisappearLoop *DisappearLoop - - usersByMXID map[id.UserID]*User - userLoginsByID map[networkid.UserLoginID]*UserLogin - portalsByKey map[networkid.PortalKey]*Portal - portalsByMXID map[id.RoomID]*Portal - ghostsByID map[networkid.UserID]*Ghost - cacheLock sync.Mutex - - didSplitPortals bool - - Background bool - ExternallyManagedDB bool - stopping atomic.Bool - - wakeupBackfillQueue chan struct{} - stopBackfillQueue *exsync.Event - manualBackfills chan *ManualBackfill - - BackgroundCtx context.Context - cancelBackgroundCtx context.CancelFunc -} - -func NewBridge( - bridgeID networkid.BridgeID, - db *dbutil.Database, - log zerolog.Logger, - cfg *bridgeconfig.BridgeConfig, - matrix MatrixConnector, - network NetworkConnector, - newCommandProcessor func(*Bridge) CommandProcessor, -) *Bridge { - br := &Bridge{ - ID: bridgeID, - DB: database.New(bridgeID, network.GetDBMetaTypes(), db), - Log: log, - - Matrix: matrix, - Network: network, - Config: cfg, - - usersByMXID: make(map[id.UserID]*User), - userLoginsByID: make(map[networkid.UserLoginID]*UserLogin), - portalsByKey: make(map[networkid.PortalKey]*Portal), - portalsByMXID: make(map[id.RoomID]*Portal), - ghostsByID: make(map[networkid.UserID]*Ghost), - - wakeupBackfillQueue: make(chan struct{}), - manualBackfills: make(chan *ManualBackfill, 64), - stopBackfillQueue: exsync.NewEvent(), - } - if br.Config == nil { - br.Config = &bridgeconfig.BridgeConfig{CommandPrefix: "!bridge"} - } - br.Commands = newCommandProcessor(br) - br.Matrix.Init(br) - br.Bot = br.Matrix.BotIntent() - br.Network.Init(br) - br.DisappearLoop = &DisappearLoop{br: br} - return br -} - -type DBUpgradeError struct { - Err error - Section string -} - -func (e DBUpgradeError) Error() string { - return e.Err.Error() -} - -func (e DBUpgradeError) Unwrap() error { - return e.Err -} - -func (br *Bridge) Start(ctx context.Context) error { - ctx = br.Log.WithContext(ctx) - err := br.StartConnectors(ctx) - if err != nil { - return err - } - err = br.StartLogins(ctx) - if err != nil { - return err - } - go br.PostStart(ctx) - return nil -} - -func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, params *ConnectBackgroundParams) error { - br.Background = true - br.stopping.Store(false) - err := br.StartConnectors(ctx) - if err != nil { - return err - } - - if loginID == "" { - br.Log.Info().Msg("No login ID provided to RunOnce, running all logins for 20 seconds") - err = br.StartLogins(ctx) - if err != nil { - return err - } - defer br.StopWithTimeout(5 * time.Second) - select { - case <-time.After(20 * time.Second): - case <-ctx.Done(): - } - return nil - } - - defer br.stop(true, 5*time.Second) - login, err := br.GetExistingUserLoginByID(ctx, loginID) - if err != nil { - return fmt.Errorf("failed to get user login: %w", err) - } else if login == nil { - return ErrNotLoggedIn - } - syncClient, ok := login.Client.(BackgroundSyncingNetworkAPI) - if !ok { - br.Log.Warn().Msg("Network connector doesn't implement background mode, using fallback mechanism for RunOnce") - login.Client.Connect(ctx) - defer login.DisconnectWithTimeout(5 * time.Second) - select { - case <-time.After(20 * time.Second): - case <-ctx.Done(): - } - br.stopping.Store(true) - return nil - } else { - br.Log.Info().Str("user_login_id", string(login.ID)).Msg("Starting individual user login in background mode") - return syncClient.ConnectBackground(login.Log.WithContext(ctx), params) - } -} - -func (br *Bridge) StartConnectors(ctx context.Context) error { - br.Log.Info().Msg("Starting bridge") - br.stopping.Store(false) - if br.BackgroundCtx == nil || br.BackgroundCtx.Err() != nil { - br.BackgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background()) - br.BackgroundCtx = br.Log.WithContext(br.BackgroundCtx) - } - - if !br.ExternallyManagedDB { - err := br.DB.Upgrade(ctx) - if err != nil { - return DBUpgradeError{Err: err, Section: "main"} - } - } - if !br.Background { - didSplitPortals, postMigrate, err := br.MigrateToSplitPortals(ctx) - if err != nil { - return fmt.Errorf("%w: %w", ErrSplitPortalMigrationFailed, err) - } - br.didSplitPortals = didSplitPortals - if postMigrate != nil { - defer postMigrate() - } - } - br.Log.Info().Msg("Starting Matrix connector") - err := br.Matrix.Start(ctx) - if err != nil { - return fmt.Errorf("failed to start Matrix connector: %w", err) - } - br.Log.Info().Msg("Starting network connector") - err = br.Network.Start(ctx) - if err != nil { - return fmt.Errorf("failed to start network connector: %w", err) - } - if br.Network.GetCapabilities().DisappearingMessages && !br.Background { - go br.DisappearLoop.Start() - } - return nil -} - -func (br *Bridge) PostStart(ctx context.Context) { - if br.Background { - return - } - rawBridgeInfoVer := br.DB.KV.Get(ctx, database.KeyBridgeInfoVersion) - bridgeInfoVer, capVer, err := parseBridgeInfoVersion(rawBridgeInfoVer) - if err != nil { - br.Log.Err(err).Str("db_bridge_info_version", rawBridgeInfoVer).Msg("Failed to parse bridge info version") - return - } - expectedBridgeInfoVer, expectedCapVer := br.Network.GetBridgeInfoVersion() - doResendBridgeInfo := bridgeInfoVer != expectedBridgeInfoVer || br.didSplitPortals || br.Config.ResendBridgeInfo - doResendCapabilities := capVer != expectedCapVer || br.didSplitPortals - if doResendBridgeInfo || doResendCapabilities { - br.ResendBridgeInfo(ctx, doResendBridgeInfo, doResendCapabilities) - } - br.DB.KV.Set(ctx, database.KeyBridgeInfoVersion, fmt.Sprintf("%d,%d", expectedBridgeInfoVer, expectedCapVer)) -} - -func (br *Bridge) GetBeeperStreamPublisher() BeeperStreamPublisher { - if br == nil || br.Matrix == nil { - return nil - } - withStreams, ok := br.Matrix.(MatrixConnectorWithBeeperStreams) - if !ok { - return nil - } - return withStreams.GetBeeperStreamPublisher() -} - -func parseBridgeInfoVersion(version string) (info, capabilities int, err error) { - _, err = fmt.Sscanf(version, "%d,%d", &info, &capabilities) - if version == "" { - err = nil - } - return -} - -func (br *Bridge) ResendBridgeInfo(ctx context.Context, resendInfo, resendCaps bool) { - log := zerolog.Ctx(ctx).With().Str("action", "resend bridge info").Logger() - portals, err := br.GetAllPortalsWithMXID(ctx) - if err != nil { - log.Err(err).Msg("Failed to get portals") - return - } - for _, portal := range portals { - if resendInfo { - portal.UpdateBridgeInfo(ctx) - } - if resendCaps { - logins, err := br.GetUserLoginsInPortal(ctx, portal.PortalKey) - if err != nil { - log.Err(err). - Stringer("room_id", portal.MXID). - Object("portal_key", portal.PortalKey). - Msg("Failed to get user logins in portal") - } else { - found := false - for _, login := range logins { - if portal.CapState.ID == "" || login.ID == portal.CapState.Source { - portal.UpdateCapabilities(ctx, login, true) - found = true - } - } - if !found && len(logins) > 0 { - portal.CapState.Source = "" - portal.UpdateCapabilities(ctx, logins[0], true) - } else if !found { - log.Warn(). - Stringer("room_id", portal.MXID). - Object("portal_key", portal.PortalKey). - Msg("No user login found to update capabilities") - } - } - } - } - log.Info(). - Bool("capabilities", resendCaps). - Bool("info", resendInfo). - Msg("Resent bridge info to all portals") -} - -func (br *Bridge) MigrateToSplitPortals(ctx context.Context) (bool, func(), error) { - log := zerolog.Ctx(ctx).With().Str("action", "migrate to split portals").Logger() - ctx = log.WithContext(ctx) - if !br.Config.SplitPortals || br.DB.KV.Get(ctx, database.KeySplitPortalsEnabled) == "true" { - return false, nil, nil - } - affected, err := br.DB.Portal.MigrateToSplitPortals(ctx) - if err != nil { - log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to migrate portals") - return false, nil, fmt.Errorf("failed to migrate database: %w", err) - } - log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals") - affected2, err := br.DB.Portal.FixParentsAfterSplitPortalMigration(ctx) - if err != nil { - log.Err(err).Msg("Failed to fix parent portals after split portal migration") - return false, nil, fmt.Errorf("failed to fix parent portals: %w", err) - } - log.Info().Int64("rows_affected", affected2).Msg("Updated parent receivers after split portal migration") - withoutReceiver, err := br.DB.Portal.GetAllWithoutReceiver(ctx) - if err != nil { - log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get portals that failed to migrate") - return false, nil, fmt.Errorf("failed to get portals without receiver: %w", err) - } - var roomsToDelete []id.RoomID - log.Info().Int("remaining_portals", len(withoutReceiver)).Msg("Deleting remaining portals without receiver") - for _, portal := range withoutReceiver { - if err = br.DB.Portal.Delete(ctx, portal.PortalKey); err != nil { - log.Err(err). - Str("portal_id", string(portal.ID)). - Stringer("mxid", portal.MXID). - Msg("Failed to delete portal database row that failed to migrate") - } else if portal.MXID != "" { - log.Debug(). - Str("portal_id", string(portal.ID)). - Stringer("mxid", portal.MXID). - Msg("Marked portal room for deletion from homeserver") - roomsToDelete = append(roomsToDelete, portal.MXID) - } else { - log.Debug(). - Str("portal_id", string(portal.ID)). - Msg("Deleted portal row with no Matrix room") - } - } - br.DB.KV.Set(ctx, database.KeySplitPortalsEnabled, "true") - log.Info().Msg("Finished split portal migration successfully") - return affected > 0, func() { - for _, roomID := range roomsToDelete { - if err = br.Bot.DeleteRoom(ctx, roomID, true); err != nil { - log.Err(err). - Stringer("mxid", roomID). - Msg("Failed to delete portal room that failed to migrate") - } - } - log.Info().Int("room_count", len(roomsToDelete)).Msg("Finished deleting rooms that failed to migrate") - }, nil -} - -func (br *Bridge) StartLogins(ctx context.Context) error { - userIDs, err := br.DB.UserLogin.GetAllUserIDsWithLogins(ctx) - if err != nil { - return fmt.Errorf("failed to get users with logins: %w", err) - } - startedAny := false - for _, userID := range userIDs { - br.Log.Info().Stringer("user_id", userID).Msg("Loading user") - var user *User - user, err = br.GetUserByMXID(ctx, userID) - if err != nil { - br.Log.Err(err).Stringer("user_id", userID).Msg("Failed to load user") - } else { - for _, login := range user.GetUserLogins() { - startedAny = true - br.Log.Info().Str("id", string(login.ID)).Msg("Starting user login") - login.Client.Connect(login.Log.WithContext(ctx)) - } - } - } - if !startedAny { - br.Log.Info().Msg("No user logins found") - br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}) - } - if !br.Background { - go br.RunBackfillQueue() - } - - br.Log.Info().Msg("Bridge started") - return nil -} - -func (br *Bridge) ResetNetworkConnections() { - nrn, ok := br.Network.(NetworkResettingNetwork) - if ok { - br.Log.Info().Msg("Resetting network connections with NetworkConnector.ResetNetworkConnections") - nrn.ResetNetworkConnections() - return - } - - br.Log.Info().Msg("Network connector doesn't support ResetNetworkConnections, recreating clients manually") - for _, login := range br.GetAllCachedUserLogins() { - login.Log.Debug().Msg("Disconnecting and recreating client for network reset") - ctx := login.Log.WithContext(br.BackgroundCtx) - login.Client.Disconnect() - err := login.recreateClient(ctx) - if err != nil { - login.Log.Err(err).Msg("Failed to recreate client during network reset") - login.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: "bridgev2-network-reset-fail", - Info: map[string]any{"go_error": err.Error()}, - }) - } else { - login.Client.Connect(ctx) - } - } - br.Log.Info().Msg("Finished resetting all user logins") -} - -func (br *Bridge) GetHTTPClientSettings() exhttp.ClientSettings { - mchs, ok := br.Matrix.(MatrixConnectorWithHTTPSettings) - if ok { - return mchs.GetHTTPClientSettings() - } - return exhttp.SensibleClientSettings -} - -func (br *Bridge) IsStopping() bool { - return br.stopping.Load() -} - -func (br *Bridge) Stop() { - br.stop(false, 0) -} - -func (br *Bridge) StopWithTimeout(timeout time.Duration) { - br.stop(false, timeout) -} - -func (br *Bridge) stop(isRunOnce bool, timeout time.Duration) { - br.Log.Info().Msg("Shutting down bridge") - br.stopping.Store(true) - br.DisappearLoop.Stop() - br.stopBackfillQueue.Set() - br.Matrix.PreStop() - if !isRunOnce { - br.cacheLock.Lock() - var wg sync.WaitGroup - wg.Add(len(br.userLoginsByID)) - for _, login := range br.userLoginsByID { - go func() { - login.DisconnectWithTimeout(timeout) - wg.Done() - }() - } - br.cacheLock.Unlock() - wg.Wait() - } - br.Matrix.Stop() - if br.cancelBackgroundCtx != nil { - br.cancelBackgroundCtx() - } - if stopNet, ok := br.Network.(StoppableNetwork); ok { - stopNet.Stop() - } - if !br.ExternallyManagedDB { - err := br.DB.Close() - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to close database") - } - } - br.Log.Info().Msg("Shutdown complete") -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/appservice.go b/mautrix-patched/bridgev2/bridgeconfig/appservice.go deleted file mode 100644 index d1ab38f4..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/appservice.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "fmt" - "regexp" - "strings" - "text/template" - - "go.mau.fi/util/exerrors" - "go.mau.fi/util/random" - "gopkg.in/yaml.v3" - - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/id" -) - -type AppserviceConfig struct { - Address string `yaml:"address"` - PublicAddress string `yaml:"public_address"` - Hostname string `yaml:"hostname"` - Port uint16 `yaml:"port"` - NoServer bool `yaml:"-"` - - ID string `yaml:"id"` - Bot BotUserConfig `yaml:"bot"` - - ASToken string `yaml:"as_token"` - HSToken string `yaml:"hs_token"` - - EphemeralEvents bool `yaml:"ephemeral_events"` - AsyncTransactions bool `yaml:"async_transactions"` - - UsernameTemplate string `yaml:"username_template"` - usernameTemplate *template.Template `yaml:"-"` -} - -func (asc *AppserviceConfig) FormatUsername(username string) string { - if asc.usernameTemplate == nil { - asc.usernameTemplate = exerrors.Must(template.New("username").Parse(asc.UsernameTemplate)) - } - var buf strings.Builder - _ = asc.usernameTemplate.Execute(&buf, username) - return buf.String() -} - -func (config *Config) MakeUserIDRegex(matcher string) *regexp.Regexp { - usernamePlaceholder := strings.ToLower(random.String(16)) - usernameTemplate := fmt.Sprintf("@%s:%s", - config.AppService.FormatUsername(usernamePlaceholder), - config.Homeserver.Domain) - usernameTemplate = regexp.QuoteMeta(usernameTemplate) - usernameTemplate = strings.Replace(usernameTemplate, usernamePlaceholder, matcher, 1) - usernameTemplate = fmt.Sprintf("^%s$", usernameTemplate) - return regexp.MustCompile(usernameTemplate) -} - -// GetRegistration copies the data from the bridge config into an *appservice.Registration struct. -// This can't be used with the homeserver, see GenerateRegistration for generating files for the homeserver. -func (asc *AppserviceConfig) GetRegistration() *appservice.Registration { - reg := &appservice.Registration{} - asc.copyToRegistration(reg) - reg.SenderLocalpart = asc.Bot.Username - reg.ServerToken = asc.HSToken - reg.AppToken = asc.ASToken - return reg -} - -func (asc *AppserviceConfig) copyToRegistration(registration *appservice.Registration) { - registration.ID = asc.ID - registration.URL = asc.Address - falseVal := false - registration.RateLimited = &falseVal - registration.EphemeralEvents = asc.EphemeralEvents - registration.SoruEphemeralEvents = asc.EphemeralEvents -} - -func (ec *EncryptionConfig) applyUnstableFlags(registration *appservice.Registration) { - registration.MSC4190 = ec.MSC4190 - registration.MSC3202 = ec.Appservice -} - -// GenerateRegistration generates a registration file for the homeserver. -func (config *Config) GenerateRegistration() *appservice.Registration { - registration := appservice.CreateRegistration() - config.AppService.HSToken = registration.ServerToken - config.AppService.ASToken = registration.AppToken - config.AppService.copyToRegistration(registration) - config.Encryption.applyUnstableFlags(registration) - - registration.SenderLocalpart = random.String(32) - botRegex := regexp.MustCompile(fmt.Sprintf("^@%s:%s$", - regexp.QuoteMeta(config.AppService.Bot.Username), - regexp.QuoteMeta(config.Homeserver.Domain))) - registration.Namespaces.UserIDs.Register(botRegex, true) - registration.Namespaces.UserIDs.Register(config.MakeUserIDRegex(".*"), true) - - return registration -} - -func (config *Config) MakeAppService() *appservice.AppService { - as := appservice.Create() - as.HomeserverDomain = config.Homeserver.Domain - _ = as.SetHomeserverURL(config.Homeserver.Address) - as.Host.Hostname = config.AppService.Hostname - as.Host.Port = config.AppService.Port - as.Registration = config.AppService.GetRegistration() - as.DefaultHTTPRetries = config.Homeserver.RetryLimit - config.Encryption.applyUnstableFlags(as.Registration) - return as -} - -type BotUserConfig struct { - Username string `yaml:"username"` - Displayname string `yaml:"displayname"` - Avatar string `yaml:"avatar"` - - ParsedAvatar id.ContentURI `yaml:"-"` -} - -type serializableBUC BotUserConfig - -func (buc *BotUserConfig) UnmarshalYAML(node *yaml.Node) error { - var sbuc serializableBUC - err := node.Decode(&sbuc) - if err != nil { - return err - } - *buc = (BotUserConfig)(sbuc) - if buc.Avatar != "" && buc.Avatar != "remove" { - buc.ParsedAvatar, err = id.ParseContentURI(buc.Avatar) - if err != nil { - return fmt.Errorf("%w in bot avatar", err) - } - } - return nil -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/backfill.go b/mautrix-patched/bridgev2/bridgeconfig/backfill.go deleted file mode 100644 index ab3ff1aa..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/backfill.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -type BackfillConfig struct { - Enabled bool `yaml:"enabled"` - MaxInitialMessages int `yaml:"max_initial_messages"` - MaxCatchupMessages int `yaml:"max_catchup_messages"` - UnreadHoursThreshold int `yaml:"unread_hours_threshold"` - - Threads BackfillThreadsConfig `yaml:"threads"` - Queue BackfillQueueConfig `yaml:"queue"` -} - -type BackfillThreadsConfig struct { - MaxInitialMessages int `yaml:"max_initial_messages"` -} - -type BackfillQueueConfig struct { - Enabled bool `yaml:"enabled"` - Manual bool `yaml:"manual"` - BatchSize int `yaml:"batch_size"` - BatchDelay int `yaml:"batch_delay"` - MaxBatches int `yaml:"max_batches"` - - MaxBatchesOverride map[string]int `yaml:"max_batches_override"` -} - -func (bcq *BackfillQueueConfig) AnyEnabled() bool { - return bcq.Enabled || bcq.Manual -} - -func (bqc *BackfillQueueConfig) GetOverride(names ...string) int { - for _, name := range names { - override, ok := bqc.MaxBatchesOverride[name] - if ok { - return override - } - } - return bqc.MaxBatches -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/config.go b/mautrix-patched/bridgev2/bridgeconfig/config.go deleted file mode 100644 index 24cc02fa..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/config.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "fmt" - "slices" - "time" - - "go.mau.fi/util/dbutil" - "go.mau.fi/zeroconfig" - "gopkg.in/yaml.v3" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/mediaproxy" -) - -type Config struct { - Network yaml.Node `yaml:"network"` - Bridge BridgeConfig `yaml:"bridge"` - Database dbutil.Config `yaml:"database"` - Homeserver HomeserverConfig `yaml:"homeserver"` - AppService AppserviceConfig `yaml:"appservice"` - Matrix MatrixConfig `yaml:"matrix"` - Analytics AnalyticsConfig `yaml:"analytics"` - Provisioning ProvisioningConfig `yaml:"provisioning"` - PublicMedia PublicMediaConfig `yaml:"public_media"` - DirectMedia DirectMediaConfig `yaml:"direct_media"` - Backfill BackfillConfig `yaml:"backfill"` - DoublePuppet DoublePuppetConfig `yaml:"double_puppet"` - Encryption EncryptionConfig `yaml:"encryption"` - Logging zeroconfig.Config `yaml:"logging"` - - EnvConfigPrefix string `yaml:"env_config_prefix"` - - ManagementRoomTexts ManagementRoomTexts `yaml:"management_room_texts"` -} - -type CleanupAction string - -const ( - CleanupActionNull CleanupAction = "" - CleanupActionNothing CleanupAction = "nothing" - CleanupActionKick CleanupAction = "kick" - CleanupActionUnbridge CleanupAction = "unbridge" - CleanupActionDelete CleanupAction = "delete" -) - -type CleanupOnLogout struct { - Private CleanupAction `yaml:"private"` - Relayed CleanupAction `yaml:"relayed"` - SharedNoUsers CleanupAction `yaml:"shared_no_users"` - SharedHasUsers CleanupAction `yaml:"shared_has_users"` -} - -type CleanupOnLogouts struct { - Enabled bool `yaml:"enabled"` - Manual CleanupOnLogout `yaml:"manual"` - BadCredentials CleanupOnLogout `yaml:"bad_credentials"` -} - -type BridgeConfig struct { - CommandPrefix string `yaml:"command_prefix"` - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` - PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` - AsyncEvents bool `yaml:"async_events"` - SplitPortals bool `yaml:"split_portals"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"` - BridgeStatusNotices string `yaml:"bridge_status_notices"` - UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"` - UnknownErrorMaxAutoReconnects int `yaml:"unknown_error_max_auto_reconnects"` - BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` - BridgeNotices bool `yaml:"bridge_notices"` - TagOnlyOnCreate bool `yaml:"tag_only_on_create"` - OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"` - MuteOnlyOnCreate bool `yaml:"mute_only_on_create"` - DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` - CrossRoomReplies bool `yaml:"cross_room_replies"` - OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` - RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` - KickMatrixUsers bool `yaml:"kick_matrix_users"` - EnableSendStateRequests bool `yaml:"enable_send_state_requests"` - PhoneNumbersInProfile bool `yaml:"phone_numbers_in_profile"` - CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` - Relay RelayConfig `yaml:"relay"` - PortalCreateFilter PortalCreateFilter `yaml:"portal_create_filter"` - Permissions PermissionConfig `yaml:"permissions"` - Backfill BackfillConfig `yaml:"backfill"` -} - -type MatrixConfig struct { - MessageStatusEvents bool `yaml:"message_status_events"` - DeliveryReceipts bool `yaml:"delivery_receipts"` - MessageErrorNotices bool `yaml:"message_error_notices"` - SyncDirectChatList bool `yaml:"sync_direct_chat_list"` - FederateRooms bool `yaml:"federate_rooms"` - UploadFileThreshold int64 `yaml:"upload_file_threshold"` - GhostExtraProfileInfo bool `yaml:"ghost_extra_profile_info"` -} - -type AnalyticsConfig struct { - Token string `yaml:"token"` - URL string `yaml:"url"` - UserID string `yaml:"user_id"` -} - -type ProvisioningConfig struct { - SharedSecret string `yaml:"shared_secret"` - AllowMatrixAuth bool `yaml:"allow_matrix_auth"` - DebugEndpoints bool `yaml:"debug_endpoints"` - EnableSessionTransfers bool `yaml:"enable_session_transfers"` - FailOnWebAuthn bool `yaml:"fail_on_webauthn"` -} - -type DirectMediaConfig struct { - Enabled bool `yaml:"enabled"` - MediaIDPrefix string `yaml:"media_id_prefix"` - mediaproxy.BasicConfig `yaml:",inline"` -} - -type PublicMediaConfig struct { - Enabled bool `yaml:"enabled"` - SigningKey string `yaml:"signing_key"` - Expiry int `yaml:"expiry"` - HashLength int `yaml:"hash_length"` - PathPrefix string `yaml:"path_prefix"` - UseDatabase bool `yaml:"use_database"` -} - -type DoublePuppetConfig struct { - Servers map[string]string `yaml:"servers"` - AllowDiscovery bool `yaml:"allow_discovery"` - Secrets map[string]string `yaml:"secrets"` -} - -type ManagementRoomTexts struct { - Welcome string `yaml:"welcome"` - WelcomeConnected string `yaml:"welcome_connected"` - WelcomeUnconnected string `yaml:"welcome_unconnected"` - AdditionalHelp string `yaml:"additional_help"` -} - -type PortalCreateFilterItem struct { - ID networkid.PortalID `yaml:"id"` - Receiver *networkid.UserLoginID `yaml:"receiver"` -} - -func (pcfi *PortalCreateFilterItem) Equals(other *PortalCreateFilterItem) bool { - if pcfi == nil || other == nil { - return pcfi == other - } else if pcfi.ID != other.ID { - return false - } else if pcfi.Receiver == nil || other.Receiver == nil { - return pcfi.Receiver == other.Receiver - } - return *pcfi.Receiver == *other.Receiver -} - -func (pcfi *PortalCreateFilterItem) Matches(key networkid.PortalKey) bool { - return pcfi != nil && pcfi.ID == key.ID && (pcfi.Receiver == nil || *pcfi.Receiver == key.Receiver) -} - -type umPortalCreateFilterItem PortalCreateFilterItem - -func (pcfi *PortalCreateFilterItem) UnmarshalYAML(node *yaml.Node) error { - err := node.Decode((*umPortalCreateFilterItem)(pcfi)) - if err != nil { - err2 := node.Decode(&pcfi.ID) - if err2 != nil { - return fmt.Errorf("both decode attempts failed: %w / %w", err, err2) - } - } - return nil -} - -type PortalCreateFilterMode string - -const ( - PortalCreateFilterModeAllow PortalCreateFilterMode = "allow" - PortalCreateFilterModeDeny PortalCreateFilterMode = "deny" -) - -type PortalCreateFilter struct { - Mode PortalCreateFilterMode `yaml:"mode"` - List []*PortalCreateFilterItem `yaml:"list"` - - AlwaysDenyFromLogin []networkid.UserLoginID `yaml:"always_deny_from_login"` -} - -func (pcf *PortalCreateFilter) ShouldAllow(source networkid.UserLoginID, key networkid.PortalKey) bool { - if slices.Contains(pcf.AlwaysDenyFromLogin, source) { - return false - } - match := slices.ContainsFunc(pcf.List, func(item *PortalCreateFilterItem) bool { - return item.Matches(key) - }) - switch pcf.Mode { - case PortalCreateFilterModeAllow: - return match - case PortalCreateFilterModeDeny: - return !match - default: - return true - } -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/encryption.go b/mautrix-patched/bridgev2/bridgeconfig/encryption.go deleted file mode 100644 index 934613ca..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/encryption.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "maunium.net/go/mautrix/id" -) - -type EncryptionConfig struct { - Allow bool `yaml:"allow"` - Default bool `yaml:"default"` - Require bool `yaml:"require"` - Appservice bool `yaml:"appservice"` - MSC4190 bool `yaml:"msc4190"` - MSC4392 bool `yaml:"msc4392"` - SelfSign bool `yaml:"self_sign"` - - PlaintextMentions bool `yaml:"plaintext_mentions"` - - PickleKey string `yaml:"pickle_key"` - - DeleteKeys struct { - DeleteOutboundOnAck bool `yaml:"delete_outbound_on_ack"` - DontStoreOutbound bool `yaml:"dont_store_outbound"` - RatchetOnDecrypt bool `yaml:"ratchet_on_decrypt"` - DeleteFullyUsedOnDecrypt bool `yaml:"delete_fully_used_on_decrypt"` - DeletePrevOnNewSession bool `yaml:"delete_prev_on_new_session"` - DeleteOnDeviceDelete bool `yaml:"delete_on_device_delete"` - PeriodicallyDeleteExpired bool `yaml:"periodically_delete_expired"` - DeleteOutdatedInbound bool `yaml:"delete_outdated_inbound"` - } `yaml:"delete_keys"` - - VerificationLevels struct { - Receive id.TrustState `yaml:"receive"` - Send id.TrustState `yaml:"send"` - Share id.TrustState `yaml:"share"` - } `yaml:"verification_levels"` - AllowKeySharing bool `yaml:"allow_key_sharing"` - - Rotation struct { - EnableCustom bool `yaml:"enable_custom"` - Milliseconds int64 `yaml:"milliseconds"` - Messages int `yaml:"messages"` - - DisableDeviceChangeKeyRotation bool `yaml:"disable_device_change_key_rotation"` - } `yaml:"rotation"` -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/homeserver.go b/mautrix-patched/bridgev2/bridgeconfig/homeserver.go deleted file mode 100644 index 4fe417c7..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/homeserver.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -type HomeserverSoftware string - -const ( - SoftwareStandard HomeserverSoftware = "standard" - SoftwareAsmux HomeserverSoftware = "asmux" - SoftwareHungry HomeserverSoftware = "hungry" -) - -var AllowedHomeserverSoftware = map[HomeserverSoftware]bool{ - SoftwareStandard: true, - SoftwareAsmux: true, - SoftwareHungry: true, -} - -type HomeserverConfig struct { - Address string `yaml:"address"` - Domain string `yaml:"domain"` - AsyncMedia bool `yaml:"async_media"` - - PublicAddress string `yaml:"public_address,omitempty"` - - Software HomeserverSoftware `yaml:"software"` - - StatusEndpoint string `yaml:"status_endpoint"` - MessageSendCheckpointEndpoint string `yaml:"message_send_checkpoint_endpoint"` - - Websocket bool `yaml:"websocket"` - WSProxy string `yaml:"websocket_proxy"` - WSPingInterval int `yaml:"ping_interval_seconds"` - - RetryLimit int `yaml:"retry_limit"` -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go b/mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go deleted file mode 100644 index add9fc04..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/legacymigrate.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "fmt" - "net/url" - "os" - "strings" - - up "go.mau.fi/util/configupgrade" -) - -var HackyMigrateLegacyNetworkConfig func(up.Helper) - -func CopyToOtherLocation(helper up.Helper, fieldType up.YAMLType, source, dest []string) { - val, ok := helper.Get(fieldType, source...) - if ok { - helper.Set(fieldType, val, dest...) - } -} - -func CopyMapToOtherLocation(helper up.Helper, source, dest []string) { - val := helper.GetNode(source...) - if val != nil && val.Map != nil { - helper.SetMap(val.Map, dest...) - } -} - -func doMigrateLegacy(helper up.Helper, python bool) { - if HackyMigrateLegacyNetworkConfig == nil { - _, _ = fmt.Fprintln(os.Stderr, "Legacy bridge config detected, but hacky network config migrator is not set") - os.Exit(1) - } - _, _ = fmt.Fprintln(os.Stderr, "Migrating legacy bridge config") - - helper.Copy(up.Str, "homeserver", "address") - helper.Copy(up.Str, "homeserver", "domain") - helper.Copy(up.Str, "homeserver", "software") - helper.Copy(up.Str|up.Null, "homeserver", "status_endpoint") - helper.Copy(up.Str|up.Null, "homeserver", "message_send_checkpoint_endpoint") - helper.Copy(up.Bool, "homeserver", "async_media") - helper.Copy(up.Str|up.Null, "homeserver", "websocket_proxy") - helper.Copy(up.Bool, "homeserver", "websocket") - helper.Copy(up.Int, "homeserver", "ping_interval_seconds") - - helper.Copy(up.Str|up.Null, "appservice", "address") - helper.Copy(up.Str|up.Null, "appservice", "hostname") - helper.Copy(up.Int|up.Null, "appservice", "port") - helper.Copy(up.Str, "appservice", "id") - if python { - CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_username"}, []string{"appservice", "bot", "username"}) - CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_displayname"}, []string{"appservice", "bot", "displayname"}) - CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_avatar"}, []string{"appservice", "bot", "avatar"}) - } else { - helper.Copy(up.Str, "appservice", "bot", "username") - helper.Copy(up.Str, "appservice", "bot", "displayname") - helper.Copy(up.Str, "appservice", "bot", "avatar") - } - helper.Copy(up.Bool, "appservice", "ephemeral_events") - helper.Copy(up.Bool, "appservice", "async_transactions") - helper.Copy(up.Str, "appservice", "as_token") - helper.Copy(up.Str, "appservice", "hs_token") - - helper.Copy(up.Str, "bridge", "command_prefix") - helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") - if oldPM, ok := helper.Get(up.Str, "bridge", "private_chat_portal_meta"); ok && (oldPM == "default" || oldPM == "always") { - helper.Set(up.Bool, "true", "bridge", "private_chat_portal_meta") - } else { - helper.Set(up.Bool, "false", "bridge", "private_chat_portal_meta") - } - helper.Copy(up.Bool, "bridge", "relay", "enabled") - helper.Copy(up.Bool, "bridge", "relay", "admin_only") - helper.Copy(up.Map, "bridge", "permissions") - - if python { - legacyDB, ok := helper.Get(up.Str, "appservice", "database") - if ok { - if strings.HasPrefix(legacyDB, "postgres") { - parsedDB, err := url.Parse(legacyDB) - if err != nil { - panic(err) - } - q := parsedDB.Query() - if parsedDB.Host == "" && !q.Has("host") { - q.Set("host", "/var/run/postgresql") - } else if !q.Has("sslmode") { - q.Set("sslmode", "disable") - } - parsedDB.RawQuery = q.Encode() - helper.Set(up.Str, parsedDB.String(), "database", "uri") - helper.Set(up.Str, "postgres", "database", "type") - } else { - dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///") - helper.Set(up.Str, fmt.Sprintf("file:%s?_txlock=immediate", dbPath), "database", "uri") - helper.Set(up.Str, "sqlite3-fk-wal", "database", "type") - } - } - if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok { - helper.Set(up.Int, legacyDBMinSize, "database", "max_idle_conns") - } - if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok { - helper.Set(up.Int, legacyDBMaxSize, "database", "max_open_conns") - } - } else { - if dbType, ok := helper.Get(up.Str, "appservice", "database", "type"); ok && dbType == "sqlite3" { - helper.Set(up.Str, "sqlite3-fk-wal", "database", "type") - } else { - CopyToOtherLocation(helper, up.Str, []string{"appservice", "database", "type"}, []string{"database", "type"}) - } - CopyToOtherLocation(helper, up.Str, []string{"appservice", "database", "uri"}, []string{"database", "uri"}) - CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_open_conns"}, []string{"database", "max_open_conns"}) - CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_idle_conns"}, []string{"database", "max_idle_conns"}) - CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_conn_idle_time"}, []string{"database", "max_conn_idle_time"}) - CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_conn_lifetime"}, []string{"database", "max_conn_lifetime"}) - } - - if python { - if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") { - helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "appservice", "username_template") - } - } else { - CopyToOtherLocation(helper, up.Str, []string{"bridge", "username_template"}, []string{"appservice", "username_template"}) - } - - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "message_status_events"}, []string{"matrix", "message_status_events"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "delivery_receipts"}, []string{"matrix", "delivery_receipts"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "message_error_notices"}, []string{"matrix", "message_error_notices"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chat_list"}, []string{"matrix", "sync_direct_chat_list"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "federate_rooms"}, []string{"matrix", "federate_rooms"}) - - CopyToOtherLocation(helper, up.Str, []string{"bridge", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"}) - CopyToOtherLocation(helper, up.Str, []string{"appservice", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "provisioning", "debug_endpoints"}, []string{"provisioning", "debug_endpoints"}) - - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "double_puppet_allow_discovery"}, []string{"double_puppet", "allow_discovery"}) - CopyMapToOtherLocation(helper, []string{"bridge", "double_puppet_server_map"}, []string{"double_puppet", "servers"}) - CopyMapToOtherLocation(helper, []string{"bridge", "login_shared_secret_map"}, []string{"double_puppet", "secrets"}) - - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "allow"}, []string{"encryption", "allow"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "default"}, []string{"encryption", "default"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "require"}, []string{"encryption", "require"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "appservice"}, []string{"encryption", "appservice"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "msc4190"}, []string{"encryption", "msc4190"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "self_sign"}, []string{"encryption", "self_sign"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "allow_key_sharing"}, []string{"encryption", "allow_key_sharing"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_outbound_on_ack"}, []string{"encryption", "delete_keys", "delete_outbound_on_ack"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "dont_store_outbound"}, []string{"encryption", "delete_keys", "dont_store_outbound"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "ratchet_on_decrypt"}, []string{"encryption", "delete_keys", "ratchet_on_decrypt"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt"}, []string{"encryption", "delete_keys", "delete_fully_used_on_decrypt"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_prev_on_new_session"}, []string{"encryption", "delete_keys", "delete_prev_on_new_session"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_on_device_delete"}, []string{"encryption", "delete_keys", "delete_on_device_delete"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "periodically_delete_expired"}, []string{"encryption", "delete_keys", "periodically_delete_expired"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_outdated_inbound"}, []string{"encryption", "delete_keys", "delete_outdated_inbound"}) - CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "receive"}, []string{"encryption", "verification_levels", "receive"}) - CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "send"}, []string{"encryption", "verification_levels", "send"}) - CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "share"}, []string{"encryption", "verification_levels", "share"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "rotation", "enable_custom"}, []string{"encryption", "rotation", "enable_custom"}) - CopyToOtherLocation(helper, up.Int, []string{"bridge", "encryption", "rotation", "milliseconds"}, []string{"encryption", "rotation", "milliseconds"}) - CopyToOtherLocation(helper, up.Int, []string{"bridge", "encryption", "rotation", "messages"}, []string{"encryption", "rotation", "messages"}) - CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "rotation", "disable_device_change_key_rotation"}, []string{"encryption", "rotation", "disable_device_change_key_rotation"}) - - if helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "print_level") != nil || helper.GetNode("logging", "file_name_format") != nil) { - _, _ = fmt.Fprintln(os.Stderr, "Migrating maulogger configs is not supported") - } else if (helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "handlers") != nil)) || python { - _, _ = fmt.Fprintln(os.Stderr, "Migrating Python log configs is not supported") - } else { - helper.Copy(up.Map, "logging") - } - - HackyMigrateLegacyNetworkConfig(helper) -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/permissions.go b/mautrix-patched/bridgev2/bridgeconfig/permissions.go deleted file mode 100644 index 9efe068e..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/permissions.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "fmt" - "os" - "strconv" - "strings" - - "gopkg.in/yaml.v3" - - "maunium.net/go/mautrix/id" -) - -type Permissions struct { - SendEvents bool `yaml:"send_events"` - Commands bool `yaml:"commands"` - Login bool `yaml:"login"` - DoublePuppet bool `yaml:"double_puppet"` - Admin bool `yaml:"admin"` - ManageRelay bool `yaml:"manage_relay"` - MaxLogins int `yaml:"max_logins"` -} - -type PermissionConfig map[string]*Permissions - -func boolToInt(val bool) int { - if val { - return 1 - } - return 0 -} - -func (pc PermissionConfig) IsConfigured() bool { - _, hasWildcard := pc["*"] - _, hasExampleDomain := pc["example.com"] - _, hasExampleUser := pc["@admin:example.com"] - exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) - return len(pc) > exampleLen -} - -func (pc PermissionConfig) Get(userID id.UserID) Permissions { - if level, ok := pc[string(userID)]; ok { - return *level - } else if level, ok = pc[userID.Homeserver()]; len(userID.Homeserver()) > 0 && ok { - return *level - } else if level, ok = pc["*"]; ok { - return *level - } else { - return PermissionLevelBlock - } -} - -var ( - PermissionLevelBlock = Permissions{} - PermissionLevelRelay = Permissions{SendEvents: true} - PermissionLevelCommands = Permissions{SendEvents: true, Commands: true, ManageRelay: true} - PermissionLevelUser = Permissions{SendEvents: true, Commands: true, ManageRelay: true, Login: true, DoublePuppet: true} - PermissionLevelAdmin = Permissions{SendEvents: true, Commands: true, ManageRelay: true, Login: true, DoublePuppet: true, Admin: true} -) - -var namesToLevels = map[string]Permissions{ - "block": PermissionLevelBlock, - "relay": PermissionLevelRelay, - "commands": PermissionLevelCommands, - "user": PermissionLevelUser, - "admin": PermissionLevelAdmin, -} - -var levelsToNames = map[Permissions]string{ - PermissionLevelBlock: "block", - PermissionLevelRelay: "relay", - PermissionLevelCommands: "commands", - PermissionLevelUser: "user", - PermissionLevelAdmin: "admin", -} - -type umPerm Permissions - -func (p *Permissions) UnmarshalYAML(perm *yaml.Node) error { - switch perm.Tag { - case "!!str": - var ok bool - *p, ok = namesToLevels[strings.ToLower(perm.Value)] - if !ok { - return fmt.Errorf("invalid permissions level %s", perm.Value) - } - return nil - case "!!map": - err := perm.Decode((*umPerm)(p)) - return err - case "!!int": - val, err := strconv.Atoi(perm.Value) - if err != nil { - return fmt.Errorf("invalid permissions level %s", perm.Value) - } - _, _ = fmt.Fprintln(os.Stderr, "Warning: config contains deprecated integer permission values") - // Integer values are deprecated, so they're hardcoded - if val < 5 { - *p = PermissionLevelBlock - } else if val < 10 { - *p = PermissionLevelRelay - } else if val < 100 { - *p = PermissionLevelUser - } else { - *p = PermissionLevelAdmin - } - return nil - default: - return fmt.Errorf("invalid permissions type %s", perm.Tag) - } -} - -func (p *Permissions) MarshalYAML() (any, error) { - if level, ok := levelsToNames[*p]; ok { - return level, nil - } - return umPerm(*p), nil -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/relay.go b/mautrix-patched/bridgev2/bridgeconfig/relay.go deleted file mode 100644 index 0ea8eb2a..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/relay.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "fmt" - "strings" - "text/template" - - "gopkg.in/yaml.v3" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" -) - -type RelayConfig struct { - Enabled bool `yaml:"enabled"` - AdminOnly bool `yaml:"admin_only"` - PreferDefault bool `yaml:"prefer_default"` - AllowBridge bool `yaml:"allow_bridge"` - DefaultRelays []networkid.UserLoginID `yaml:"default_relays"` - MessageFormats map[event.MessageType]string `yaml:"message_formats"` - DisplaynameFormat string `yaml:"displayname_format"` - messageTemplates *template.Template `yaml:"-"` - nameTemplate *template.Template `yaml:"-"` -} - -type umRelayConfig RelayConfig - -func (rc *RelayConfig) UnmarshalYAML(node *yaml.Node) error { - err := node.Decode((*umRelayConfig)(rc)) - if err != nil { - return err - } - - rc.messageTemplates = template.New("messageTemplates") - for key, template := range rc.MessageFormats { - _, err = rc.messageTemplates.New(string(key)).Parse(template) - if err != nil { - return err - } - } - - rc.nameTemplate, err = template.New("nameTemplate").Parse(rc.DisplaynameFormat) - if err != nil { - return err - } - - return nil -} - -type formatData struct { - Sender any - Content *event.MessageEventContent - Caption string - Message string - FileName string -} - -func isMedia(msgType event.MessageType) bool { - switch msgType { - case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: - return true - default: - return false - } -} - -func (rc *RelayConfig) FormatMessage(content *event.MessageEventContent, sender any) (*event.MessageEventContent, error) { - _, isSupported := rc.MessageFormats[content.MsgType] - if !isSupported { - return nil, fmt.Errorf("relay format for %q is not defined in config", content.MsgType) - } - contentCopy := *content - content = &contentCopy - content.EnsureHasHTML() - fd := &formatData{ - Sender: sender, - Content: content, - Message: content.FormattedBody, - } - fd.Message = content.FormattedBody - if content.FileName != "" { - fd.FileName = content.FileName - if content.FileName != content.Body { - fd.Caption = fd.Message - } - } else if isMedia(content.MsgType) { - content.FileName = content.Body - fd.FileName = content.Body - } - var output strings.Builder - err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), fd) - if err != nil { - return nil, err - } - content.FormattedBody = output.String() - content.Body = format.HTMLToText(content.FormattedBody) - return content, nil -} - -func (rc *RelayConfig) FormatName(sender any) string { - var buf strings.Builder - _ = rc.nameTemplate.Execute(&buf, sender) - return strings.TrimSpace(buf.String()) -} diff --git a/mautrix-patched/bridgev2/bridgeconfig/upgrade.go b/mautrix-patched/bridgev2/bridgeconfig/upgrade.go deleted file mode 100644 index 75ca76ea..00000000 --- a/mautrix-patched/bridgev2/bridgeconfig/upgrade.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgeconfig - -import ( - "fmt" - - up "go.mau.fi/util/configupgrade" - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/federation" -) - -func doUpgrade(helper up.Helper) { - if _, isLegacyConfig := helper.Get(up.Str, "appservice", "database", "uri"); isLegacyConfig { - doMigrateLegacy(helper, false) - return - } else if _, isLegacyPython := helper.Get(up.Str, "appservice", "database"); isLegacyPython { - doMigrateLegacy(helper, true) - return - } - - helper.Copy(up.Str, "bridge", "command_prefix") - helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") - helper.Copy(up.Bool, "bridge", "private_chat_portal_meta") - helper.Copy(up.Bool, "bridge", "async_events") - helper.Copy(up.Bool, "bridge", "split_portals") - helper.Copy(up.Bool, "bridge", "resend_bridge_info") - helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key") - helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices") - helper.Copy(up.Str|up.Int|up.Null, "bridge", "unknown_error_auto_reconnect") - helper.Copy(up.Int, "bridge", "unknown_error_max_auto_reconnects") - helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") - helper.Copy(up.Bool, "bridge", "bridge_notices") - helper.Copy(up.Bool, "bridge", "tag_only_on_create") - helper.Copy(up.List, "bridge", "only_bridge_tags") - helper.Copy(up.Bool, "bridge", "mute_only_on_create") - helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") - helper.Copy(up.Bool, "bridge", "cross_room_replies") - helper.Copy(up.Bool, "bridge", "revert_failed_state_changes") - helper.Copy(up.Bool, "bridge", "kick_matrix_users") - helper.Copy(up.Bool, "bridge", "enable_send_state_requests") - helper.Copy(up.Bool, "bridge", "phone_numbers_in_profile") - helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "shared_no_users") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "shared_has_users") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "private") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "relayed") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "shared_no_users") - helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "shared_has_users") - helper.Copy(up.Bool, "bridge", "relay", "enabled") - helper.Copy(up.Bool, "bridge", "relay", "admin_only") - helper.Copy(up.Bool, "bridge", "relay", "prefer_default") - helper.Copy(up.Bool, "bridge", "relay", "allow_bridge") - helper.Copy(up.List, "bridge", "relay", "default_relays") - helper.Copy(up.Map, "bridge", "relay", "message_formats") - helper.Copy(up.Str, "bridge", "relay", "displayname_format") - helper.Copy(up.Str, "bridge", "portal_create_filter", "mode") - helper.Copy(up.List, "bridge", "portal_create_filter", "list") - helper.Copy(up.List, "bridge", "portal_create_filter", "always_deny_from_login") - helper.Copy(up.Map, "bridge", "permissions") - - if dbType, ok := helper.Get(up.Str, "database", "type"); ok && dbType == "sqlite3" { - fmt.Println("Warning: invalid database type sqlite3 in config. Autocorrecting to sqlite3-fk-wal") - helper.Set(up.Str, "sqlite3-fk-wal", "database", "type") - } else { - helper.Copy(up.Str, "database", "type") - } - helper.Copy(up.Str, "database", "uri") - helper.Copy(up.Int, "database", "max_open_conns") - helper.Copy(up.Int, "database", "max_idle_conns") - helper.Copy(up.Str|up.Null, "database", "max_conn_idle_time") - helper.Copy(up.Str|up.Null, "database", "max_conn_lifetime") - - helper.Copy(up.Str, "homeserver", "address") - helper.Copy(up.Str, "homeserver", "domain") - helper.Copy(up.Str, "homeserver", "software") - helper.Copy(up.Str|up.Null, "homeserver", "status_endpoint") - helper.Copy(up.Str|up.Null, "homeserver", "message_send_checkpoint_endpoint") - helper.Copy(up.Bool, "homeserver", "async_media") - helper.Copy(up.Str|up.Null, "homeserver", "websocket_proxy") - helper.Copy(up.Bool, "homeserver", "websocket") - helper.Copy(up.Int, "homeserver", "ping_interval_seconds") - helper.Copy(up.Int, "homeserver", "retry_limit") - - helper.Copy(up.Str|up.Null, "appservice", "address") - helper.Copy(up.Str|up.Null, "appservice", "public_address") - helper.Copy(up.Str|up.Null, "appservice", "hostname") - helper.Copy(up.Int|up.Null, "appservice", "port") - helper.Copy(up.Str, "appservice", "id") - helper.Copy(up.Str, "appservice", "bot", "username") - helper.Copy(up.Str, "appservice", "bot", "displayname") - helper.Copy(up.Str, "appservice", "bot", "avatar") - helper.Copy(up.Bool, "appservice", "ephemeral_events") - helper.Copy(up.Bool, "appservice", "async_transactions") - helper.Copy(up.Str, "appservice", "as_token") - helper.Copy(up.Str, "appservice", "hs_token") - helper.Copy(up.Str, "appservice", "username_template") - - helper.Copy(up.Bool, "matrix", "message_status_events") - helper.Copy(up.Bool, "matrix", "delivery_receipts") - helper.Copy(up.Bool, "matrix", "message_error_notices") - helper.Copy(up.Bool, "matrix", "sync_direct_chat_list") - helper.Copy(up.Bool, "matrix", "federate_rooms") - helper.Copy(up.Int, "matrix", "upload_file_threshold") - helper.Copy(up.Bool, "matrix", "ghost_extra_profile_info") - - helper.Copy(up.Str|up.Null, "analytics", "token") - helper.Copy(up.Str|up.Null, "analytics", "url") - helper.Copy(up.Str|up.Null, "analytics", "user_id") - - if secret, ok := helper.Get(up.Str, "provisioning", "shared_secret"); !ok || secret == "generate" { - sharedSecret := random.String(64) - helper.Set(up.Str, sharedSecret, "provisioning", "shared_secret") - } else { - helper.Copy(up.Str, "provisioning", "shared_secret") - } - helper.Copy(up.Bool, "provisioning", "allow_matrix_auth") - helper.Copy(up.Bool, "provisioning", "debug_endpoints") - helper.Copy(up.Bool, "provisioning", "enable_session_transfers") - helper.Copy(up.Bool, "provisioning", "fail_on_webauthn") - - helper.Copy(up.Bool, "direct_media", "enabled") - helper.Copy(up.Str|up.Null, "direct_media", "media_id_prefix") - helper.Copy(up.Str, "direct_media", "server_name") - helper.Copy(up.Str|up.Null, "direct_media", "well_known_response") - helper.Copy(up.Bool, "direct_media", "allow_proxy") - if serverKey, ok := helper.Get(up.Str, "direct_media", "server_key"); !ok || serverKey == "generate" { - serverKey = federation.GenerateSigningKey().SynapseString() - helper.Set(up.Str, serverKey, "direct_media", "server_key") - } else { - helper.Copy(up.Str, "direct_media", "server_key") - } - - helper.Copy(up.Bool, "public_media", "enabled") - if signingKey, ok := helper.Get(up.Str, "public_media", "signing_key"); !ok || signingKey == "generate" { - helper.Set(up.Str, random.String(64), "public_media", "signing_key") - } else { - helper.Copy(up.Str, "public_media", "signing_key") - } - helper.Copy(up.Int, "public_media", "expiry") - helper.Copy(up.Int, "public_media", "hash_length") - helper.Copy(up.Str|up.Null, "public_media", "path_prefix") - helper.Copy(up.Bool, "public_media", "use_database") - - helper.Copy(up.Bool, "backfill", "enabled") - helper.Copy(up.Int, "backfill", "max_initial_messages") - helper.Copy(up.Int, "backfill", "max_catchup_messages") - helper.Copy(up.Int, "backfill", "unread_hours_threshold") - helper.Copy(up.Int, "backfill", "threads", "max_initial_messages") - helper.Copy(up.Bool, "backfill", "queue", "enabled") - helper.Copy(up.Bool, "backfill", "queue", "manual") - helper.Copy(up.Int, "backfill", "queue", "batch_size") - helper.Copy(up.Int, "backfill", "queue", "batch_delay") - helper.Copy(up.Int, "backfill", "queue", "max_batches") - helper.Copy(up.Map, "backfill", "queue", "max_batches_override") - - helper.Copy(up.Map, "double_puppet", "servers") - helper.Copy(up.Bool, "double_puppet", "allow_discovery") - helper.Copy(up.Map, "double_puppet", "secrets") - - helper.Copy(up.Bool, "encryption", "allow") - helper.Copy(up.Bool, "encryption", "default") - helper.Copy(up.Bool, "encryption", "require") - helper.Copy(up.Bool, "encryption", "appservice") - if val, ok := helper.Get(up.Bool, "appservice", "msc4190"); ok { - helper.Set(up.Bool, val, "encryption", "msc4190") - } else { - helper.Copy(up.Bool, "encryption", "msc4190") - } - helper.Copy(up.Bool, "encryption", "msc4392") - helper.Copy(up.Bool, "encryption", "self_sign") - helper.Copy(up.Bool, "encryption", "allow_key_sharing") - helper.Copy(up.Bool, "encryption", "plaintext_mentions") - if secret, ok := helper.Get(up.Str, "encryption", "pickle_key"); !ok || secret == "generate" { - helper.Set(up.Str, random.String(64), "encryption", "pickle_key") - } else { - helper.Copy(up.Str, "encryption", "pickle_key") - } - helper.Copy(up.Bool, "encryption", "delete_keys", "delete_outbound_on_ack") - helper.Copy(up.Bool, "encryption", "delete_keys", "dont_store_outbound") - helper.Copy(up.Bool, "encryption", "delete_keys", "ratchet_on_decrypt") - helper.Copy(up.Bool, "encryption", "delete_keys", "delete_fully_used_on_decrypt") - helper.Copy(up.Bool, "encryption", "delete_keys", "delete_prev_on_new_session") - helper.Copy(up.Bool, "encryption", "delete_keys", "delete_on_device_delete") - helper.Copy(up.Bool, "encryption", "delete_keys", "periodically_delete_expired") - helper.Copy(up.Bool, "encryption", "delete_keys", "delete_outdated_inbound") - helper.Copy(up.Str, "encryption", "verification_levels", "receive") - helper.Copy(up.Str, "encryption", "verification_levels", "send") - helper.Copy(up.Str, "encryption", "verification_levels", "share") - helper.Copy(up.Bool, "encryption", "rotation", "enable_custom") - helper.Copy(up.Int, "encryption", "rotation", "milliseconds") - helper.Copy(up.Int, "encryption", "rotation", "messages") - helper.Copy(up.Bool, "encryption", "rotation", "disable_device_change_key_rotation") - - helper.Copy(up.Str|up.Null, "env_config_prefix") - - helper.Copy(up.Map, "logging") -} - -var SpacedBlocks = [][]string{ - {"bridge"}, - {"bridge", "bridge_matrix_leave"}, - {"bridge", "cleanup_on_logout"}, - {"bridge", "relay"}, - {"bridge", "portal_create_filter"}, - {"bridge", "permissions"}, - {"database"}, - {"homeserver"}, - {"homeserver", "software"}, - {"homeserver", "websocket"}, - {"appservice"}, - {"appservice", "hostname"}, - {"appservice", "id"}, - {"appservice", "ephemeral_events"}, - {"appservice", "as_token"}, - {"appservice", "username_template"}, - {"matrix"}, - {"analytics"}, - {"provisioning"}, - {"public_media"}, - {"direct_media"}, - {"backfill"}, - {"double_puppet"}, - {"encryption"}, - {"env_config_prefix"}, - {"logging"}, -} - -// Upgrader is a config upgrader that copies the default fields in the homeserver, appservice and logging blocks. -var Upgrader up.SpacedUpgrader = &up.StructUpgrader{ - SimpleUpgrader: up.SimpleUpgrader(doUpgrade), - Blocks: SpacedBlocks, -} diff --git a/mautrix-patched/bridgev2/bridgestate.go b/mautrix-patched/bridgev2/bridgestate.go deleted file mode 100644 index 96d9fd5c..00000000 --- a/mautrix-patched/bridgev2/bridgestate.go +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "fmt" - "math/rand/v2" - "runtime/debug" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exfmt" - - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" -) - -var CatchBridgeStateQueuePanics = true - -type BridgeStateQueue struct { - prevUnsent *status.BridgeState - prevSent *status.BridgeState - errorSent bool - ch chan status.BridgeState - bridge *Bridge - login *UserLogin - - firstTransientDisconnect time.Time - cancelScheduledNotice atomic.Pointer[context.CancelFunc] - - stopChan chan struct{} - stopReconnect atomic.Pointer[context.CancelFunc] - - unknownErrorReconnects int -} - -func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) { - state = state.Fill(nil) - for { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - if err := br.Matrix.SendBridgeStatus(ctx, &state); err != nil { - br.Log.Warn().Err(err).Msg("Failed to update global bridge state") - cancel() - time.Sleep(5 * time.Second) - continue - } else { - br.Log.Debug().Any("bridge_state", state).Msg("Sent new global bridge state") - cancel() - break - } - } -} - -func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue { - bsq := &BridgeStateQueue{ - ch: make(chan status.BridgeState, 10), - stopChan: make(chan struct{}), - bridge: br, - login: login, - } - go bsq.loop() - return bsq -} - -func (bsq *BridgeStateQueue) Destroy() { - close(bsq.stopChan) - close(bsq.ch) - bsq.StopUnknownErrorReconnect() -} - -func (bsq *BridgeStateQueue) StopUnknownErrorReconnect() { - if bsq == nil { - return - } - if cancelFn := bsq.stopReconnect.Swap(nil); cancelFn != nil { - (*cancelFn)() - } - if cancelFn := bsq.cancelScheduledNotice.Swap(nil); cancelFn != nil { - (*cancelFn)() - } -} - -func (bsq *BridgeStateQueue) loop() { - if CatchBridgeStateQueuePanics { - defer func() { - err := recover() - if err != nil { - bsq.login.Log.Error(). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()). - Any(zerolog.ErrorFieldName, err). - Msg("Panic in bridge state loop") - } - }() - } - for state := range bsq.ch { - bsq.immediateSendBridgeState(state) - } -} - -func (bsq *BridgeStateQueue) scheduleNotice(triggeredBy status.BridgeState) { - log := bsq.login.Log.With().Str("action", "transient disconnect notice").Logger() - ctx := log.WithContext(bsq.bridge.BackgroundCtx) - if !bsq.waitForTransientDisconnectReconnect(ctx) { - return - } - prevUnsent := bsq.GetPrevUnsent() - prev := bsq.GetPrev() - if triggeredBy.Timestamp != prev.Timestamp || len(bsq.ch) > 0 || bsq.errorSent || - prevUnsent.StateEvent != status.StateTransientDisconnect || prev.StateEvent != status.StateTransientDisconnect { - log.Trace().Any("triggered_by", triggeredBy).Msg("Not sending delayed transient disconnect notice") - return - } - log.Debug().Any("triggered_by", triggeredBy).Msg("Sending delayed transient disconnect notice") - bsq.sendNotice(ctx, triggeredBy, true) -} - -func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState, isDelayed bool) { - noticeConfig := bsq.bridge.Config.BridgeStatusNotices - isError := state.StateEvent == status.StateBadCredentials || - state.StateEvent == status.StateUnknownError || - state.UserAction == status.UserActionOpenNative || - (isDelayed && state.StateEvent == status.StateTransientDisconnect) - sendNotice := noticeConfig == "all" || (noticeConfig == "errors" && - (isError || (bsq.errorSent && state.StateEvent == status.StateConnected))) - if state.StateEvent != status.StateTransientDisconnect && state.StateEvent != status.StateUnknownError { - bsq.firstTransientDisconnect = time.Time{} - } - if !sendNotice { - if !bsq.errorSent && !isDelayed && noticeConfig == "errors" && state.StateEvent == status.StateTransientDisconnect { - if bsq.firstTransientDisconnect.IsZero() { - bsq.firstTransientDisconnect = time.Now() - } - go bsq.scheduleNotice(state) - } - return - } - managementRoom, err := bsq.login.User.GetManagementRoom(ctx) - if err != nil { - bsq.login.Log.Err(err).Msg("Failed to get management room") - return - } - name := bsq.login.RemoteName - if name == "" { - name = fmt.Sprintf("`%s`", bsq.login.ID) - } - message := fmt.Sprintf("State update for %s: `%s`", name, state.StateEvent) - if state.Error != "" { - message += fmt.Sprintf(" (`%s`)", state.Error) - } - if isDelayed { - message += fmt.Sprintf(" not resolved after waiting %s", exfmt.Duration(TransientDisconnectNoticeDelay)) - } - if state.Message != "" { - message += fmt.Sprintf(": %s", state.Message) - } - content := format.RenderMarkdown(message, true, false) - if !isError { - content.MsgType = event.MsgNotice - } - _, err = bsq.bridge.Bot.SendMessage(ctx, managementRoom, event.EventMessage, &event.Content{ - Parsed: content, - Raw: map[string]any{ - "fi.mau.bridge_state": state, - }, - }, nil) - if err != nil { - bsq.login.Log.Err(err).Msg("Failed to send bridge state notice") - } else { - bsq.errorSent = isError - } -} - -func (bsq *BridgeStateQueue) unknownErrorReconnect(triggeredBy status.BridgeState) { - log := bsq.login.Log.With().Str("action", "unknown error reconnect").Logger() - ctx := log.WithContext(bsq.bridge.BackgroundCtx) - if !bsq.waitForUnknownErrorReconnect(ctx) { - return - } - prevUnsent := bsq.GetPrevUnsent() - prev := bsq.GetPrev() - if triggeredBy.Timestamp != prev.Timestamp { - log.Debug().Msg("Not reconnecting as a new bridge state was sent after the unknown error") - return - } else if len(bsq.ch) > 0 { - log.Warn().Msg("Not reconnecting as there are unsent bridge states") - return - } else if prevUnsent.StateEvent != status.StateUnknownError || prev.StateEvent != status.StateUnknownError { - log.Debug().Msg("Not reconnecting as the previous state was not an unknown error") - return - } else if bsq.unknownErrorReconnects > bsq.bridge.Config.UnknownErrorMaxAutoReconnects { - log.Warn().Msg("Not reconnecting as the maximum number of unknown error reconnects has been reached") - return - } - bsq.unknownErrorReconnects++ - log.Info(). - Int("reconnect_num", bsq.unknownErrorReconnects). - Msg("Disconnecting and reconnecting login due to unknown error") - bsq.login.Disconnect() - log.Debug().Msg("Disconnection finished, recreating client and reconnecting") - err := bsq.login.recreateClient(ctx) - if err != nil { - log.Err(err).Msg("Failed to recreate client after unknown error") - return - } - bsq.login.Client.Connect(ctx) - log.Debug().Msg("Reconnection finished") -} - -func (bsq *BridgeStateQueue) waitForUnknownErrorReconnect(ctx context.Context) bool { - reconnectIn := bsq.bridge.Config.UnknownErrorAutoReconnect - // Don't allow too low values - if reconnectIn < 1*time.Minute { - return false - } - reconnectIn += time.Duration(rand.Int64N(int64(float64(reconnectIn)*0.4)) - int64(float64(reconnectIn)*0.2)) - return bsq.waitForReconnect(ctx, reconnectIn, &bsq.stopReconnect) -} - -const TransientDisconnectNoticeDelay = 3 * time.Minute - -func (bsq *BridgeStateQueue) waitForTransientDisconnectReconnect(ctx context.Context) bool { - timeUntilSchedule := time.Until(bsq.firstTransientDisconnect.Add(TransientDisconnectNoticeDelay)) - zerolog.Ctx(ctx).Trace(). - Stringer("duration", timeUntilSchedule). - Msg("Waiting before sending notice about transient disconnect") - return bsq.waitForReconnect(ctx, timeUntilSchedule, &bsq.cancelScheduledNotice) -} - -func (bsq *BridgeStateQueue) waitForReconnect( - ctx context.Context, reconnectIn time.Duration, ptr *atomic.Pointer[context.CancelFunc], -) bool { - cancelCtx, cancel := context.WithCancel(ctx) - defer cancel() - if oldCancel := ptr.Swap(&cancel); oldCancel != nil { - (*oldCancel)() - } - select { - case <-time.After(reconnectIn): - return ptr.CompareAndSwap(&cancel, nil) - case <-cancelCtx.Done(): - return false - case <-bsq.stopChan: - return false - } -} - -func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) { - if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) { - bsq.login.Log.Debug(). - Str("state_event", string(state.StateEvent)). - Msg("Not sending bridge state as it's a duplicate") - return - } - if state.StateEvent == status.StateUnknownError { - go bsq.unknownErrorReconnect(state) - } - - ctx := bsq.login.Log.WithContext(context.Background()) - bsq.sendNotice(ctx, state, false) - - retryIn := 2 - for { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - err := bsq.bridge.Matrix.SendBridgeStatus(ctx, &state) - cancel() - - if err != nil { - bsq.login.Log.Warn().Err(err). - Int("retry_in_seconds", retryIn). - Msg("Failed to update bridge state") - time.Sleep(time.Duration(retryIn) * time.Second) - retryIn *= 2 - if retryIn > 64 { - retryIn = 64 - } - } else { - bsq.prevSent = &state - bsq.login.Log.Debug(). - Any("bridge_state", state). - Msg("Sent new bridge state") - return - } - } -} - -func (bsq *BridgeStateQueue) Send(state status.BridgeState) { - if bsq == nil { - return - } - - state = state.Fill(bsq.login) - bsq.prevUnsent = &state - - if len(bsq.ch) >= 8 { - bsq.login.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item") - select { - case <-bsq.ch: - default: - } - } - select { - case bsq.ch <- state: - default: - bsq.login.Log.Error().Msg("Bridge state queue is full, dropped new state") - } -} - -func (bsq *BridgeStateQueue) GetPrev() status.BridgeState { - if bsq != nil && bsq.prevSent != nil { - return *bsq.prevSent - } - return status.BridgeState{} -} - -func (bsq *BridgeStateQueue) GetPrevUnsent() status.BridgeState { - if bsq != nil && bsq.prevSent != nil { - return *bsq.prevUnsent - } - return status.BridgeState{} -} - -func (bsq *BridgeStateQueue) SetPrev(prev status.BridgeState) { - if bsq != nil { - bsq.prevSent = &prev - } -} diff --git a/mautrix-patched/bridgev2/commands/cleanup.go b/mautrix-patched/bridgev2/commands/cleanup.go deleted file mode 100644 index dc21a16e..00000000 --- a/mautrix-patched/bridgev2/commands/cleanup.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "maunium.net/go/mautrix/bridgev2" -) - -var CommandDeletePortal = &FullHandler{ - Func: func(ce *Event) { - // TODO clean up child portals? - err := ce.Portal.Delete(ce.Ctx) - if err != nil { - ce.Reply("Failed to delete portal: %v", err) - return - } - err = ce.Bot.DeleteRoom(ce.Ctx, ce.Portal.MXID, false) - if err != nil { - ce.Reply("Failed to clean up room: %v", err) - } - ce.MessageStatus.DisableMSS = true - }, - Name: "delete-portal", - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Delete the current portal room", - }, - RequiresAdmin: true, - RequiresPortal: true, -} - -var CommandDeleteAllPortals = &FullHandler{ - Func: func(ce *Event) { - portals, err := ce.Bridge.GetAllPortals(ce.Ctx) - if err != nil { - ce.Reply("Failed to get portals: %v", err) - return - } - bridgev2.DeleteManyPortals(ce.Ctx, portals, func(portal *bridgev2.Portal, delete bool, err error) { - if !delete { - ce.Reply("Failed to delete portal %s: %v", portal.MXID, err) - } else { - ce.Reply("Failed to clean up room %s: %v", portal.MXID, err) - } - }) - }, - Name: "delete-all-portals", - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Delete all portals the bridge knows about", - }, - RequiresAdmin: true, -} - -var CommandSetManagementRoom = &FullHandler{ - Func: func(ce *Event) { - if ce.User.ManagementRoom == ce.RoomID { - ce.Reply("This room is already your management room") - return - } else if ce.Portal != nil { - ce.Reply("This is a portal room: you can't set this as your management room") - return - } - members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID) - if err != nil { - ce.Log.Err(err).Msg("Failed to get room members to check if room can be a management room") - ce.Reply("Failed to get room members") - return - } - _, hasBot := members[ce.Bot.GetMXID()] - if !hasBot { - // This reply will probably fail, but whatever - ce.Reply("The bridge bot must be in the room to set it as your management room") - return - } else if len(members) != 2 { - ce.Reply("Your management room must not have any members other than you and the bridge bot") - return - } - ce.User.ManagementRoom = ce.RoomID - err = ce.User.Save(ce.Ctx) - if err != nil { - ce.Log.Err(err).Msg("Failed to save management room") - ce.Reply("Failed to save management room") - } else { - ce.Reply("Management room updated") - } - }, - Name: "set-management-room", - Help: HelpMeta{ - Section: HelpSectionGeneral, - Description: "Mark this room as your management room", - }, -} diff --git a/mautrix-patched/bridgev2/commands/debug.go b/mautrix-patched/bridgev2/commands/debug.go deleted file mode 100644 index 1cae98fe..00000000 --- a/mautrix-patched/bridgev2/commands/debug.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "encoding/json" - "strings" - "time" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" -) - -var CommandRegisterPush = &FullHandler{ - Func: func(ce *Event) { - if len(ce.Args) < 3 { - ce.Reply("Usage: `$cmdprefix debug-register-push `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) - return - } - pushType := bridgev2.PushTypeFromString(ce.Args[1]) - if pushType == bridgev2.PushTypeUnknown { - ce.Reply("Unknown push type `%s`. Allowed types: `web`, `apns`, `fcm`", ce.Args[1]) - return - } - login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) - if login == nil || login.UserMXID != ce.User.MXID { - ce.Reply("Login `%s` not found", ce.Args[0]) - return - } - pushable, ok := login.Client.(bridgev2.PushableNetworkAPI) - if !ok { - ce.Reply("This network connector does not support push registration") - return - } - pushToken := strings.Join(ce.Args[2:], " ") - if pushToken == "null" { - pushToken = "" - } - err := pushable.RegisterPushNotifications(ce.Ctx, pushType, pushToken) - if err != nil { - ce.Reply("Failed to register pusher: %v", err) - return - } - if pushToken == "" { - ce.Reply("Pusher de-registered successfully") - } else { - ce.Reply("Pusher registered successfully") - } - }, - Name: "debug-register-push", - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Register a pusher", - Args: "<_login ID_> <_push type_> <_push token_>", - }, - RequiresAdmin: true, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.PushableNetworkAPI], -} - -var CommandSendAccountData = &FullHandler{ - Func: func(ce *Event) { - if len(ce.Args) < 2 { - ce.Reply("Usage: `$cmdprefix debug-account-data ") - return - } - var content event.Content - evtType := event.Type{Type: ce.Args[0], Class: event.AccountDataEventType} - ce.RawArgs = strings.TrimSpace(strings.Trim(ce.RawArgs, ce.Args[0])) - err := json.Unmarshal([]byte(ce.RawArgs), &content) - if err != nil { - ce.Reply("Failed to parse JSON: %v", err) - return - } - err = content.ParseRaw(evtType) - if err != nil { - ce.Reply("Failed to deserialize content: %v", err) - return - } - res := ce.Bridge.QueueMatrixEvent(ce.Ctx, &event.Event{ - Sender: ce.User.MXID, - Type: evtType, - Timestamp: time.Now().UnixMilli(), - RoomID: ce.RoomID, - Content: content, - }) - ce.Reply("Result: %+v", res) - }, - Name: "debug-account-data", - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Send a room account data event to the bridge", - Args: "<_type_> <_content_>", - }, - RequiresAdmin: true, - RequiresPortal: true, - RequiresLogin: true, -} - -var CommandResetNetwork = &FullHandler{ - Func: func(ce *Event) { - if strings.Contains(strings.ToLower(ce.RawArgs), "--reset-transport") { - nrn, ok := ce.Bridge.Network.(bridgev2.NetworkResettingNetwork) - if ok { - nrn.ResetHTTPTransport() - } else { - ce.Reply("Network connector does not support resetting HTTP transport") - } - } - ce.Bridge.ResetNetworkConnections() - ce.React("✅️") - }, - Name: "debug-reset-network", - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Reset network connections to the remote network", - Args: "[--reset-transport]", - }, - RequiresAdmin: true, -} diff --git a/mautrix-patched/bridgev2/commands/event.go b/mautrix-patched/bridgev2/commands/event.go deleted file mode 100644 index 13f8dae5..00000000 --- a/mautrix-patched/bridgev2/commands/event.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" -) - -// Event stores all data which might be used to handle commands -type Event struct { - Bot bridgev2.MatrixAPI - Bridge *bridgev2.Bridge - Portal *bridgev2.Portal - Processor *Processor - Handler MinimalCommandHandler - RoomID id.RoomID - OrigRoomID id.RoomID - EventID id.EventID - User *bridgev2.User - Command string - Args []string - RawArgs string - ReplyTo id.EventID - Ctx context.Context - Log *zerolog.Logger - Sudo bool - - MessageStatus *bridgev2.MessageStatus -} - -// Reply sends a reply to command as notice, with optional string formatting and automatic $cmdprefix replacement. -func (ce *Event) Reply(msg string, args ...any) id.EventID { - msg = strings.ReplaceAll(msg, "$cmdprefix", ce.Bridge.Config.CommandPrefix) - if len(args) > 0 { - msg = fmt.Sprintf(msg, args...) - } - return ce.ReplyAdvanced(msg, true, false) -} - -// ReplyAdvanced sends a reply to command as notice. It allows using HTML and disabling markdown, -// but doesn't have built-in string formatting. -func (ce *Event) ReplyAdvanced(msg string, allowMarkdown, allowHTML bool) id.EventID { - content := format.RenderMarkdown(msg, allowMarkdown, allowHTML) - content.MsgType = event.MsgNotice - resp, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil) - if err != nil { - ce.Log.Err(err).Msg("Failed to reply to command") - return "" - } - return resp.EventID -} - -// React sends a reaction to the command. -func (ce *Event) React(key string) id.EventID { - resp, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventReaction, &event.Content{ - Parsed: &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: ce.EventID, - Key: key, - }, - }, - }, nil) - if err != nil { - ce.Log.Err(err).Msg("Failed to react to command") - return "" - } - return resp.EventID -} - -// Redact redacts the command. -func (ce *Event) Redact(req ...mautrix.ReqRedact) { - _, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: ce.EventID, - }, - }, nil) - if err != nil { - ce.Log.Err(err).Msg("Failed to redact command") - } -} - -// MarkRead marks the command event as read. -func (ce *Event) MarkRead() { - err := ce.Bot.MarkRead(ce.Ctx, ce.RoomID, ce.EventID, time.Now()) - if err != nil { - ce.Log.Err(err).Msg("Failed to mark command as read") - } -} diff --git a/mautrix-patched/bridgev2/commands/handler.go b/mautrix-patched/bridgev2/commands/handler.go deleted file mode 100644 index 672c81dc..00000000 --- a/mautrix-patched/bridgev2/commands/handler.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/event" -) - -type MinimalCommandHandler interface { - Run(*Event) -} - -type MinimalCommandHandlerFunc func(*Event) - -func (mhf MinimalCommandHandlerFunc) Run(ce *Event) { - mhf(ce) -} - -type CommandState struct { - Next MinimalCommandHandler - Action string - Meta any - Cancel func() -} - -type CommandHandler interface { - MinimalCommandHandler - GetName() string -} - -type AliasedCommandHandler interface { - CommandHandler - GetAliases() []string -} - -func NetworkAPIImplements[T bridgev2.NetworkAPI](val bridgev2.NetworkAPI) bool { - _, ok := val.(T) - return ok -} - -func NetworkConnectorImplements[T bridgev2.NetworkConnector](val bridgev2.NetworkConnector) bool { - _, ok := val.(T) - return ok -} - -type ImplementationChecker[T any] func(val T) bool - -type FullHandler struct { - Func func(*Event) - - Name string - Aliases []string - Help HelpMeta - - RequiresAdmin bool - RequiresPortal bool - RequiresLogin bool - RequiresEventLevel event.Type - RequiresLoginPermission bool - - NetworkAPI ImplementationChecker[bridgev2.NetworkAPI] - NetworkConnector ImplementationChecker[bridgev2.NetworkConnector] -} - -func (fh *FullHandler) GetHelp() HelpMeta { - fh.Help.Command = fh.Name - return fh.Help -} - -func (fh *FullHandler) GetName() string { - return fh.Name -} - -func (fh *FullHandler) GetAliases() []string { - return fh.Aliases -} - -func (fh *FullHandler) ImplementationsFulfilled(ce *Event) bool { - // TODO add dedicated method to get an empty NetworkAPI instead of getting default login - client := ce.User.GetDefaultLogin() - return (fh.NetworkAPI == nil || client == nil || fh.NetworkAPI(client.Client)) && - (fh.NetworkConnector == nil || fh.NetworkConnector(ce.Bridge.Network)) -} - -func (fh *FullHandler) ShowInHelp(ce *Event) bool { - return fh.ImplementationsFulfilled(ce) && (!fh.RequiresAdmin || ce.User.Permissions.Admin) -} - -func (fh *FullHandler) userHasRoomPermission(ce *Event) bool { - levels, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, ce.RoomID) - if err != nil { - ce.Log.Warn().Err(err).Msg("Failed to check room power levels") - ce.Reply("Failed to get room power levels to see if you're allowed to use that command") - return false - } - return levels.GetUserLevel(ce.User.MXID) >= levels.GetEventLevel(fh.RequiresEventLevel) -} - -func (fh *FullHandler) Run(ce *Event) { - if fh.RequiresAdmin && !ce.User.Permissions.Admin { - ce.Reply("That command is limited to bridge administrators.") - } else if fh.RequiresLoginPermission && !ce.User.Permissions.Login { - ce.Reply("You do not have permissions to log into this bridge.") - } else if fh.RequiresEventLevel.Type != "" && !ce.User.Permissions.Admin && !fh.userHasRoomPermission(ce) { - ce.Reply("That command requires room admin rights.") - } else if fh.RequiresPortal && ce.Portal == nil { - ce.Reply("That command can only be ran in portal rooms.") - } else if fh.RequiresLogin && ce.User.GetDefaultLogin() == nil { - ce.Reply("That command requires you to be logged in.") - } else { - fh.Func(ce) - } -} diff --git a/mautrix-patched/bridgev2/commands/help.go b/mautrix-patched/bridgev2/commands/help.go deleted file mode 100644 index 07cb6921..00000000 --- a/mautrix-patched/bridgev2/commands/help.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "fmt" - "sort" - "strings" -) - -type HelpfulHandler interface { - CommandHandler - GetHelp() HelpMeta - ShowInHelp(*Event) bool -} - -type HelpSection struct { - Name string - Order int -} - -var ( - // Deprecated: this should be used as a placeholder that needs to be fixed - HelpSectionUnclassified = HelpSection{"Unclassified", -1} - - HelpSectionGeneral = HelpSection{"General", 0} - HelpSectionAuth = HelpSection{"Authentication", 10} - HelpSectionChats = HelpSection{"Starting and managing chats", 20} - HelpSectionMisc = HelpSection{"Miscellaneous", 30} - HelpSectionAdmin = HelpSection{"Administration", 50} -) - -type HelpMeta struct { - Command string - Section HelpSection - Description string - Args string -} - -func (hm *HelpMeta) String() string { - if len(hm.Args) == 0 { - return fmt.Sprintf("**%s** - %s", hm.Command, hm.Description) - } - return fmt.Sprintf("**%s** %s - %s", hm.Command, hm.Args, hm.Description) -} - -type helpSectionList []HelpSection - -func (h helpSectionList) Len() int { - return len(h) -} - -func (h helpSectionList) Less(i, j int) bool { - return h[i].Order < h[j].Order -} - -func (h helpSectionList) Swap(i, j int) { - h[i], h[j] = h[j], h[i] -} - -type helpMetaList []HelpMeta - -func (h helpMetaList) Len() int { - return len(h) -} - -func (h helpMetaList) Less(i, j int) bool { - return h[i].Command < h[j].Command -} - -func (h helpMetaList) Swap(i, j int) { - h[i], h[j] = h[j], h[i] -} - -var _ sort.Interface = (helpSectionList)(nil) -var _ sort.Interface = (helpMetaList)(nil) - -func FormatHelp(ce *Event) string { - sections := make(map[HelpSection]helpMetaList) - for _, handler := range ce.Processor.handlers { - helpfulHandler, ok := handler.(HelpfulHandler) - if !ok || !helpfulHandler.ShowInHelp(ce) { - continue - } - help := helpfulHandler.GetHelp() - if help.Description == "" { - continue - } - sections[help.Section] = append(sections[help.Section], help) - } - - sortedSections := make(helpSectionList, 0, len(sections)) - for section := range sections { - sortedSections = append(sortedSections, section) - } - sort.Sort(sortedSections) - - var output strings.Builder - output.Grow(10240) - - var prefixMsg string - if ce.RoomID == ce.User.ManagementRoom { - prefixMsg = "This is your management room: prefixing commands with `%s` is not required." - } else if ce.Portal != nil { - prefixMsg = "**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged." - } else { - prefixMsg = "This is not your management room: prefixing commands with `%s` is required." - } - _, _ = fmt.Fprintf(&output, prefixMsg, ce.Bridge.Config.CommandPrefix) - output.WriteByte('\n') - output.WriteString("Parameters in [square brackets] are optional, while parameters in are required.") - output.WriteByte('\n') - output.WriteByte('\n') - - for _, section := range sortedSections { - output.WriteString("#### ") - output.WriteString(section.Name) - output.WriteByte('\n') - sort.Sort(sections[section]) - for _, command := range sections[section] { - output.WriteString(command.String()) - output.WriteByte('\n') - } - output.WriteByte('\n') - } - return output.String() -} - -var CommandHelp = &FullHandler{ - Func: func(ce *Event) { - ce.Reply(FormatHelp(ce)) - }, - Name: "help", - Help: HelpMeta{ - Section: HelpSectionGeneral, - Description: "Show this help message.", - }, -} diff --git a/mautrix-patched/bridgev2/commands/imagepack.go b/mautrix-patched/bridgev2/commands/imagepack.go deleted file mode 100644 index 35dfbe5b..00000000 --- a/mautrix-patched/bridgev2/commands/imagepack.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "fmt" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/provisionutil" - "maunium.net/go/mautrix/format" -) - -var CommandImportImagePack = &FullHandler{ - Func: fnImportImagePack, - Name: "import-image-pack", - Help: HelpMeta{ - Section: HelpSectionMisc, - Description: "Import a sticker or emoji pack from the remote network", - Args: "", - }, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.StickerImportingNetworkAPI], -} - -func fnImportImagePack(ce *Event) { - login, _, args := getClientForStartingChat[bridgev2.StickerImportingNetworkAPI](ce, "importing pack") - if len(args) == 0 { - ce.Reply("Usage: `$cmdprefix import-image-pack `") - return - } - resp, err := provisionutil.ImportImagePack(ce.Ctx, login, args[0]) - if err != nil { - ce.Reply("Failed to import pack: %s", err) - return - } - var footer string - parts := len(resp.StateKeys) - if parts > 1 { - footer = fmt.Sprintf(". Note: the pack was large, so it had to be split up into %d parts", parts) - } - ce.Reply( - "Successfully bridged image pack to %s%s", - format.MarkdownLink("your personal filtering space", - resp.RoomID.URI(ce.Bridge.Matrix.ServerName()).MatrixToURL()), - footer, - ) -} diff --git a/mautrix-patched/bridgev2/commands/login.go b/mautrix-patched/bridgev2/commands/login.go deleted file mode 100644 index ebd681ff..00000000 --- a/mautrix-patched/bridgev2/commands/login.go +++ /dev/null @@ -1,667 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "encoding/json" - "fmt" - "html" - "net/url" - "regexp" - "slices" - "strings" - - "github.com/skip2/go-qrcode" - "go.mau.fi/util/curl" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var CommandLogin = &FullHandler{ - Func: fnLogin, - Name: "login", - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "Log into the bridge", - Args: "[_flow ID_]", - }, - RequiresLoginPermission: true, -} - -var CommandRelogin = &FullHandler{ - Func: fnLogin, - Name: "relogin", - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "Re-authenticate an existing login", - Args: "<_login ID_> [_flow ID_]", - }, - RequiresLoginPermission: true, -} - -func formatFlowsReply(flows []bridgev2.LoginFlow) string { - var buf strings.Builder - for _, flow := range flows { - _, _ = fmt.Fprintf(&buf, "* `%s` - %s\n", flow.ID, flow.Description) - } - return buf.String() -} - -func fnLogin(ce *Event) { - var reauth *bridgev2.UserLogin - if ce.Command == "relogin" { - if len(ce.Args) == 0 { - ce.Reply("Usage: `$cmdprefix relogin [_flow ID_]`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) - return - } - reauth = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) - if reauth == nil { - ce.Reply("Login `%s` not found", ce.Args[0]) - return - } - ce.Args = ce.Args[1:] - } - if reauth == nil && ce.User.HasTooManyLogins() { - ce.Reply( - "You have reached the maximum number of logins (%d). "+ - "Please logout from an existing login before creating a new one. "+ - "If you want to re-authenticate an existing login, use the `$cmdprefix relogin` command.", - ce.User.Permissions.MaxLogins, - ) - return - } - if existingState := LoadCommandState(ce.User); existingState != nil { - ce.Reply("You already have an ongoing %s. You can use `$cmdprefix cancel` to cancel it.", strings.ToLower(existingState.Action)) - return - } - flows := ce.Bridge.Network.GetLoginFlows() - var chosenFlowID string - if len(ce.Args) > 0 { - inputFlowID := strings.ToLower(ce.Args[0]) - ce.Args = ce.Args[1:] - for _, flow := range flows { - if flow.ID == inputFlowID { - chosenFlowID = flow.ID - break - } - } - if chosenFlowID == "" { - ce.Reply("Invalid login flow `%s`. Available options:\n\n%s", inputFlowID, formatFlowsReply(flows)) - return - } - } else if len(flows) == 1 { - chosenFlowID = flows[0].ID - } else { - if reauth != nil { - ce.Reply("Please specify a login flow, e.g. `relogin %s %s`.\n\n%s", reauth.ID, flows[0].ID, formatFlowsReply(flows)) - } else { - ce.Reply("Please specify a login flow, e.g. `login %s`.\n\n%s", flows[0].ID, formatFlowsReply(flows)) - } - return - } - if !ce.Sudo && ce.RoomID != ce.User.ManagementRoom { - ce.Reply("\u26a0\ufe0f This is not your management room. Entering login info must be prefixed with `$cmdprefix` like other commands.") - } - - login, err := ce.Bridge.Network.CreateLogin(ce.Ctx, ce.User, chosenFlowID) - if err != nil { - ce.Reply("Failed to prepare login process: %v", err) - return - } - overridable, ok := login.(bridgev2.LoginProcessWithOverride) - var nextStep *bridgev2.LoginStep - if ok && reauth != nil { - nextStep, err = overridable.StartWithOverride(ce.Ctx, reauth) - } else { - nextStep, err = login.Start(ce.Ctx) - } - if err != nil { - ce.Reply("Failed to start login: %v", err) - return - } - ce.Log.Debug().Any("first_step", nextStep).Msg("Created login process") - - nextStep = checkLoginCommandDirectParams(ce, login, nextStep) - if nextStep != nil { - doLoginStep(ce, login, nextStep, reauth) - } -} - -func checkLoginCommandDirectParams(ce *Event, login bridgev2.LoginProcess, nextStep *bridgev2.LoginStep) *bridgev2.LoginStep { - if len(ce.Args) == 0 { - return nextStep - } - var ok bool - defer func() { - if !ok { - login.Cancel() - } - }() - var err error - switch nextStep.Type { - case bridgev2.LoginStepTypeDisplayAndWait: - ce.Reply("Invalid extra parameters for display and wait login step") - return nil - case bridgev2.LoginStepTypeWebAuthn: - ce.Reply("Invalid extra parameters for webauthn login step") - return nil - case bridgev2.LoginStepTypeUserInput: - if len(ce.Args) != len(nextStep.UserInputParams.Fields) { - ce.Reply("Invalid number of extra parameters (expected 0 or %d, got %d)", len(nextStep.UserInputParams.Fields), len(ce.Args)) - return nil - } - input := make(map[string]string) - var shouldRedact bool - for i, param := range nextStep.UserInputParams.Fields { - param.FillDefaultValidate() - input[param.ID], err = param.Validate(ce.Args[i]) - if err != nil { - ce.Reply("Invalid value for %s: %v", param.Name, err) - return nil - } - if param.Type == bridgev2.LoginInputFieldTypePassword || param.Type == bridgev2.LoginInputFieldTypeToken { - shouldRedact = true - } - } - if shouldRedact { - ce.Redact() - } - nextStep, err = login.(bridgev2.LoginProcessUserInput).SubmitUserInput(ce.Ctx, input) - case bridgev2.LoginStepTypeCookies: - if len(ce.Args) != len(nextStep.CookiesParams.Fields) { - ce.Reply("Invalid number of extra parameters (expected 0 or %d, got %d)", len(nextStep.CookiesParams.Fields), len(ce.Args)) - return nil - } - input := make(map[string]string) - for i, param := range nextStep.CookiesParams.Fields { - val := maybeURLDecodeCookie(ce.Args[i], ¶m) - if match, _ := regexp.MatchString(param.Pattern, val); !match { - ce.Reply("Invalid value for %s: `%s` doesn't match regex `%s`", param.ID, val, param.Pattern) - return nil - } - input[param.ID] = val - } - ce.Redact() - nextStep, err = login.(bridgev2.LoginProcessCookies).SubmitCookies(ce.Ctx, input) - } - if err != nil { - ce.Reply("Failed to submit input: %v", err) - return nil - } - ok = true - return nextStep -} - -type userInputLoginCommandState struct { - Login bridgev2.LoginProcessUserInput - Data map[string]string - RemainingFields []bridgev2.LoginInputDataField - Override *bridgev2.UserLogin -} - -func (uilcs *userInputLoginCommandState) promptNext(ce *Event) { - field := uilcs.RemainingFields[0] - parts := []string{fmt.Sprintf("Please enter your %s", field.Name)} - if field.Description != "" { - parts = append(parts, field.Description) - } - if len(field.Options) > 0 { - parts = append(parts, fmt.Sprintf("Options: `%s`", strings.Join(field.Options, "`, `"))) - } - ce.Reply(strings.Join(parts, "\n")) - StoreCommandState(ce.User, &CommandState{ - Next: MinimalCommandHandlerFunc(uilcs.submitNext), - Action: "Login", - Meta: uilcs, - Cancel: uilcs.Login.Cancel, - }) -} - -func (uilcs *userInputLoginCommandState) submitNext(ce *Event) { - field := uilcs.RemainingFields[0] - field.FillDefaultValidate() - if field.Type == bridgev2.LoginInputFieldTypePassword || field.Type == bridgev2.LoginInputFieldTypeToken { - ce.Redact() - } - var err error - uilcs.Data[field.ID], err = field.Validate(ce.RawArgs) - if err != nil { - ce.Reply("Invalid value: %v", err) - return - } else if len(uilcs.RemainingFields) > 1 { - uilcs.RemainingFields = uilcs.RemainingFields[1:] - uilcs.promptNext(ce) - return - } - StoreCommandState(ce.User, nil) - if nextStep, err := uilcs.Login.SubmitUserInput(ce.Ctx, uilcs.Data); err != nil { - ce.Reply("Failed to submit input: %v", err) - } else { - doLoginStep(ce, uilcs.Login, nextStep, uilcs.Override) - } -} - -const qrSizePx = 512 - -func sendQR(ce *Event, qr string, prevEventID *id.EventID) error { - qrData, err := qrcode.Encode(qr, qrcode.Low, qrSizePx) - if err != nil { - return fmt.Errorf("failed to encode QR code: %w", err) - } - qrMXC, qrFile, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, qrData, "qr.png", "image/png") - if err != nil { - return fmt.Errorf("failed to upload image: %w", err) - } - content := &event.MessageEventContent{ - MsgType: event.MsgImage, - FileName: "qr.png", - URL: qrMXC, - File: qrFile, - Body: qr, - Format: event.FormatHTML, - FormattedBody: fmt.Sprintf("
      %s
      ", html.EscapeString(qr)), - Info: &event.FileInfo{ - MimeType: "image/png", - Width: qrSizePx, - Height: qrSizePx, - Size: len(qrData), - }, - } - if *prevEventID != "" { - content.SetEdit(*prevEventID) - } - newEventID, err := ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) - if err != nil { - return err - } - if *prevEventID == "" { - *prevEventID = newEventID.EventID - } - return nil -} - -func sendUserInputAttachments(ce *Event, atts []*bridgev2.LoginUserInputAttachment) error { - for _, att := range atts { - if att.FileName == "" { - return fmt.Errorf("missing attachment filename") - } - mxc, file, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, att.Content, att.FileName, att.Info.MimeType) - if err != nil { - return fmt.Errorf("failed to upload attachment %q: %w", att.FileName, err) - } - content := &event.MessageEventContent{ - MsgType: att.Type, - FileName: att.FileName, - URL: mxc, - File: file, - Info: &event.FileInfo{ - MimeType: att.Info.MimeType, - Width: att.Info.Width, - Height: att.Info.Height, - Size: att.Info.Size, - }, - Body: att.FileName, - } - _, err = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) - if err != nil { - return nil - } - } - return nil -} - -type contextKey int - -const ( - contextKeyPrevEventID contextKey = iota -) - -func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait, step *bridgev2.LoginStep, override *bridgev2.UserLogin) { - prevEvent, ok := ce.Ctx.Value(contextKeyPrevEventID).(*id.EventID) - if !ok { - prevEvent = new(id.EventID) - ce.Ctx = context.WithValue(ce.Ctx, contextKeyPrevEventID, prevEvent) - } - cancelCtx, cancelFunc := context.WithCancel(ce.Ctx) - defer cancelFunc() - StoreCommandState(ce.User, &CommandState{ - Action: "Login", - Cancel: cancelFunc, - }) - switch step.DisplayAndWaitParams.Type { - case bridgev2.LoginDisplayTypeQR: - err := sendQR(ce, step.DisplayAndWaitParams.Data, prevEvent) - if err != nil { - ce.Reply("Failed to send QR code: %v", err) - login.Cancel() - StoreCommandState(ce.User, nil) - return - } - case bridgev2.LoginDisplayTypeEmoji: - ce.ReplyAdvanced(step.DisplayAndWaitParams.Data, false, false) - case bridgev2.LoginDisplayTypeCode: - ce.ReplyAdvanced(fmt.Sprintf("%s", html.EscapeString(step.DisplayAndWaitParams.Data)), false, true) - case bridgev2.LoginDisplayTypeNothing: - // Do nothing - default: - ce.Reply("Unsupported display type %q", step.DisplayAndWaitParams.Type) - login.Cancel() - StoreCommandState(ce.User, nil) - return - } - nextStep, err := login.Wait(cancelCtx) - // Redact the QR code, unless the next step is refreshing the code (in which case the event is just edited) - if *prevEvent != "" && (nextStep == nil || nextStep.StepID != step.StepID) { - _, _ = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: *prevEvent, - }, - }, nil) - *prevEvent = "" - } - if err != nil { - ce.Reply("Login failed: %v", err) - StoreCommandState(ce.User, nil) - return - } - StoreCommandState(ce.User, nil) - doLoginStep(ce, login, nextStep, override) -} - -type cookieLoginCommandState struct { - Login bridgev2.LoginProcessCookies - Data *bridgev2.LoginCookiesParams - Override *bridgev2.UserLogin -} - -func (clcs *cookieLoginCommandState) prompt(ce *Event) { - ce.Reply("Login URL: <%s>", clcs.Data.URL) - StoreCommandState(ce.User, &CommandState{ - Next: MinimalCommandHandlerFunc(clcs.submit), - Action: "Login", - Meta: clcs, - Cancel: clcs.Login.Cancel, - }) -} - -func (clcs *cookieLoginCommandState) submit(ce *Event) { - ce.Redact() - - cookiesInput := make(map[string]string) - if strings.HasPrefix(strings.TrimSpace(ce.RawArgs), "curl") { - parsed, err := curl.Parse(ce.RawArgs) - if err != nil { - ce.Reply("Failed to parse curl: %v", err) - return - } - reqCookies := make(map[string]string) - for _, cookie := range parsed.Cookies() { - reqCookies[cookie.Name], err = url.PathUnescape(cookie.Value) - if err != nil { - ce.Reply("Failed to parse cookie %s: %v", cookie.Name, err) - return - } - } - var missingKeys, unsupportedKeys []string - for _, field := range clcs.Data.Fields { - var value string - var supported bool - for _, src := range field.Sources { - switch src.Type { - case bridgev2.LoginCookieTypeCookie: - supported = true - value = reqCookies[src.Name] - case bridgev2.LoginCookieTypeRequestHeader: - supported = true - value = parsed.Header.Get(src.Name) - case bridgev2.LoginCookieTypeRequestBody: - supported = true - switch { - case parsed.MultipartForm != nil: - values, ok := parsed.MultipartForm.Value[src.Name] - if ok && len(values) > 0 { - value = values[0] - } - case parsed.ParsedJSON != nil: - untypedValue, ok := parsed.ParsedJSON[src.Name] - if ok { - value = fmt.Sprintf("%v", untypedValue) - } - } - } - if value != "" { - cookiesInput[field.ID] = value - break - } - } - if value == "" && field.Required { - if supported { - missingKeys = append(missingKeys, field.ID) - } else { - unsupportedKeys = append(unsupportedKeys, field.ID) - } - } - } - if len(unsupportedKeys) > 0 { - ce.Reply("Some keys can't be extracted from a cURL request: %+v\n\nPlease provide a JSON object instead.", unsupportedKeys) - return - } else if len(missingKeys) > 0 { - ce.Reply("Missing some keys: %+v", missingKeys) - return - } - } else { - err := json.Unmarshal([]byte(ce.RawArgs), &cookiesInput) - if err != nil { - ce.Reply("Failed to parse input as JSON: %v", err) - return - } - for _, field := range clcs.Data.Fields { - val, ok := cookiesInput[field.ID] - if ok { - cookiesInput[field.ID] = maybeURLDecodeCookie(val, &field) - } - } - } - var missingKeys []string - for _, field := range clcs.Data.Fields { - val, ok := cookiesInput[field.ID] - if !ok && field.Required { - missingKeys = append(missingKeys, field.ID) - } - if match, _ := regexp.MatchString(field.Pattern, val); !match { - ce.Reply("Invalid value for %s: `%s` doesn't match regex `%s`", field.ID, val, field.Pattern) - return - } - } - if len(missingKeys) > 0 { - ce.Reply("Missing some keys: %+v", missingKeys) - return - } - StoreCommandState(ce.User, nil) - nextStep, err := clcs.Login.SubmitCookies(ce.Ctx, cookiesInput) - if err != nil { - ce.Reply("Login failed: %v", err) - return - } - doLoginStep(ce, clcs.Login, nextStep, clcs.Override) -} - -func maybeURLDecodeCookie(val string, field *bridgev2.LoginCookieField) string { - if val == "" { - return val - } - isCookie := slices.ContainsFunc(field.Sources, func(src bridgev2.LoginCookieFieldSource) bool { - return src.Type == bridgev2.LoginCookieTypeCookie - }) - if !isCookie { - return val - } - decoded, err := url.PathUnescape(val) - if err != nil { - return val - } - return decoded -} - -type webauthnLoginCommandState struct { - Login bridgev2.LoginProcessWebAuthn - Override *bridgev2.UserLogin -} - -const webauthnSnippet = "Run the following JS on <%s>:\n\n```js\nconsole.log((await navigator.credentials.get({\n publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(%s)\n})).toJSON())\n```\n\nThen paste the resulting JSON object here." - -func (wlcs *webauthnLoginCommandState) prompt(ce *Event, params *bridgev2.LoginWebAuthnParams) { - marshaledPubKey, _ := json.MarshalIndent(params.PublicKey, "", " ") - ce.Reply(webauthnSnippet, params.URL, marshaledPubKey) - StoreCommandState(ce.User, &CommandState{ - Next: MinimalCommandHandlerFunc(wlcs.submit), - Action: "Login", - Meta: wlcs, - Cancel: wlcs.Login.Cancel, - }) -} - -func (wlcs *webauthnLoginCommandState) submit(ce *Event) { - rawArgBytes := []byte(strings.TrimSpace(ce.RawArgs)) - if !json.Valid(rawArgBytes) { - ce.Reply("Input is not valid JSON") - return - } - StoreCommandState(ce.User, nil) - nextStep, err := wlcs.Login.SubmitWebAuthnResponse(ce.Ctx, rawArgBytes) - if err != nil { - ce.Reply("Login failed: %v", err) - return - } - doLoginStep(ce, wlcs.Login, nextStep, wlcs.Override) -} - -func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginStep, override *bridgev2.UserLogin) { - ce.Log.Debug().Any("next_step", step).Msg("Got next login step") - if step.Instructions != "" { - ce.Reply(step.Instructions) - } - - switch step.Type { - case bridgev2.LoginStepTypeDisplayAndWait: - doLoginDisplayAndWait(ce, login.(bridgev2.LoginProcessDisplayAndWait), step, override) - case bridgev2.LoginStepTypeCookies: - (&cookieLoginCommandState{ - Login: login.(bridgev2.LoginProcessCookies), - Data: step.CookiesParams, - Override: override, - }).prompt(ce) - case bridgev2.LoginStepTypeUserInput: - err := sendUserInputAttachments(ce, step.UserInputParams.Attachments) - if err != nil { - ce.Reply("Failed to send attachments: %v", err) - } - (&userInputLoginCommandState{ - Login: login.(bridgev2.LoginProcessUserInput), - RemainingFields: step.UserInputParams.Fields, - Data: make(map[string]string), - Override: override, - }).promptNext(ce) - case bridgev2.LoginStepTypeWebAuthn: - (&webauthnLoginCommandState{ - Login: login.(bridgev2.LoginProcessWebAuthn), - Override: override, - }).prompt(ce, step.WebAuthnParams) - case bridgev2.LoginStepTypeComplete: - if override != nil && override.ID != step.CompleteParams.UserLoginID { - ce.Log.Info(). - Str("old_login_id", string(override.ID)). - Str("new_login_id", string(step.CompleteParams.UserLoginID)). - Msg("Login resulted in different remote ID than what was being overridden. Deleting previous login") - override.Delete(ce.Ctx, status.BridgeState{ - StateEvent: status.StateLoggedOut, - Reason: "LOGIN_OVERRIDDEN", - }, bridgev2.DeleteOpts{LogoutRemote: true}) - } - default: - panic(fmt.Errorf("unknown login step type %q", step.Type)) - } -} - -var CommandListLogins = &FullHandler{ - Func: fnListLogins, - Name: "list-logins", - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "List your logins", - }, - RequiresLoginPermission: true, -} - -func fnListLogins(ce *Event) { - logins := ce.User.GetFormattedUserLogins() - if len(logins) == 0 { - ce.Reply("You're not logged in") - } else { - ce.Reply("%s", logins) - } -} - -var CommandLogout = &FullHandler{ - Func: fnLogout, - Name: "logout", - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "Log out of the bridge", - Args: "<_login ID_>", - }, -} - -func fnLogout(ce *Event) { - if len(ce.Args) == 0 { - ce.Reply("Usage: `$cmdprefix logout `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) - return - } - login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) - if login == nil || login.UserMXID != ce.User.MXID { - ce.Reply("Login `%s` not found", ce.Args[0]) - return - } - login.Logout(ce.Ctx) - ce.Reply("Logged out") -} - -var CommandSetPreferredLogin = &FullHandler{ - Func: fnSetPreferredLogin, - Name: "set-preferred-login", - Aliases: []string{"prefer"}, - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "Set the preferred login ID for sending messages to this portal (only relevant when logged into multiple accounts via the bridge)", - Args: "<_login ID_>", - }, - RequiresPortal: true, - RequiresLoginPermission: true, -} - -func fnSetPreferredLogin(ce *Event) { - if len(ce.Args) == 0 { - ce.Reply("Usage: `$cmdprefix set-preferred-login `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) - return - } - login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) - if login == nil || login.UserMXID != ce.User.MXID { - ce.Reply("Login `%s` not found", ce.Args[0]) - return - } - err := login.MarkAsPreferredIn(ce.Ctx, ce.Portal) - if err != nil { - ce.Reply("Failed to set preferred login: %v", err) - } else { - ce.Reply("Preferred login set") - } -} diff --git a/mautrix-patched/bridgev2/commands/managechat.go b/mautrix-patched/bridgev2/commands/managechat.go deleted file mode 100644 index 0f1c7e0a..00000000 --- a/mautrix-patched/bridgev2/commands/managechat.go +++ /dev/null @@ -1,358 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "fmt" - "slices" - "strings" - "time" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" -) - -var CommandID = &FullHandler{ - Func: func(ce *Event) { - var receiver string - if ce.Portal.Receiver != "" { - receiver = fmt.Sprintf(" (receiver: %s)", format.SafeMarkdownCode(ce.Portal.Receiver)) - } - ce.Reply("This room is bridged to %s%s on %s", format.SafeMarkdownCode(ce.Portal.ID), receiver, ce.Bridge.Network.GetName().DisplayName) - }, - Name: "id", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "View the internal network ID of the current portal room", - }, - RequiresPortal: true, -} - -var CommandBridge = &FullHandler{ - Func: fnBridge, - Name: "bridge", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Bridge an existing chat on the remote network to this Matrix room", - Args: "[login ID] ", - }, - RequiresEventLevel: event.StateBridge, -} - -func alreadyBridged(ce *Event) bool { - if ce.Portal != nil { - var receiver string - if ce.Portal.Receiver != "" { - receiver = fmt.Sprintf(" (receiver: %s)", format.SafeMarkdownCode(ce.Portal.Receiver)) - } - ce.Reply("This room is already bridged to %s%s on %s", format.SafeMarkdownCode(ce.Portal.ID), receiver, ce.Bridge.Network.GetName().DisplayName) - return true - } - return false -} - -func fnBridge(ce *Event) { - if !canPlumb(ce) { - ce.Reply("You don't have permission to bridge this room") - return - } else if alreadyBridged(ce) { - return - } - var allowOverwrite, ignorePermissions bool - ce.Args = slices.DeleteFunc(ce.Args, func(s string) bool { - switch strings.ToLower(s) { - case "--overwrite": - allowOverwrite = true - return true - case "--ignore-permissions": - ignorePermissions = true - return true - default: - return false - } - }) - if len(ce.Args) == 0 || len(ce.Args) > 2 { - ce.Reply("Usage: `$cmdprefix bridge [login ID] `") - return - } - if !ignorePermissions { - pls, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, ce.RoomID) - if err != nil { - ce.Log.Err(err).Msg("Failed to get power levels for plumbing") - ce.Reply("Failed to check power levels in room: %v", err) - return - } else if pls.GetUserLevel(ce.Bot.GetMXID()) < pls.GetEventLevel(event.StateBridge) { - ce.Reply("I don't have sufficient permissions for `m.bridge` events. Adjust power levels or use the `--ignore-permissions` flag to ignore.") - return - } - } - portal, login, ok := getCreatePortalInput( - ce, - ce.Bridge.Config.Relay.AllowBridge, - ce.Bridge.Config.Relay.PreferDefault && len(ce.Bridge.Config.Relay.DefaultRelays) > 0, - ) - if !ok { - return - } else if portal.MXID != "" { - // TODO check overwrite permissions - canOverwrite := ce.User.Permissions.Admin || hasRoomPermissions(ce, portal.MXID, fakeEvtPlumb) - if !canOverwrite { - ce.Reply("That chat is already bridged to another room, and you don't have the permission to delete it.") - return - } else if !allowOverwrite { - ce.Reply("That chat is already bridged to [%s](%s). Use `--overwrite` to delete the existing room.", portal.Name, portal.MXID.URI().MatrixToURL()) - return - } - } - if info, err := login.Client.GetChatInfo(ce.Ctx, portal); err != nil { - ce.Log.Err(err).Msg("Failed to get chat info for plumbing") - ce.Reply("Failed to get chat info: %v", err) - } else if info == nil { - ce.Reply("Chat info not found") - } else if err = portal.UpdateMatrixRoomID(ce.Ctx, ce.RoomID, bridgev2.UpdateMatrixRoomIDParams{ - FailIfMXIDSet: !allowOverwrite, - TombstoneOldRoom: true, - DeleteOldRoom: true, - ChatInfo: info, - ChatInfoSource: login, - }); err != nil { - ce.Log.Err(err).Msg("Failed to plumb room") - ce.Reply("Failed to plumb room: %v", err) - } else { - var relaySuffix string - if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, login.ID) { - err = portal.SetRelay(ce.Ctx, login) - if err != nil { - ce.Log.Err(err).Msg("Failed to set relay for portal after plumbing") - relaySuffix = fmt.Sprintf(", but failed to set %s as relay", format.SafeMarkdownCode(login.ID)) - } else { - relaySuffix = fmt.Sprintf(" and set %s as relay", format.SafeMarkdownCode(login.ID)) - } - } - ce.Reply( - "Successfully plumbed this room to %s on %s%s", - format.SafeMarkdownCode(portal.ID), - ce.Bridge.Network.GetName().DisplayName, - relaySuffix, - ) - } -} - -var CommandUnbridge = &FullHandler{ - Func: func(ce *Event) { - if !canPlumb(ce) { - ce.Reply("You don't have permission to unbridge this portal") - return - } - mxid := ce.Portal.MXID - err := ce.Portal.RemoveMXID(ce.Ctx) - if err != nil { - ce.Reply("Failed to remove portal mxid: %v", err) - return - } - err = ce.Bot.DeleteRoom(ce.Ctx, mxid, true) - if err != nil { - ce.Reply("Failed to clean up room: %v", err) - } - ce.MessageStatus.DisableMSS = true - }, - Name: "unbridge", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Unbridge the current portal room", - }, - RequiresPortal: true, -} - -var CommandSyncChat = &FullHandler{ - Func: fnSyncChat, - Name: "sync-portal", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Sync the current portal room", - }, - RequiresPortal: true, -} - -func fnSyncChat(ce *Event) { - login, _, err := ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, ce.Bridge.Config.Relay.AllowBridge) - if err != nil { - ce.Log.Err(err).Msg("Failed to find login for sync") - ce.Reply("Failed to find login: %v", err) - return - } else if login == nil { - if ce.Portal.Relay == nil { - ce.Reply("No login found for sync") - return - } else if !canManageRelay(ce) { - ce.Reply("Only users with relay management permissions can use sync-portal through the relay") - return - } - login = ce.Portal.Relay - } - info, err := login.Client.GetChatInfo(ce.Ctx, ce.Portal) - if err != nil { - ce.Log.Err(err).Msg("Failed to get chat info for sync") - ce.Reply("Failed to get chat info: %v", err) - return - } - ce.Portal.UpdateInfo(ce.Ctx, info, login, nil, time.Time{}) - ce.React("✅️") -} - -var CommandMute = &FullHandler{ - Func: fnMute, - Name: "mute", - Aliases: []string{"unmute"}, - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Mute or unmute a chat on the remote network", - Args: "[duration]", - }, - RequiresPortal: true, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.MuteHandlingNetworkAPI], -} - -func fnMute(ce *Event) { - _, api, _ := getClientForStartingChat[bridgev2.MuteHandlingNetworkAPI](ce, "muting chats") - var mutedUntil int64 - if ce.Command == "mute" { - mutedUntil = -1 - if len(ce.Args) > 0 { - duration, err := time.ParseDuration(ce.Args[0]) - if err != nil { - ce.Reply("Invalid duration: %v", err) - return - } - mutedUntil = time.Now().Add(duration).UnixMilli() - } - } - err := api.HandleMute(ce.Ctx, &bridgev2.MatrixMute{ - MatrixEventBase: bridgev2.MatrixEventBase[*event.BeeperMuteEventContent]{ - Content: &event.BeeperMuteEventContent{MutedUntil: mutedUntil}, - Portal: ce.Portal, - }, - }) - if err != nil { - ce.Reply("Failed to %s chat: %v", ce.Command, err) - } else { - ce.React("✅️") - } -} - -var CommandDeleteChat = &FullHandler{ - Func: fnDeleteChat, - Name: "delete-chat", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Delete the current chat on the remote network", - Args: "[--for-everyone]", - }, - RequiresPortal: true, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.DeleteChatHandlingNetworkAPI], -} - -func fnDeleteChat(ce *Event) { - _, api, _ := getClientForStartingChat[bridgev2.DeleteChatHandlingNetworkAPI](ce, "deleting chats") - err := api.HandleMatrixDeleteChat(ce.Ctx, &bridgev2.MatrixDeleteChat{ - Event: nil, - Content: &event.BeeperChatDeleteEventContent{ - DeleteForEveryone: slices.Contains(ce.Args, "--for-everyone"), - FromMessageRequest: ce.Portal.MessageRequest, - }, - Portal: ce.Portal, - }) - if err != nil { - ce.Reply("Failed to delete chat: %v", err) - } else { - ce.React("✅️") - } -} - -var CommandFilter = &FullHandler{ - Func: fnFilter, - Name: "filter", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Manage the room creation filter. Changes are currently in-memory only", - Args: " [receiver]", - }, - RequiresAdmin: true, -} - -func markdownPCFI(pcfi *bridgeconfig.PortalCreateFilterItem) string { - if pcfi == nil { - return "" - } else if pcfi.Receiver == nil { - return format.SafeMarkdownCode(pcfi.ID) - } - return fmt.Sprintf("%s (receiver: %s)", format.SafeMarkdownCode(pcfi.ID), format.SafeMarkdownCode(*pcfi.Receiver)) -} - -func fnFilter(ce *Event) { - if len(ce.Args) < 2 || len(ce.Args) > 3 { - ce.Reply("Usage: %s [receiver]", ce.Command) - return - } - target := &bridgeconfig.PortalCreateFilterItem{ - ID: networkid.PortalID(ce.Args[1]), - } - if len(ce.Args) == 3 { - target.Receiver = (*networkid.UserLoginID)(&ce.Args[2]) - } - pcf := &ce.Bridge.Config.PortalCreateFilter - found := slices.ContainsFunc(pcf.List, func(item *bridgeconfig.PortalCreateFilterItem) bool { - return item.Equals(target) - }) - switch strings.ToLower(ce.Args[0]) { - case "allow": - switch pcf.Mode { - case bridgeconfig.PortalCreateFilterModeAllow: - if found { - ce.Reply("%s is already on the allow list", markdownPCFI(target)) - } else { - pcf.List = append(pcf.List, target) - ce.Reply("Added %s to allow list", markdownPCFI(target)) - } - case bridgeconfig.PortalCreateFilterModeDeny: - if !found { - ce.Reply("%s is not on the deny list", markdownPCFI(target)) - } else { - pcf.List = slices.DeleteFunc(pcf.List, func(item *bridgeconfig.PortalCreateFilterItem) bool { - return item.Equals(target) - }) - ce.Reply("Removed %s from deny list", markdownPCFI(target)) - } - } - case "disallow", "block", "deny": - switch pcf.Mode { - case bridgeconfig.PortalCreateFilterModeAllow: - if !found { - ce.Reply("%s is not on the allow list", markdownPCFI(target)) - } else { - pcf.List = slices.DeleteFunc(pcf.List, func(item *bridgeconfig.PortalCreateFilterItem) bool { - return item.Equals(target) - }) - ce.Reply("Removed %s from allow list", markdownPCFI(target)) - } - case bridgeconfig.PortalCreateFilterModeDeny: - if found { - ce.Reply("%s is already on the deny list", markdownPCFI(target)) - } else { - pcf.List = append(pcf.List, target) - ce.Reply("Added %s to deny list", markdownPCFI(target)) - } - } - default: - ce.Reply("Usage: %s [receiver]", ce.Command) - } -} diff --git a/mautrix-patched/bridgev2/commands/processor.go b/mautrix-patched/bridgev2/commands/processor.go deleted file mode 100644 index 5b63bbae..00000000 --- a/mautrix-patched/bridgev2/commands/processor.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "fmt" - "runtime/debug" - "strings" - "sync/atomic" - "unsafe" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type Processor struct { - bridge *bridgev2.Bridge - log *zerolog.Logger - - handlers map[string]CommandHandler - aliases map[string]string -} - -// NewProcessor creates a Processor -func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { - proc := &Processor{ - bridge: bridge, - log: &bridge.Log, - - handlers: make(map[string]CommandHandler), - aliases: make(map[string]string), - } - proc.AddHandlers( - CommandHelp, CommandCancel, - CommandRegisterPush, CommandSendAccountData, CommandResetNetwork, - CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom, - CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, - CommandSetRelay, CommandUnsetRelay, - CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandCreatePortal, - CommandID, CommandUnbridge, CommandBridge, CommandSyncChat, CommandMute, CommandDeleteChat, CommandFilter, - CommandSudo, CommandDoIn, - CommandImportImagePack, - ) - return proc -} - -func (proc *Processor) AddHandlers(handlers ...CommandHandler) { - for _, handler := range handlers { - proc.AddHandler(handler) - } -} - -func (proc *Processor) AddHandler(handler CommandHandler) { - proc.handlers[handler.GetName()] = handler - aliased, ok := handler.(AliasedCommandHandler) - if ok { - for _, alias := range aliased.GetAliases() { - proc.aliases[alias] = handler.GetName() - } - } -} - -// Handle handles messages to the bridge -func (proc *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) { - ms := &bridgev2.MessageStatus{ - Step: status.MsgStepCommand, - Status: event.MessageStatusSuccess, - } - logCopy := zerolog.Ctx(ctx).With().Logger() - log := &logCopy - defer func() { - statusInfo := &bridgev2.MessageStatusEventInfo{ - RoomID: roomID, - SourceEventID: eventID, - EventType: event.EventMessage, - Sender: user.MXID, - } - err := recover() - if err != nil { - logEvt := log.Error(). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()) - if realErr, ok := err.(error); ok { - logEvt = logEvt.Err(realErr) - } else { - logEvt = logEvt.Any(zerolog.ErrorFieldName, err) - } - logEvt.Msg("Panic in Matrix command handler") - ms.Status = event.MessageStatusFail - ms.IsCertain = true - if realErr, ok := err.(error); ok { - ms.InternalError = realErr - } else { - ms.InternalError = fmt.Errorf("%v", err) - } - ms.ErrorAsMessage = true - } - proc.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo) - }() - args := strings.Fields(message) - if len(args) == 0 { - args = []string{"unknown-command"} - } - command := strings.ToLower(args[0]) - rawArgs := strings.TrimLeft(strings.TrimPrefix(message, command), " ") - portal, err := proc.bridge.GetPortalByMXID(ctx, roomID) - if err != nil { - log.Err(err).Msg("Failed to get portal") - // :( - } - ce := &Event{ - Bot: proc.bridge.Bot, - Bridge: proc.bridge, - Portal: portal, - Processor: proc, - RoomID: roomID, - OrigRoomID: roomID, - EventID: eventID, - User: user, - Command: command, - Args: args[1:], - RawArgs: rawArgs, - ReplyTo: replyTo, - Ctx: ctx, - Log: log, - - MessageStatus: ms, - } - proc.handleCommand(ctx, ce, message, args) -} - -func (proc *Processor) handleCommand(ctx context.Context, ce *Event, origMessage string, origArgs []string) { - realCommand, ok := proc.aliases[ce.Command] - if !ok { - realCommand = ce.Command - } - log := zerolog.Ctx(ctx) - - var handler MinimalCommandHandler - handler, ok = proc.handlers[realCommand] - if !ok { - state := LoadCommandState(ce.User) - if state != nil && state.Next != nil { - ce.Command = "" - ce.RawArgs = origMessage - ce.Args = origArgs - ce.Handler = state.Next - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("action", state.Action) - }) - log.Debug().Msg("Received reply to command state") - state.Next.Run(ce) - } else { - zerolog.Ctx(ctx).Debug().Str("mx_command", ce.Command).Msg("Received unknown command") - ce.Reply("Unknown command, use the `help` command for help.") - } - } else { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("mx_command", ce.Command) - }) - log.Debug().Msg("Received command") - ce.Handler = handler - handler.Run(ce) - } -} - -func LoadCommandState(user *bridgev2.User) *CommandState { - return (*CommandState)(atomic.LoadPointer(&user.CommandState)) -} - -func StoreCommandState(user *bridgev2.User, cs *CommandState) { - atomic.StorePointer(&user.CommandState, unsafe.Pointer(cs)) -} - -func SwapCommandState(user *bridgev2.User, cs *CommandState) *CommandState { - return (*CommandState)(atomic.SwapPointer(&user.CommandState, unsafe.Pointer(cs))) -} - -var CommandCancel = &FullHandler{ - Func: func(ce *Event) { - state := SwapCommandState(ce.User, nil) - if state != nil { - action := state.Action - if action == "" { - action = "Unknown action" - } - if state.Cancel != nil { - state.Cancel() - } - ce.Reply("%s cancelled.", action) - } else { - ce.Reply("No ongoing command.") - } - }, - Name: "cancel", - Help: HelpMeta{ - Section: HelpSectionGeneral, - Description: "Cancel an ongoing action.", - }, -} diff --git a/mautrix-patched/bridgev2/commands/relay.go b/mautrix-patched/bridgev2/commands/relay.go deleted file mode 100644 index 3d86b375..00000000 --- a/mautrix-patched/bridgev2/commands/relay.go +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ( - fakeEvtSetRelay = event.Type{Type: "fi.mau.bridge.set_relay", Class: event.StateEventType} - fakeEvtPlumb = event.Type{Type: "fi.mau.bridge.plumb", Class: event.StateEventType} -) - -var CommandSetRelay = &FullHandler{ - Func: fnSetRelay, - Name: "set-relay", - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "Use your account to relay messages sent by users who haven't logged in", - Args: "[_login ID_]", - }, - RequiresPortal: true, -} - -func fnSetRelay(ce *Event) { - if !ce.Bridge.Config.Relay.Enabled { - ce.Reply("This bridge does not allow relay mode") - return - } else if !canManageRelay(ce) { - ce.Reply("You don't have permission to manage the relay in this room") - return - } - onlySetDefaultRelays := !ce.User.Permissions.Admin && ce.Bridge.Config.Relay.AdminOnly - preferDefaultRelay := ce.Bridge.Config.Relay.PreferDefault && len(ce.Bridge.Config.Relay.DefaultRelays) > 0 - var relay *bridgev2.UserLogin - if len(ce.Args) == 0 && ce.Portal.Receiver == "" { - relay = ce.User.GetDefaultLogin() - isLoggedIn := relay != nil - if onlySetDefaultRelays || preferDefaultRelay { - relay = nil - } - if relay == nil { - if len(ce.Bridge.Config.Relay.DefaultRelays) == 0 { - ce.Reply("You're not logged in and there are no default relay users configured") - return - } - logins, err := ce.Bridge.GetUserLoginsInPortal(ce.Ctx, ce.Portal.PortalKey) - if err != nil { - ce.Log.Err(err).Msg("Failed to get user logins in portal") - ce.Reply("Failed to get logins in portal to find default relay") - return - } - Outer: - for _, loginID := range ce.Bridge.Config.Relay.DefaultRelays { - for _, login := range logins { - if login.ID == loginID { - relay = login - break Outer - } - } - } - if relay == nil { - if isLoggedIn { - if onlySetDefaultRelays { - ce.Reply("You're not allowed to use yourself as relay and none of the default relay users are in the chat") - } else { - ce.Reply("None of the default relay users are in the chat. If you want to use yourself as the relay, specify your login ID explicitly") - } - } else { - ce.Reply("You're not logged in and none of the default relay users are in the chat") - } - return - } - } - } else { - var targetID networkid.UserLoginID - if ce.Portal.Receiver != "" { - targetID = ce.Portal.Receiver - if len(ce.Args) > 0 && ce.Args[0] != string(targetID) { - ce.Reply("In split portals, only the receiver (%s) can be set as relay", targetID) - return - } - } else { - targetID = networkid.UserLoginID(ce.Args[0]) - } - relay = ce.Bridge.GetCachedUserLoginByID(targetID) - if relay == nil { - ce.Reply("User login with ID `%s` not found", targetID) - return - } else if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, relay.ID) { - // All good - } else if relay.UserMXID != ce.User.MXID && !ce.User.Permissions.Admin { - ce.Reply("Only bridge admins can set another user's login as the relay") - return - } else if onlySetDefaultRelays { - ce.Reply("You're not allowed to use yourself as relay") - return - } - } - err := ce.Portal.SetRelay(ce.Ctx, relay) - if err != nil { - ce.Log.Err(err).Msg("Failed to unset relay") - ce.Reply("Failed to save relay settings") - } else { - ce.Reply( - "Messages sent by users who haven't logged in will now be relayed through %s ([%s](%s)'s login)", - relay.RemoteName, - relay.UserMXID, - // TODO this will need to stop linkifying if we ever allow UserLogins that aren't bound to a real user. - relay.UserMXID.URI().MatrixToURL(), - ) - } -} - -var CommandUnsetRelay = &FullHandler{ - Func: fnUnsetRelay, - Name: "unset-relay", - Help: HelpMeta{ - Section: HelpSectionAuth, - Description: "Stop relaying messages sent by users who haven't logged in", - }, - RequiresPortal: true, -} - -func fnUnsetRelay(ce *Event) { - if ce.Portal.Relay == nil { - ce.Reply("This portal doesn't have a relay set.") - return - } else if !canManageRelay(ce) { - ce.Reply("You don't have permission to manage the relay in this room") - return - } - err := ce.Portal.SetRelay(ce.Ctx, nil) - if err != nil { - ce.Log.Err(err).Msg("Failed to unset relay") - ce.Reply("Failed to save relay settings") - } else { - ce.Reply("Stopped relaying messages for users who haven't logged in") - } -} - -func canManageRelay(ce *Event) bool { - return ce.User.Permissions.ManageRelay && - (ce.User.Permissions.Admin || - (ce.Portal.Relay != nil && ce.Portal.Relay.UserMXID == ce.User.MXID) || - hasRoomPermissions(ce, ce.RoomID, fakeEvtSetRelay)) -} - -func canPlumb(ce *Event) bool { - return ce.User.Permissions.ManageRelay && - (ce.User.Permissions.Admin || hasRoomPermissions(ce, ce.RoomID, fakeEvtPlumb)) -} - -func hasRoomPermissions(ce *Event, roomID id.RoomID, evtType event.Type) bool { - levels, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, roomID) - if err != nil { - ce.Log.Err(err).Msg("Failed to check room power levels") - return false - } - return levels.GetUserLevel(ce.User.MXID) >= levels.GetEventLevel(evtType) -} diff --git a/mautrix-patched/bridgev2/commands/startchat.go b/mautrix-patched/bridgev2/commands/startchat.go deleted file mode 100644 index a317ed01..00000000 --- a/mautrix-patched/bridgev2/commands/startchat.go +++ /dev/null @@ -1,366 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "errors" - "fmt" - "html" - "maps" - "slices" - "strings" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/provisionutil" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" -) - -var CommandResolveIdentifier = &FullHandler{ - Func: fnResolveIdentifier, - Name: "resolve-identifier", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Check if a given identifier is on the remote network", - Args: "[_login ID_] <_identifier_>", - }, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI], -} - -var CommandStartChat = &FullHandler{ - Func: fnResolveIdentifier, - Name: "start-chat", - Aliases: []string{"pm"}, - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Start a direct chat with the given user", - Args: "[_login ID_] <_identifier_>", - }, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI], -} - -func getClientForStartingChat[T bridgev2.NetworkAPI](ce *Event, thing string) (login *bridgev2.UserLogin, api T, remainingArgs []string) { - if len(ce.Args) > 1 { - remainingArgs = ce.Args[1:] - } - if len(ce.Args) > 0 { - login = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) - } - if login == nil || login.UserMXID != ce.User.MXID { - remainingArgs = ce.Args - login = ce.User.GetDefaultLogin() - if login == nil { - ce.Reply("You're not logged in") - return - } - } - var ok bool - api, ok = login.Client.(T) - if !ok { - ce.Reply("This bridge does not support %s", thing) - } - return login, api, remainingArgs -} - -func formatResolveIdentifierResult(resp *provisionutil.RespResolveIdentifier) string { - if resp.MXID != "" { - return fmt.Sprintf("`%s` / [%s](%s)", resp.ID, resp.Name, resp.MXID.URI().MatrixToURL()) - } else if resp.Name != "" { - return fmt.Sprintf("`%s` / %s", resp.ID, resp.Name) - } else { - return fmt.Sprintf("`%s`", resp.ID) - } -} - -func fnResolveIdentifier(ce *Event) { - if len(ce.Args) == 0 { - ce.Reply("Usage: `$cmdprefix %s `", ce.Command) - return - } - login, api, identifierParts := getClientForStartingChat[bridgev2.IdentifierResolvingNetworkAPI](ce, "resolving identifiers") - if api == nil { - return - } - allLogins := ce.User.GetUserLogins() - createChat := ce.Command == "start-chat" || ce.Command == "pm" - identifier := strings.Join(identifierParts, " ") - resp, err := provisionutil.ResolveIdentifier(ce.Ctx, login, identifier, createChat) - for i := 0; i < len(allLogins) && errors.Is(err, bridgev2.ErrResolveIdentifierTryNext); i++ { - resp, err = provisionutil.ResolveIdentifier(ce.Ctx, allLogins[i], identifier, createChat) - } - if err != nil { - ce.Reply("Failed to resolve identifier: %v", err) - return - } else if resp == nil { - ce.ReplyAdvanced(fmt.Sprintf("Identifier %s not found", html.EscapeString(identifier)), false, true) - return - } - formattedName := formatResolveIdentifierResult(resp) - if createChat { - name := resp.Portal.Name - if name == "" { - name = resp.Portal.MXID.String() - } - if !resp.JustCreated { - ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL()) - } else { - ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL()) - } - } else { - ce.Reply("Found %s", formattedName) - } -} - -var CommandCreateGroup = &FullHandler{ - Func: fnCreateGroup, - Name: "create-group", - Aliases: []string{"create"}, - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Create a new group chat for the current Matrix room", - Args: "[_group type_]", - }, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.GroupCreatingNetworkAPI], - RequiresEventLevel: event.StateBridge, -} - -func getState[T any](ctx context.Context, roomID id.RoomID, evtType event.Type, provider bridgev2.MatrixConnectorWithArbitraryRoomState) (content T) { - evt, err := provider.GetStateEvent(ctx, roomID, evtType, "") - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("event_type", evtType).Msg("Failed to get state event for group creation") - } else if evt != nil { - content, _ = evt.Content.Parsed.(T) - } - return -} - -func fnCreateGroup(ce *Event) { - if alreadyBridged(ce) { - return - } - login, api, remainingArgs := getClientForStartingChat[bridgev2.GroupCreatingNetworkAPI](ce, "creating group") - if api == nil { - return - } - stateProvider, ok := ce.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState) - if !ok { - ce.Reply("Matrix connector doesn't support fetching room state") - return - } - members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID) - if err != nil { - ce.Log.Err(err).Msg("Failed to get room members for group creation") - ce.Reply("Failed to get room members: %v", err) - return - } - caps := ce.Bridge.Network.GetCapabilities() - params := &bridgev2.GroupCreateParams{ - Username: "", - Participants: make([]networkid.UserID, 0, len(members)-2), - Parent: nil, // TODO check space parent event - Name: getState[*event.RoomNameEventContent](ce.Ctx, ce.RoomID, event.StateRoomName, stateProvider), - Avatar: getState[*event.RoomAvatarEventContent](ce.Ctx, ce.RoomID, event.StateRoomAvatar, stateProvider), - Topic: getState[*event.TopicEventContent](ce.Ctx, ce.RoomID, event.StateTopic, stateProvider), - Disappear: getState[*event.BeeperDisappearingTimer](ce.Ctx, ce.RoomID, event.StateBeeperDisappearingTimer, stateProvider), - RoomID: ce.RoomID, - } - for userID, member := range members { - if userID == ce.User.MXID || userID == ce.Bot.GetMXID() || !member.Membership.IsInviteOrJoin() { - continue - } - if parsedUserID, ok := ce.Bridge.Matrix.ParseGhostMXID(userID); ok { - params.Participants = append(params.Participants, parsedUserID) - } else if !ce.Bridge.Config.SplitPortals { - if user, err := ce.Bridge.GetExistingUserByMXID(ce.Ctx, userID); err != nil { - ce.Log.Err(err).Stringer("user_id", userID).Msg("Failed to get user for room member") - } else if user != nil { - for _, login := range user.GetUserLogins() { - nui, ok := login.Client.(bridgev2.NetworkAPIWithUserID) - if !ok { - continue - } - loginUserID := nui.GetUserID() - if loginUserID != "" && nui.IsLoggedIn() { - params.Participants = append(params.Participants, loginUserID) - } - } - } - } - } - - if len(caps.Provisioning.GroupCreation) == 0 { - ce.Reply("No group creation types defined in network capabilities") - return - } else if len(remainingArgs) > 0 { - params.Type = remainingArgs[0] - } else if len(caps.Provisioning.GroupCreation) == 1 { - for params.Type = range caps.Provisioning.GroupCreation { - // The loop assigns the variable we want - } - } else { - types := strings.Join(slices.Collect(maps.Keys(caps.Provisioning.GroupCreation)), "`, `") - ce.Reply("Please specify type of group to create: `%s`", types) - return - } - resp, err := provisionutil.CreateGroup(ce.Ctx, login, params) - if err != nil { - ce.Reply("Failed to create group: %v", err) - return - } - var postfix string - if len(resp.FailedParticipants) > 0 { - failedParticipantsStrings := make([]string, len(resp.FailedParticipants)) - i := 0 - for participantID, meta := range resp.FailedParticipants { - failedParticipantsStrings[i] = fmt.Sprintf("* %s: %s", format.SafeMarkdownCode(participantID), meta.Reason) - i++ - } - postfix += "\n\nFailed to add some participants:\n" + strings.Join(failedParticipantsStrings, "\n") - } - ce.Reply("Successfully created group `%s`%s", resp.ID, postfix) -} - -var CommandCreatePortal = &FullHandler{ - Func: fnCreatePortal, - Name: "create-portal", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Create a new Matrix room for an existing chat on the remote network", - Args: "[login ID] ", - }, -} - -func getCreatePortalInput(ce *Event, allowRelay, preferRelay bool) (portal *bridgev2.Portal, login *bridgev2.UserLogin, ok bool) { - portalID := networkid.PortalID(ce.Args[len(ce.Args)-1]) - if len(ce.Args) == 2 { - loginID := networkid.UserLoginID(ce.Args[0]) - login = ce.Bridge.GetCachedUserLoginByID(loginID) - if login == nil { - ce.Reply("No login found with ID %s", format.SafeMarkdownCode(loginID)) - return - } else if login.UserMXID != ce.User.MXID && - !(ce.User.Permissions.Admin || (allowRelay && slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, login.ID))) { - ce.Reply("Login %s does not belong to you", format.SafeMarkdownCode(loginID)) - return - } - } else if login = ce.User.GetDefaultLogin(); login == nil || preferRelay { - if !allowRelay || len(ce.Bridge.Config.Relay.DefaultRelays) == 0 { - ce.Reply("You're not logged in") - return - } - for _, relayID := range ce.Bridge.Config.Relay.DefaultRelays { - login = ce.Bridge.GetCachedUserLoginByID(relayID) - if login != nil { - break - } - } - if login == nil { - ce.Reply("You're not logged in and none of the default relays were found") - return - } - } - if !login.Client.IsLoggedIn() { - ce.Reply("Login %s is not logged in", format.SafeMarkdownCode(login.ID)) - return - } - var err error - portal, err = ce.Bridge.GetExistingPortalByKey(ce.Ctx, networkid.PortalKey{ - ID: portalID, - Receiver: login.ID, - }) - if err != nil { - ce.Log.Err(err).Msg("Failed to get portal") - ce.Reply("Failed to get portal") - } else if portal == nil { - if ce.Command == "create-portal" { - ce.Reply("No portal found with ID %s. Try `$cmdprefix filter allow` instead", format.SafeMarkdownCode(portalID)) - } else { - ce.Reply("No portal found with ID %s. You may need to receive a message in the chat first", format.SafeMarkdownCode(portalID)) - } - } else { - ok = true - } - return -} - -func fnCreatePortal(ce *Event) { - if len(ce.Args) == 0 || len(ce.Args) > 2 { - ce.Reply("Usage: `$cmdprefix create-portal [login ID] `") - return - } - portal, login, ok := getCreatePortalInput(ce, false, false) - if !ok { - return - } - if portal.MXID != "" { - // TODO allow showing room ID if the user is already in the room, even if they don't have admin permissions - if ce.User.Permissions.Admin { - ce.Reply("That chat already has a Matrix room at [%s](%s)", portal.Name, portal.MXID.URI().MatrixToURL()) - } else { - ce.Reply("That chat already has a Matrix room") - } - } else if info, err := login.Client.GetChatInfo(ce.Ctx, portal); err != nil { - ce.Log.Err(err).Msg("Failed to get chat info for creating portal") - ce.Reply("Failed to get chat info: %v", err) - } else if info == nil { - ce.Reply("Chat info not found") - } else if err = portal.CreateMatrixRoom(ce.Ctx, login, info); err != nil { - ce.Log.Err(err).Msg("Failed to create portal room") - ce.Reply("Failed to create portal room: %v", err) - } else { - ce.Reply("Successfully created portal room [%s](%s)", portal.Name, portal.MXID.URI().MatrixToURL()) - } -} - -var CommandSearch = &FullHandler{ - Func: fnSearch, - Name: "search", - Help: HelpMeta{ - Section: HelpSectionChats, - Description: "Search for users on the remote network", - Args: "<_query_>", - }, - RequiresLogin: true, - NetworkAPI: NetworkAPIImplements[bridgev2.UserSearchingNetworkAPI], -} - -func fnSearch(ce *Event) { - if len(ce.Args) == 0 { - ce.Reply("Usage: `$cmdprefix search `") - return - } - login, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users") - if api == nil { - return - } - resp, err := provisionutil.SearchUsers(ce.Ctx, login, strings.Join(queryParts, " ")) - if err != nil { - ce.Reply("Failed to search for users: %v", err) - return - } - resultsString := make([]string, len(resp.Results)) - for i, res := range resp.Results { - formattedName := formatResolveIdentifierResult(res) - resultsString[i] = fmt.Sprintf("* %s", formattedName) - if res.Portal != nil && res.Portal.MXID != "" { - portalName := res.Portal.Name - if portalName == "" { - portalName = res.Portal.MXID.String() - } - resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Portal.MXID.URI().MatrixToURL()) - } - } - ce.Reply("Search results:\n\n%s", strings.Join(resultsString, "\n")) -} diff --git a/mautrix-patched/bridgev2/commands/sudo.go b/mautrix-patched/bridgev2/commands/sudo.go deleted file mode 100644 index e0814ecb..00000000 --- a/mautrix-patched/bridgev2/commands/sudo.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "strings" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var CommandSudo = &FullHandler{ - Func: fnSudo, - Name: "sudo", - Aliases: []string{"doas", "do-as", "runas", "run-as"}, - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Run a command as a different user.", - Args: "[--create] <_user ID_> <_command_> [_args..._]", - }, - RequiresAdmin: true, -} - -func fnSudo(ce *Event) { - forceNonexistentUser := len(ce.Args) > 0 && strings.ToLower(ce.Args[0]) == "--create" - if forceNonexistentUser { - ce.Args = ce.Args[1:] - } - if len(ce.Args) < 2 { - ce.Reply("Usage: `$cmdprefix sudo [--create] [args...]`") - return - } - targetUserID := id.UserID(ce.Args[0]) - if _, _, err := targetUserID.Parse(); err != nil || len(targetUserID) > id.UserIDMaxLength { - ce.Reply("Invalid user ID `%s`", targetUserID) - return - } - var targetUser *bridgev2.User - var err error - if forceNonexistentUser { - targetUser, err = ce.Bridge.GetUserByMXID(ce.Ctx, targetUserID) - } else { - targetUser, err = ce.Bridge.GetExistingUserByMXID(ce.Ctx, targetUserID) - } - if err != nil { - ce.Log.Err(err).Msg("Failed to get user from database") - ce.Reply("Failed to get user") - return - } else if targetUser == nil { - ce.Reply("User not found. Use `--create` if you want to run commands as a user who has never used the bridge.") - return - } - ce.Sudo = true - ce.User = targetUser - origArgs := ce.Args[1:] - ce.Command = strings.ToLower(ce.Args[1]) - ce.Args = ce.Args[2:] - ce.RawArgs = strings.Join(ce.Args, " ") - ce.Processor.handleCommand(ce.Ctx, ce, strings.Join(origArgs, " "), origArgs) -} - -var CommandDoIn = &FullHandler{ - Func: fnDoIn, - Name: "doin", - Aliases: []string{"do-in", "runin", "run-in"}, - Help: HelpMeta{ - Section: HelpSectionAdmin, - Description: "Run a command in a different room.", - Args: "<_room ID_> <_command_> [_args..._]", - }, -} - -func fnDoIn(ce *Event) { - if len(ce.Args) < 2 { - ce.Reply("Usage: `$cmdprefix doin [args...]`") - return - } - targetRoomID := id.RoomID(ce.Args[0]) - if !ce.User.Permissions.Admin { - memberInfo, err := ce.Bridge.Matrix.GetMemberInfo(ce.Ctx, targetRoomID, ce.User.MXID) - if err != nil { - ce.Log.Err(err).Msg("Failed to check if user is in doin target room") - ce.Reply("Failed to check if you're in the target room") - return - } else if memberInfo == nil || memberInfo.Membership != event.MembershipJoin { - ce.Reply("You must be in the target room to run commands there") - return - } - } - ce.RoomID = targetRoomID - var err error - ce.Portal, err = ce.Bridge.GetPortalByMXID(ce.Ctx, targetRoomID) - if err != nil { - ce.Log.Err(err).Msg("Failed to get target portal") - ce.Reply("Failed to get portal") - return - } - origArgs := ce.Args[1:] - ce.Command = strings.ToLower(ce.Args[1]) - ce.Args = ce.Args[2:] - ce.RawArgs = strings.Join(ce.Args, " ") - ce.Processor.handleCommand(ce.Ctx, ce, strings.Join(origArgs, " "), origArgs) -} diff --git a/mautrix-patched/bridgev2/database/backfillqueue.go b/mautrix-patched/bridgev2/database/backfillqueue.go deleted file mode 100644 index 310b6822..00000000 --- a/mautrix-patched/bridgev2/database/backfillqueue.go +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - "time" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" -) - -type BackfillTaskQuery struct { - BridgeID networkid.BridgeID - *dbutil.QueryHelper[*BackfillTask] -} - -type BackfillTask struct { - BridgeID networkid.BridgeID - PortalKey networkid.PortalKey - UserLoginID networkid.UserLoginID - - BatchCount int - IsDone bool - QueueDone bool - Cursor networkid.PaginationCursor - OldestMessageID networkid.MessageID - DispatchedAt time.Time - CompletedAt time.Time - NextDispatchMinTS time.Time - - FromQueue bool -} - -var BackfillNextDispatchNever = time.Unix(0, (1<<63)-1) - -const ( - ensureBackfillExistsQuery = ` - INSERT INTO backfill_task (bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, next_dispatch_min_ts) - VALUES ($1, $2, $3, $4, -1, false, false, $5) - ON CONFLICT (bridge_id, portal_id, portal_receiver) DO UPDATE - SET user_login_id=CASE - WHEN backfill_task.user_login_id='' - THEN excluded.user_login_id - ELSE backfill_task.user_login_id - END, - next_dispatch_min_ts=CASE - WHEN backfill_task.next_dispatch_min_ts=9223372036854775807 - THEN excluded.next_dispatch_min_ts - ELSE backfill_task.next_dispatch_min_ts - END - ` - upsertBackfillQueueQuery = ` - INSERT INTO backfill_task ( - bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, cursor, - oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (bridge_id, portal_id, portal_receiver) DO UPDATE - SET user_login_id=excluded.user_login_id, - batch_count=excluded.batch_count, - is_done=excluded.is_done, - queue_done=excluded.queue_done, - cursor=excluded.cursor, - oldest_message_id=excluded.oldest_message_id, - dispatched_at=excluded.dispatched_at, - completed_at=excluded.completed_at, - next_dispatch_min_ts=excluded.next_dispatch_min_ts - ` - markBackfillDispatchedQuery = ` - UPDATE backfill_task SET dispatched_at=$4, completed_at=NULL, next_dispatch_min_ts=$5 - WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 - ` - updateBackfillQueueQuery = ` - UPDATE backfill_task - SET user_login_id=$4, batch_count=$5, is_done=$6, queue_done=$7, cursor=$8, oldest_message_id=$9, - dispatched_at=$10, completed_at=$11, next_dispatch_min_ts=$12 - WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 - ` - markBackfillTaskNotDoneQuery = ` - UPDATE backfill_task - SET is_done = false - WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 AND user_login_id = $4 - ` - getNextBackfillQuery = ` - SELECT - bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, - cursor, oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts - FROM backfill_task - WHERE bridge_id = $1 AND next_dispatch_min_ts < $2 AND is_done = false AND queue_done = false AND user_login_id <> '' - ORDER BY next_dispatch_min_ts LIMIT 1 - ` - getNextBackfillQueryForPortal = ` - SELECT - bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, queue_done, - cursor, oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts - FROM backfill_task - WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 - ` - deleteBackfillQueueQuery = ` - DELETE FROM backfill_task - WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 - ` -) - -func (btq *BackfillTaskQuery) EnsureExists(ctx context.Context, portal networkid.PortalKey, loginID networkid.UserLoginID) error { - return btq.Exec(ctx, ensureBackfillExistsQuery, btq.BridgeID, portal.ID, portal.Receiver, loginID, time.Now().UnixNano()) -} - -func (btq *BackfillTaskQuery) Upsert(ctx context.Context, bq *BackfillTask) error { - ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID) - return btq.Exec(ctx, upsertBackfillQueueQuery, bq.sqlVariables()...) -} - -const UnfinishedBackfillBackoff = 1 * time.Hour - -func (btq *BackfillTaskQuery) MarkDispatched(ctx context.Context, bq *BackfillTask) error { - ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID) - bq.DispatchedAt = time.Now() - bq.CompletedAt = time.Time{} - bq.NextDispatchMinTS = bq.DispatchedAt.Add(UnfinishedBackfillBackoff) - return btq.Exec( - ctx, markBackfillDispatchedQuery, - bq.BridgeID, bq.PortalKey.ID, bq.PortalKey.Receiver, - bq.DispatchedAt.UnixNano(), bq.NextDispatchMinTS.UnixNano(), - ) -} - -func (btq *BackfillTaskQuery) Update(ctx context.Context, bq *BackfillTask) error { - ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID) - return btq.Exec(ctx, updateBackfillQueueQuery, bq.sqlVariables()...) -} - -func (btq *BackfillTaskQuery) MarkNotDone(ctx context.Context, portalKey networkid.PortalKey, userLoginID networkid.UserLoginID) error { - return btq.Exec(ctx, markBackfillTaskNotDoneQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver, userLoginID) -} - -func (btq *BackfillTaskQuery) GetNext(ctx context.Context) (*BackfillTask, error) { - return btq.QueryOne(ctx, getNextBackfillQuery, btq.BridgeID, time.Now().UnixNano()) -} - -func (btq *BackfillTaskQuery) GetNextForPortal(ctx context.Context, portalKey networkid.PortalKey, allowCompletedTask bool) (*BackfillTask, error) { - task, err := btq.QueryOne(ctx, getNextBackfillQueryForPortal, btq.BridgeID, portalKey.ID, portalKey.Receiver) - if err != nil { - return nil, err - } else if !allowCompletedTask && (task == nil || task.IsDone || task.UserLoginID == "") { - return nil, nil - } - return task, nil -} - -func (btq *BackfillTaskQuery) Delete(ctx context.Context, portalKey networkid.PortalKey) error { - return btq.Exec(ctx, deleteBackfillQueueQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver) -} - -func (bt *BackfillTask) Scan(row dbutil.Scannable) (*BackfillTask, error) { - var cursor, oldestMessageID sql.NullString - var dispatchedAt, completedAt, nextDispatchMinTS sql.NullInt64 - err := row.Scan( - &bt.BridgeID, &bt.PortalKey.ID, &bt.PortalKey.Receiver, &bt.UserLoginID, &bt.BatchCount, &bt.IsDone, &bt.QueueDone, - &cursor, &oldestMessageID, &dispatchedAt, &completedAt, &nextDispatchMinTS) - if err != nil { - return nil, err - } - bt.Cursor = networkid.PaginationCursor(cursor.String) - bt.OldestMessageID = networkid.MessageID(oldestMessageID.String) - if dispatchedAt.Valid { - bt.DispatchedAt = time.Unix(0, dispatchedAt.Int64) - } - if completedAt.Valid { - bt.CompletedAt = time.Unix(0, completedAt.Int64) - } - if nextDispatchMinTS.Valid { - bt.NextDispatchMinTS = time.Unix(0, nextDispatchMinTS.Int64) - } - return bt, nil -} - -func (bt *BackfillTask) sqlVariables() []any { - return []any{ - bt.BridgeID, bt.PortalKey.ID, bt.PortalKey.Receiver, bt.UserLoginID, bt.BatchCount, bt.IsDone, bt.QueueDone, - dbutil.StrPtr(bt.Cursor), dbutil.StrPtr(bt.OldestMessageID), - dbutil.ConvertedPtr(bt.DispatchedAt, time.Time.UnixNano), - dbutil.ConvertedPtr(bt.CompletedAt, time.Time.UnixNano), - bt.NextDispatchMinTS.UnixNano(), - } -} diff --git a/mautrix-patched/bridgev2/database/database.go b/mautrix-patched/bridgev2/database/database.go deleted file mode 100644 index 05abddf0..00000000 --- a/mautrix-patched/bridgev2/database/database.go +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - - "maunium.net/go/mautrix/bridgev2/database/upgrades" -) - -type Database struct { - *dbutil.Database - - BridgeID networkid.BridgeID - Portal *PortalQuery - Ghost *GhostQuery - Message *MessageQuery - DisappearingMessage *DisappearingMessageQuery - Reaction *ReactionQuery - User *UserQuery - UserLogin *UserLoginQuery - UserPortal *UserPortalQuery - BackfillTask *BackfillTaskQuery - KV *KVQuery - PublicMedia *PublicMediaQuery -} - -type MetaMerger interface { - CopyFrom(other any) -} - -type MetaTypeCreator func() any - -type MetaTypes struct { - Portal MetaTypeCreator - Ghost MetaTypeCreator - Message MetaTypeCreator - Reaction MetaTypeCreator - UserLogin MetaTypeCreator -} - -type blankMeta struct{} - -var blankMetaItem = &blankMeta{} - -func blankMetaCreator() any { - return blankMetaItem -} - -func New(bridgeID networkid.BridgeID, mt MetaTypes, db *dbutil.Database) *Database { - if mt.Portal == nil { - mt.Portal = blankMetaCreator - } - if mt.Ghost == nil { - mt.Ghost = blankMetaCreator - } - if mt.Message == nil { - mt.Message = blankMetaCreator - } - if mt.Reaction == nil { - mt.Reaction = blankMetaCreator - } - if mt.UserLogin == nil { - mt.UserLogin = blankMetaCreator - } - db.UpgradeTable = upgrades.Table - return &Database{ - Database: db, - BridgeID: bridgeID, - Portal: &PortalQuery{ - BridgeID: bridgeID, - MetaType: mt.Portal, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Portal]) *Portal { - return (&Portal{}).ensureHasMetadata(mt.Portal) - }), - }, - Ghost: &GhostQuery{ - BridgeID: bridgeID, - MetaType: mt.Ghost, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Ghost]) *Ghost { - return (&Ghost{}).ensureHasMetadata(mt.Ghost) - }), - }, - Message: &MessageQuery{ - BridgeID: bridgeID, - MetaType: mt.Message, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Message]) *Message { - return (&Message{}).ensureHasMetadata(mt.Message) - }), - }, - DisappearingMessage: &DisappearingMessageQuery{ - BridgeID: bridgeID, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage { - return &DisappearingMessage{} - }), - }, - Reaction: &ReactionQuery{ - BridgeID: bridgeID, - MetaType: mt.Reaction, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Reaction]) *Reaction { - return (&Reaction{}).ensureHasMetadata(mt.Reaction) - }), - }, - User: &UserQuery{ - BridgeID: bridgeID, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*User]) *User { - return &User{} - }), - }, - UserLogin: &UserLoginQuery{ - BridgeID: bridgeID, - MetaType: mt.UserLogin, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*UserLogin]) *UserLogin { - return (&UserLogin{}).ensureHasMetadata(mt.UserLogin) - }), - }, - UserPortal: &UserPortalQuery{ - BridgeID: bridgeID, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*UserPortal]) *UserPortal { - return &UserPortal{} - }), - }, - BackfillTask: &BackfillTaskQuery{ - BridgeID: bridgeID, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*BackfillTask]) *BackfillTask { - return &BackfillTask{} - }), - }, - KV: &KVQuery{ - BridgeID: bridgeID, - Database: db, - }, - PublicMedia: &PublicMediaQuery{ - BridgeID: bridgeID, - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*PublicMedia]) *PublicMedia { - return &PublicMedia{} - }), - }, - } -} - -func ensureBridgeIDMatches(ptr *networkid.BridgeID, expected networkid.BridgeID) { - if *ptr == "" { - *ptr = expected - } else if *ptr != expected { - panic("bridge ID mismatch") - } -} diff --git a/mautrix-patched/bridgev2/database/disappear.go b/mautrix-patched/bridgev2/database/disappear.go deleted file mode 100644 index df36b205..00000000 --- a/mautrix-patched/bridgev2/database/disappear.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - "time" - - "go.mau.fi/util/dbutil" - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// Deprecated: use [event.DisappearingType] -type DisappearingType = event.DisappearingType - -// Deprecated: use constants in event package -const ( - DisappearingTypeNone = event.DisappearingTypeNone - DisappearingTypeAfterRead = event.DisappearingTypeAfterRead - DisappearingTypeAfterSend = event.DisappearingTypeAfterSend -) - -// DisappearingSetting represents a disappearing message timer setting -// by combining a type with a timer and an optional start timestamp. -type DisappearingSetting struct { - Type event.DisappearingType - Timer time.Duration - DisappearAt time.Time -} - -func DisappearingSettingFromEvent(evt *event.BeeperDisappearingTimer) DisappearingSetting { - if evt == nil || evt.Type == event.DisappearingTypeNone { - return DisappearingSetting{} - } - return DisappearingSetting{ - Type: evt.Type, - Timer: evt.Timer.Duration, - } -} - -func (ds DisappearingSetting) Normalize() DisappearingSetting { - if ds.Type == event.DisappearingTypeNone { - ds.Timer = 0 - } else if ds.Timer == 0 { - ds.Type = event.DisappearingTypeNone - } - return ds -} - -func (ds DisappearingSetting) StartingAt(start time.Time) DisappearingSetting { - ds.DisappearAt = start.Add(ds.Timer) - return ds -} - -func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer { - if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 { - return &event.BeeperDisappearingTimer{} - } - return &event.BeeperDisappearingTimer{ - Type: ds.Type, - Timer: jsontime.MS(ds.Timer), - } -} - -type DisappearingMessageQuery struct { - BridgeID networkid.BridgeID - *dbutil.QueryHelper[*DisappearingMessage] -} - -type DisappearingMessage struct { - BridgeID networkid.BridgeID - RoomID id.RoomID - EventID id.EventID - Timestamp time.Time - DisappearingSetting -} - -const ( - upsertDisappearingMessageQuery = ` - INSERT INTO disappearing_message (bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (bridge_id, mxid) DO UPDATE SET timer=excluded.timer, disappear_at=excluded.disappear_at - ` - startDisappearingMessagesQuery = ` - UPDATE disappearing_message - SET disappear_at=$1 + timer - WHERE bridge_id=$2 AND mx_room=$3 AND disappear_at IS NULL AND type='after_read' AND timestamp<=$4 - RETURNING bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at - ` - getUpcomingDisappearingMessagesQuery = ` - SELECT bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at - FROM disappearing_message WHERE bridge_id = $1 AND disappear_at IS NOT NULL AND disappear_at < $2 - ORDER BY disappear_at LIMIT $3 - ` - deleteDisappearingMessageQuery = ` - DELETE FROM disappearing_message WHERE bridge_id=$1 AND mxid=$2 - ` -) - -func (dmq *DisappearingMessageQuery) Put(ctx context.Context, dm *DisappearingMessage) error { - ensureBridgeIDMatches(&dm.BridgeID, dmq.BridgeID) - return dmq.Exec(ctx, upsertDisappearingMessageQuery, dm.sqlVariables()...) -} - -func (dmq *DisappearingMessageQuery) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) ([]*DisappearingMessage, error) { - return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID, beforeTS.UnixNano()) -} - -func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration, limit int) ([]*DisappearingMessage, error) { - return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).UnixNano(), limit) -} - -func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.EventID) error { - return dmq.Exec(ctx, deleteDisappearingMessageQuery, dmq.BridgeID, eventID) -} - -func (d *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) { - var timestamp int64 - var disappearAt sql.NullInt64 - err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, ×tamp, &d.Type, &d.Timer, &disappearAt) - if err != nil { - return nil, err - } - if disappearAt.Valid { - d.DisappearAt = time.Unix(0, disappearAt.Int64) - } - d.Timestamp = time.Unix(0, timestamp) - return d, nil -} - -func (d *DisappearingMessage) sqlVariables() []any { - return []any{d.BridgeID, d.RoomID, d.EventID, d.Timestamp.UnixNano(), d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)} -} diff --git a/mautrix-patched/bridgev2/database/ghost.go b/mautrix-patched/bridgev2/database/ghost.go deleted file mode 100644 index b936e14a..00000000 --- a/mautrix-patched/bridgev2/database/ghost.go +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/id" -) - -type GhostQuery struct { - BridgeID networkid.BridgeID - MetaType MetaTypeCreator - *dbutil.QueryHelper[*Ghost] -} - -type ExtraProfile map[string]json.RawMessage - -func (ep *ExtraProfile) Set(key string, value any) error { - if key == "displayname" || key == "avatar_url" { - return fmt.Errorf("cannot set reserved profile key %q", key) - } - marshaled, err := canonicaljson.Marshal(value) - if err != nil { - return err - } - if *ep == nil { - *ep = make(ExtraProfile) - } - (*ep)[key] = marshaled - return nil -} - -func (ep *ExtraProfile) With(key string, value any) *ExtraProfile { - exerrors.PanicIfNotNil(ep.Set(key, value)) - return ep -} - -func canonicalize(data json.RawMessage) json.RawMessage { - canonicalized, _ := canonicaljson.Marshal(data) - return canonicalized -} - -func (ep *ExtraProfile) CopyTo(dest *ExtraProfile) (changed bool) { - if len(*ep) == 0 { - return - } - if *dest == nil { - *dest = make(ExtraProfile) - } - for key, val := range *ep { - if key == "displayname" || key == "avatar_url" { - continue - } - existing, exists := (*dest)[key] - if !exists || !bytes.Equal(canonicalize(existing), val) { - (*dest)[key] = val - changed = true - } - } - return -} - -type Ghost struct { - BridgeID networkid.BridgeID - ID networkid.UserID - - Name string - AvatarID networkid.AvatarID - AvatarHash [32]byte - AvatarMXC id.ContentURIString - NameSet bool - AvatarSet bool - ContactInfoSet bool - IsBot bool - Identifiers []string - ExtraProfile ExtraProfile - Metadata any -} - -const ( - getGhostBaseQuery = ` - SELECT bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, - name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata - FROM ghost - ` - getGhostByIDQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND id=$2` - getGhostByMetadataQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND metadata->>$2=$3` - insertGhostQuery = ` - INSERT INTO ghost ( - bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, - name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - ` - updateGhostQuery = ` - UPDATE ghost SET name=$3, avatar_id=$4, avatar_hash=$5, avatar_mxc=$6, - name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10, - identifiers=$11, extra_profile=$12, metadata=$13 - WHERE bridge_id=$1 AND id=$2 - ` -) - -func (gq *GhostQuery) GetByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { - return gq.QueryOne(ctx, getGhostByIDQuery, gq.BridgeID, id) -} - -// GetByMetadata returns the ghosts whose metadata field at the given JSON key -// matches the given value. -func (gq *GhostQuery) GetByMetadata(ctx context.Context, key string, value any) ([]*Ghost, error) { - return gq.QueryMany(ctx, getGhostByMetadataQuery, gq.BridgeID, key, value) -} - -func (gq *GhostQuery) Insert(ctx context.Context, ghost *Ghost) error { - ensureBridgeIDMatches(&ghost.BridgeID, gq.BridgeID) - return gq.Exec(ctx, insertGhostQuery, ghost.ensureHasMetadata(gq.MetaType).sqlVariables()...) -} - -func (gq *GhostQuery) Update(ctx context.Context, ghost *Ghost) error { - ensureBridgeIDMatches(&ghost.BridgeID, gq.BridgeID) - return gq.Exec(ctx, updateGhostQuery, ghost.ensureHasMetadata(gq.MetaType).sqlVariables()...) -} - -func (g *Ghost) Scan(row dbutil.Scannable) (*Ghost, error) { - var avatarHash string - err := row.Scan( - &g.BridgeID, &g.ID, - &g.Name, &g.AvatarID, &avatarHash, &g.AvatarMXC, - &g.NameSet, &g.AvatarSet, &g.ContactInfoSet, &g.IsBot, - dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: &g.ExtraProfile}, dbutil.JSON{Data: g.Metadata}, - ) - if err != nil { - return nil, err - } - if avatarHash != "" { - data, _ := hex.DecodeString(avatarHash) - if len(data) == 32 { - g.AvatarHash = *(*[32]byte)(data) - } - } - return g, nil -} - -func (g *Ghost) ensureHasMetadata(metaType MetaTypeCreator) *Ghost { - if g.Metadata == nil { - g.Metadata = metaType() - } - return g -} - -func (g *Ghost) sqlVariables() []any { - var avatarHash string - if g.AvatarHash != [32]byte{} { - avatarHash = hex.EncodeToString(g.AvatarHash[:]) - } - return []any{ - g.BridgeID, g.ID, - g.Name, g.AvatarID, avatarHash, g.AvatarMXC, - g.NameSet, g.AvatarSet, g.ContactInfoSet, g.IsBot, - dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.ExtraProfile}, dbutil.JSON{Data: g.Metadata}, - } -} diff --git a/mautrix-patched/bridgev2/database/kvstore.go b/mautrix-patched/bridgev2/database/kvstore.go deleted file mode 100644 index bca26ed5..00000000 --- a/mautrix-patched/bridgev2/database/kvstore.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - "errors" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" -) - -type Key string - -const ( - KeySplitPortalsEnabled Key = "split_portals_enabled" - KeyBridgeInfoVersion Key = "bridge_info_version" - KeyEncryptionStateResynced Key = "encryption_state_resynced" - KeyRecoveryKey Key = "recovery_key" -) - -type KVQuery struct { - BridgeID networkid.BridgeID - *dbutil.Database -} - -const ( - getKVQuery = `SELECT value FROM kv_store WHERE bridge_id = $1 AND key = $2` - setKVQuery = ` - INSERT INTO kv_store (bridge_id, key, value) VALUES ($1, $2, $3) - ON CONFLICT (bridge_id, key) DO UPDATE SET value = $3 - ` -) - -func (kvq *KVQuery) Get(ctx context.Context, key Key) string { - var value string - err := kvq.QueryRow(ctx, getKVQuery, kvq.BridgeID, key).Scan(&value) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - zerolog.Ctx(ctx).Err(err).Str("key", string(key)).Msg("Failed to get key from kvstore") - } - return value -} - -func (kvq *KVQuery) Set(ctx context.Context, key Key, value string) { - _, err := kvq.Exec(ctx, setKVQuery, kvq.BridgeID, key, value) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("key", string(key)). - Str("value", value). - Msg("Failed to set key in kvstore") - } -} diff --git a/mautrix-patched/bridgev2/database/message.go b/mautrix-patched/bridgev2/database/message.go deleted file mode 100644 index d6c79fcd..00000000 --- a/mautrix-patched/bridgev2/database/message.go +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "crypto/sha256" - "database/sql" - "encoding/base64" - "fmt" - "strings" - "sync" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" -) - -type MessageQuery struct { - BridgeID networkid.BridgeID - MetaType MetaTypeCreator - *dbutil.QueryHelper[*Message] - chunkDeleteLock sync.Mutex -} - -type Message struct { - RowID int64 - BridgeID networkid.BridgeID - ID networkid.MessageID - PartID networkid.PartID - MXID id.EventID - - Room networkid.PortalKey - SenderID networkid.UserID - SenderMXID id.UserID - Timestamp time.Time - EditCount int - IsDoublePuppeted bool - - ThreadRoot networkid.MessageID - ReplyTo networkid.MessageOptionalPartID - - SendTxnID networkid.RawTransactionID - - Metadata any -} - -const ( - getMessageBaseQuery = ` - SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, - timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, - send_txn_id, metadata - FROM message - ` - getAllMessagePartsByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3` - getMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 AND part_id=$4` - getMessagePartByRowIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND rowid=$2` - getMessageByMXIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` - getMessageByTxnIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND (mxid=$3 OR send_txn_id=$4)` - getLastMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id DESC LIMIT 1` - getFirstMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id ASC LIMIT 1` - getMessagesBetweenTimeQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND timestamp>$4 AND timestamp<=$5` - getOldestMessageInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp ASC, id ASC, part_id ASC LIMIT 1` - getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS FIRST, timestamp ASC, id ASC, part_id ASC LIMIT 1` - getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS LAST, timestamp DESC, id DESC, part_id DESC LIMIT 1` - getLastNInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp DESC, id DESC, part_id DESC LIMIT $4` - - getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, id DESC, part_id DESC LIMIT 1` - getLastNonFakeMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 AND mxid NOT LIKE '~fake:%' ORDER BY timestamp DESC, id DESC, part_id DESC LIMIT 1` - - countMessagesInPortalQuery = ` - SELECT COUNT(*) FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 - ` - - insertMessageQuery = ` - INSERT INTO message ( - bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, - timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, - send_txn_id, metadata - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) - RETURNING rowid - ` - updateMessageQuery = ` - UPDATE message SET id=$2, part_id=$3, mxid=$4, room_id=$5, room_receiver=$6, sender_id=$7, sender_mxid=$8, - timestamp=$9, edit_count=$10, double_puppeted=$11, thread_root_id=$12, reply_to_id=$13, - reply_to_part_id=$14, send_txn_id=$15, metadata=$16 - WHERE bridge_id=$1 AND rowid=$17 - ` - deleteAllMessagePartsByIDQuery = ` - DELETE FROM message WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 - ` - deleteMessagePartByRowIDQuery = ` - DELETE FROM message WHERE bridge_id=$1 AND rowid=$2 - ` - deleteMessageChunkQuery = ` - DELETE FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND rowid > $4 AND rowid <= $5 - ` - getMaxMessageRowIDQuery = `SELECT MAX(rowid) FROM message WHERE bridge_id=$1` -) - -func (mq *MessageQuery) GetAllPartsByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) ([]*Message, error) { - return mq.QueryMany(ctx, getAllMessagePartsByIDQuery, mq.BridgeID, receiver, id) -} - -func (mq *MessageQuery) GetPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID, partID networkid.PartID) (*Message, error) { - return mq.QueryOne(ctx, getMessagePartByIDQuery, mq.BridgeID, receiver, id, partID) -} - -func (mq *MessageQuery) GetPartByMXID(ctx context.Context, mxid id.EventID) (*Message, error) { - return mq.QueryOne(ctx, getMessageByMXIDQuery, mq.BridgeID, mxid) -} - -func (mq *MessageQuery) GetPartByTxnID(ctx context.Context, receiver networkid.UserLoginID, mxid id.EventID, txnID networkid.RawTransactionID) (*Message, error) { - return mq.QueryOne(ctx, getMessageByTxnIDQuery, mq.BridgeID, receiver, mxid, txnID) -} - -func (mq *MessageQuery) GetLastPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) { - return mq.QueryOne(ctx, getLastMessagePartByIDQuery, mq.BridgeID, receiver, id) -} - -func (mq *MessageQuery) GetFirstPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) { - return mq.QueryOne(ctx, getFirstMessagePartByIDQuery, mq.BridgeID, receiver, id) -} - -func (mq *MessageQuery) GetByRowID(ctx context.Context, rowID int64) (*Message, error) { - return mq.QueryOne(ctx, getMessagePartByRowIDQuery, mq.BridgeID, rowID) -} - -func (mq *MessageQuery) GetFirstOrSpecificPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageOptionalPartID) (*Message, error) { - if id.PartID == nil { - return mq.GetFirstPartByID(ctx, receiver, id.MessageID) - } else { - return mq.GetPartByID(ctx, receiver, id.MessageID, *id.PartID) - } -} - -func (mq *MessageQuery) GetLastPartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) { - return mq.QueryOne(ctx, getLastMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano()) -} - -func (mq *MessageQuery) GetLastNonFakePartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) { - return mq.QueryOne(ctx, getLastNonFakeMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano()) -} - -func (mq *MessageQuery) GetMessagesBetweenTimeQuery(ctx context.Context, portal networkid.PortalKey, start, end time.Time) ([]*Message, error) { - return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, start.UnixNano(), end.UnixNano()) -} - -func (mq *MessageQuery) GetFirstPortalMessage(ctx context.Context, portal networkid.PortalKey) (*Message, error) { - return mq.QueryOne(ctx, getOldestMessageInPortal, mq.BridgeID, portal.ID, portal.Receiver) -} - -func (mq *MessageQuery) GetFirstThreadMessage(ctx context.Context, portal networkid.PortalKey, threadRoot networkid.MessageID) (*Message, error) { - return mq.QueryOne(ctx, getFirstMessageInThread, mq.BridgeID, portal.ID, portal.Receiver, threadRoot) -} - -func (mq *MessageQuery) GetLastThreadMessage(ctx context.Context, portal networkid.PortalKey, threadRoot networkid.MessageID) (*Message, error) { - return mq.QueryOne(ctx, getLastMessageInThread, mq.BridgeID, portal.ID, portal.Receiver, threadRoot) -} - -func (mq *MessageQuery) GetLastNInPortal(ctx context.Context, portal networkid.PortalKey, n int) ([]*Message, error) { - return mq.QueryMany(ctx, getLastNInPortal, mq.BridgeID, portal.ID, portal.Receiver, n) -} - -func (mq *MessageQuery) Insert(ctx context.Context, msg *Message) error { - ensureBridgeIDMatches(&msg.BridgeID, mq.BridgeID) - return mq.GetDB().QueryRow(ctx, insertMessageQuery, msg.ensureHasMetadata(mq.MetaType).sqlVariables()...).Scan(&msg.RowID) -} - -func (mq *MessageQuery) Update(ctx context.Context, msg *Message) error { - ensureBridgeIDMatches(&msg.BridgeID, mq.BridgeID) - return mq.Exec(ctx, updateMessageQuery, msg.ensureHasMetadata(mq.MetaType).updateSQLVariables()...) -} - -func (mq *MessageQuery) DeleteAllParts(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) error { - return mq.Exec(ctx, deleteAllMessagePartsByIDQuery, mq.BridgeID, receiver, id) -} - -func (mq *MessageQuery) Delete(ctx context.Context, rowID int64) error { - return mq.Exec(ctx, deleteMessagePartByRowIDQuery, mq.BridgeID, rowID) -} - -func (mq *MessageQuery) deleteChunk(ctx context.Context, portal networkid.PortalKey, minRowID, maxRowID int64) (int64, error) { - res, err := mq.GetDB().Exec(ctx, deleteMessageChunkQuery, mq.BridgeID, portal.ID, portal.Receiver, minRowID, maxRowID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (mq *MessageQuery) getMaxRowID(ctx context.Context) (maxRowID int64, err error) { - err = mq.GetDB().QueryRow(ctx, getMaxMessageRowIDQuery, mq.BridgeID).Scan(&maxRowID) - return -} - -const deleteChunkSize = 100_000 - -func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.PortalKey) error { - if mq.GetDB().Dialect != dbutil.SQLite { - return nil - } - log := zerolog.Ctx(ctx).With(). - Str("action", "delete messages in chunks"). - Stringer("portal_key", portal). - Logger() - if !mq.chunkDeleteLock.TryLock() { - log.Warn().Msg("Portal deletion lock is being held, waiting...") - mq.chunkDeleteLock.Lock() - log.Debug().Msg("Acquired portal deletion lock after waiting") - } - defer mq.chunkDeleteLock.Unlock() - total, err := mq.CountMessagesInPortal(ctx, portal) - if err != nil { - return fmt.Errorf("failed to count messages in portal: %w", err) - } else if total < deleteChunkSize/3 { - return nil - } - globalMaxRowID, err := mq.getMaxRowID(ctx) - if err != nil { - return fmt.Errorf("failed to get max row ID: %w", err) - } - log.Debug(). - Int("total_count", total). - Int64("global_max_row_id", globalMaxRowID). - Msg("Portal has lots of messages, deleting in chunks to avoid database locks") - maxRowID := int64(deleteChunkSize) - globalMaxRowID += deleteChunkSize * 1.2 - var dbTimeUsed time.Duration - globalStart := time.Now() - for total > 500 && maxRowID < globalMaxRowID { - start := time.Now() - count, err := mq.deleteChunk(ctx, portal, maxRowID-deleteChunkSize, maxRowID) - duration := time.Since(start) - dbTimeUsed += duration - if err != nil { - return fmt.Errorf("failed to delete chunk of messages before %d: %w", maxRowID, err) - } - total -= int(count) - maxRowID += deleteChunkSize - sleepTime := max(10*time.Millisecond, min(250*time.Millisecond, time.Duration(count/100)*time.Millisecond)) - log.Debug(). - Int64("max_row_id", maxRowID). - Int64("deleted_count", count). - Int("remaining_count", total). - Dur("duration", duration). - Dur("sleep_time", sleepTime). - Msg("Deleted chunk of messages") - select { - case <-time.After(sleepTime): - case <-ctx.Done(): - return ctx.Err() - } - } - log.Debug(). - Int("remaining_count", total). - Dur("db_time_used", dbTimeUsed). - Dur("total_duration", time.Since(globalStart)). - Msg("Finished chunked delete of messages in portal") - return nil -} - -func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid.PortalKey) (count int, err error) { - err = mq.GetDB().QueryRow(ctx, countMessagesInPortalQuery, mq.BridgeID, key.ID, key.Receiver).Scan(&count) - return -} - -func (m *Message) Scan(row dbutil.Scannable) (*Message, error) { - var timestamp int64 - var threadRootID, replyToID, replyToPartID, sendTxnID sql.NullString - var doublePuppeted sql.NullBool - err := row.Scan( - &m.RowID, &m.BridgeID, &m.ID, &m.PartID, &m.MXID, &m.Room.ID, &m.Room.Receiver, &m.SenderID, &m.SenderMXID, - ×tamp, &m.EditCount, &doublePuppeted, &threadRootID, &replyToID, &replyToPartID, &sendTxnID, - dbutil.JSON{Data: m.Metadata}, - ) - if err != nil { - return nil, err - } - m.Timestamp = time.Unix(0, timestamp) - m.ThreadRoot = networkid.MessageID(threadRootID.String) - m.IsDoublePuppeted = doublePuppeted.Valid - if replyToID.Valid { - m.ReplyTo.MessageID = networkid.MessageID(replyToID.String) - if replyToPartID.Valid { - m.ReplyTo.PartID = (*networkid.PartID)(&replyToPartID.String) - } - } - if sendTxnID.Valid { - m.SendTxnID = networkid.RawTransactionID(sendTxnID.String) - } - return m, nil -} - -func (m *Message) ensureHasMetadata(metaType MetaTypeCreator) *Message { - if m.Metadata == nil { - m.Metadata = metaType() - } - return m -} - -func (m *Message) sqlVariables() []any { - return []any{ - m.BridgeID, m.ID, m.PartID, m.MXID, m.Room.ID, m.Room.Receiver, m.SenderID, m.SenderMXID, - m.Timestamp.UnixNano(), m.EditCount, m.IsDoublePuppeted, dbutil.StrPtr(m.ThreadRoot), - dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID, dbutil.StrPtr(m.SendTxnID), - dbutil.JSON{Data: m.Metadata}, - } -} - -func (m *Message) updateSQLVariables() []any { - return append(m.sqlVariables(), m.RowID) -} - -const FakeMXIDPrefix = "~fake:" -const TxnMXIDPrefix = "~txn:" -const NetworkTxnMXIDPrefix = TxnMXIDPrefix + "network:" -const RandomTxnMXIDPrefix = TxnMXIDPrefix + "random:" - -func (m *Message) SetFakeMXID() { - hash := sha256.Sum256([]byte(m.ID)) - m.MXID = id.EventID(FakeMXIDPrefix + base64.RawURLEncoding.EncodeToString(hash[:])) -} - -func (m *Message) HasFakeMXID() bool { - return strings.HasPrefix(m.MXID.String(), FakeMXIDPrefix) -} diff --git a/mautrix-patched/bridgev2/database/portal.go b/mautrix-patched/bridgev2/database/portal.go deleted file mode 100644 index 0e6be286..00000000 --- a/mautrix-patched/bridgev2/database/portal.go +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - "encoding/hex" - "errors" - "time" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type RoomType string - -const ( - RoomTypeDefault RoomType = "" - RoomTypeDM RoomType = "dm" - RoomTypeGroupDM RoomType = "group_dm" - RoomTypeSpace RoomType = "space" -) - -type PortalQuery struct { - BridgeID networkid.BridgeID - MetaType MetaTypeCreator - *dbutil.QueryHelper[*Portal] -} - -type CapStateFlags uint32 - -func (csf CapStateFlags) Has(flag CapStateFlags) bool { - return csf&flag != 0 -} - -const ( - CapStateFlagDisappearingTimerSet CapStateFlags = 1 << iota -) - -type CapabilityState struct { - Source networkid.UserLoginID `json:"source"` - ID string `json:"id"` - Flags CapStateFlags `json:"flags"` -} - -type Portal struct { - BridgeID networkid.BridgeID - networkid.PortalKey - MXID id.RoomID - - ParentKey networkid.PortalKey - RelayLoginID networkid.UserLoginID - OtherUserID networkid.UserID - Name string - Topic string - AvatarID networkid.AvatarID - AvatarHash [32]byte - AvatarMXC id.ContentURIString - NameSet bool - TopicSet bool - AvatarSet bool - NameIsCustom bool - InSpace bool - MessageRequest bool - RoomType RoomType - Disappear DisappearingSetting - CapState CapabilityState - Metadata any -} - -const ( - getPortalBaseQuery = ` - SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_login_id, other_user_id, - name, topic, avatar_id, avatar_hash, avatar_mxc, - name_set, topic_set, avatar_set, name_is_custom, in_space, message_request, - room_type, disappear_type, disappear_timer, cap_state, - metadata - FROM portal - ` - getPortalByKeyQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND receiver=$3` - getPortalByIDWithUncertainReceiverQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='')` - getPortalByMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` - getAllPortalsWithMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid IS NOT NULL` - getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND (receiver='' OR (parent_id<>'' AND parent_receiver='')) ORDER BY parent_id DESC` - getAllDMPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND other_user_id=$2` - getDMPortalQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND receiver=$2 AND other_user_id=$3` - getAllPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1` - getChildPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND parent_id=$2 AND parent_receiver=$3` - - findPortalReceiverQuery = `SELECT id, receiver FROM portal WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='') LIMIT 1` - - insertPortalQuery = ` - INSERT INTO portal ( - bridge_id, id, receiver, mxid, - parent_id, parent_receiver, relay_login_id, other_user_id, - name, topic, avatar_id, avatar_hash, avatar_mxc, - name_set, avatar_set, topic_set, name_is_custom, in_space, message_request, - room_type, disappear_type, disappear_timer, cap_state, - metadata, relay_bridge_id - ) VALUES ( - $1, $2, $3, $4, $5, $6, cast($7 AS TEXT), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, - CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE $1 END - ) - ` - updatePortalQuery = ` - UPDATE portal - SET mxid=$4, parent_id=$5, parent_receiver=$6, - relay_login_id=cast($7 AS TEXT), relay_bridge_id=CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE bridge_id END, - other_user_id=$8, name=$9, topic=$10, avatar_id=$11, avatar_hash=$12, avatar_mxc=$13, - name_set=$14, avatar_set=$15, topic_set=$16, name_is_custom=$17, in_space=$18, message_request=$19, - room_type=$20, disappear_type=$21, disappear_timer=$22, cap_state=$23, metadata=$24 - WHERE bridge_id=$1 AND id=$2 AND receiver=$3 - ` - deletePortalQuery = ` - DELETE FROM portal - WHERE bridge_id=$1 AND id=$2 AND receiver=$3 - ` - reIDPortalQuery = `UPDATE portal SET id=$4, receiver=$5 WHERE bridge_id=$1 AND id=$2 AND receiver=$3` - migrateToSplitPortalsQuery = ` - UPDATE portal - SET receiver=new_receiver - FROM ( - SELECT bridge_id, id, COALESCE(( - SELECT login_id - FROM user_portal - WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver='' - LIMIT 1 - ), ( - SELECT login_id - FROM user_portal - WHERE portal.parent_id<>'' AND bridge_id=portal.bridge_id AND portal_id=portal.parent_id - LIMIT 1 - ), ( - SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1 - ), '') AS new_receiver - FROM portal - WHERE receiver='' AND bridge_id=$1 - ) updates - WHERE portal.bridge_id=updates.bridge_id AND portal.id=updates.id AND portal.receiver='' AND NOT EXISTS ( - SELECT 1 FROM portal p2 WHERE p2.bridge_id=updates.bridge_id AND p2.id=updates.id AND p2.receiver=updates.new_receiver - ) - ` - fixParentsAfterSplitPortalMigrationQuery = ` - UPDATE portal - SET parent_receiver=receiver - WHERE bridge_id=$1 AND parent_receiver='' AND receiver<>'' AND parent_id<>'' - AND EXISTS(SELECT 1 FROM portal pp WHERE pp.bridge_id=$1 AND pp.id=portal.parent_id AND pp.receiver=portal.receiver); - ` -) - -func (pq *PortalQuery) GetByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByKeyQuery, pq.BridgeID, key.ID, key.Receiver) -} - -func (pq *PortalQuery) FindReceiver(ctx context.Context, id networkid.PortalID, maybeReceiver networkid.UserLoginID) (key networkid.PortalKey, err error) { - err = pq.GetDB().QueryRow(ctx, findPortalReceiverQuery, pq.BridgeID, id, maybeReceiver).Scan(&key.ID, &key.Receiver) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return -} - -func (pq *PortalQuery) GetByIDWithUncertainReceiver(ctx context.Context, key networkid.PortalKey) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByIDWithUncertainReceiverQuery, pq.BridgeID, key.ID, key.Receiver) -} - -func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByMXIDQuery, pq.BridgeID, mxid) -} - -func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) { - return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery, pq.BridgeID) -} - -func (pq *PortalQuery) GetAllWithoutReceiver(ctx context.Context) ([]*Portal, error) { - return pq.QueryMany(ctx, getAllPortalsWithoutReceiver, pq.BridgeID) -} - -func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) { - return pq.QueryMany(ctx, getAllPortalsQuery, pq.BridgeID) -} - -func (pq *PortalQuery) GetAllDMsWith(ctx context.Context, otherUserID networkid.UserID) ([]*Portal, error) { - return pq.QueryMany(ctx, getAllDMPortalsQuery, pq.BridgeID, otherUserID) -} - -func (pq *PortalQuery) GetDM(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { - return pq.QueryOne(ctx, getDMPortalQuery, pq.BridgeID, receiver, otherUserID) -} - -func (pq *PortalQuery) GetChildren(ctx context.Context, parentKey networkid.PortalKey) ([]*Portal, error) { - return pq.QueryMany(ctx, getChildPortalsQuery, pq.BridgeID, parentKey.ID, parentKey.Receiver) -} - -func (pq *PortalQuery) ReID(ctx context.Context, oldID, newID networkid.PortalKey) error { - return pq.Exec(ctx, reIDPortalQuery, pq.BridgeID, oldID.ID, oldID.Receiver, newID.ID, newID.Receiver) -} - -func (pq *PortalQuery) Insert(ctx context.Context, p *Portal) error { - ensureBridgeIDMatches(&p.BridgeID, pq.BridgeID) - return pq.Exec(ctx, insertPortalQuery, p.ensureHasMetadata(pq.MetaType).sqlVariables()...) -} - -func (pq *PortalQuery) Update(ctx context.Context, p *Portal) error { - ensureBridgeIDMatches(&p.BridgeID, pq.BridgeID) - return pq.Exec(ctx, updatePortalQuery, p.ensureHasMetadata(pq.MetaType).sqlVariables()...) -} - -func (pq *PortalQuery) Delete(ctx context.Context, key networkid.PortalKey) error { - return pq.Exec(ctx, deletePortalQuery, pq.BridgeID, key.ID, key.Receiver) -} - -func (pq *PortalQuery) MigrateToSplitPortals(ctx context.Context) (int64, error) { - res, err := pq.GetDB().Exec(ctx, migrateToSplitPortalsQuery, pq.BridgeID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (pq *PortalQuery) FixParentsAfterSplitPortalMigration(ctx context.Context) (int64, error) { - res, err := pq.GetDB().Exec(ctx, fixParentsAfterSplitPortalMigrationQuery, pq.BridgeID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { - var mxid, parentID, parentReceiver, relayLoginID, otherUserID, disappearType sql.NullString - var disappearTimer sql.NullInt64 - var avatarHash string - err := row.Scan( - &p.BridgeID, &p.ID, &p.Receiver, &mxid, - &parentID, &parentReceiver, &relayLoginID, &otherUserID, - &p.Name, &p.Topic, &p.AvatarID, &avatarHash, &p.AvatarMXC, - &p.NameSet, &p.TopicSet, &p.AvatarSet, &p.NameIsCustom, &p.InSpace, &p.MessageRequest, - &p.RoomType, &disappearType, &disappearTimer, - dbutil.JSON{Data: &p.CapState}, dbutil.JSON{Data: p.Metadata}, - ) - if err != nil { - return nil, err - } - if avatarHash != "" { - data, _ := hex.DecodeString(avatarHash) - if len(data) == 32 { - p.AvatarHash = *(*[32]byte)(data) - } - } - if disappearType.Valid { - p.Disappear = DisappearingSetting{ - Type: event.DisappearingType(disappearType.String), - Timer: time.Duration(disappearTimer.Int64), - } - } - p.MXID = id.RoomID(mxid.String) - p.OtherUserID = networkid.UserID(otherUserID.String) - if parentID.Valid { - p.ParentKey = networkid.PortalKey{ - ID: networkid.PortalID(parentID.String), - Receiver: networkid.UserLoginID(parentReceiver.String), - } - } - p.RelayLoginID = networkid.UserLoginID(relayLoginID.String) - return p, nil -} - -func (p *Portal) ensureHasMetadata(metaType MetaTypeCreator) *Portal { - if p.Metadata == nil { - p.Metadata = metaType() - } - return p -} - -func (p *Portal) sqlVariables() []any { - var avatarHash string - if p.AvatarHash != [32]byte{} { - avatarHash = hex.EncodeToString(p.AvatarHash[:]) - } - return []any{ - p.BridgeID, p.ID, p.Receiver, dbutil.StrPtr(p.MXID), - dbutil.StrPtr(p.ParentKey.ID), p.ParentKey.Receiver, dbutil.StrPtr(p.RelayLoginID), dbutil.StrPtr(p.OtherUserID), - p.Name, p.Topic, p.AvatarID, avatarHash, p.AvatarMXC, - p.NameSet, p.TopicSet, p.AvatarSet, p.NameIsCustom, p.InSpace, p.MessageRequest, - p.RoomType, dbutil.StrPtr(p.Disappear.Type), dbutil.NumPtr(p.Disappear.Timer), - dbutil.JSON{Data: p.CapState}, dbutil.JSON{Data: p.Metadata}, - } -} diff --git a/mautrix-patched/bridgev2/database/publicmedia.go b/mautrix-patched/bridgev2/database/publicmedia.go deleted file mode 100644 index b667399c..00000000 --- a/mautrix-patched/bridgev2/database/publicmedia.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - "time" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/id" -) - -type PublicMediaQuery struct { - BridgeID networkid.BridgeID - *dbutil.QueryHelper[*PublicMedia] -} - -type PublicMedia struct { - BridgeID networkid.BridgeID - PublicID string - MXC id.ContentURI - Keys *attachment.EncryptedFile - MimeType string - Expiry time.Time -} - -const ( - upsertPublicMediaQuery = ` - INSERT INTO public_media (bridge_id, public_id, mxc, keys, mimetype, expiry) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (bridge_id, public_id) DO UPDATE SET expiry=EXCLUDED.expiry - ` - getPublicMediaQuery = ` - SELECT bridge_id, public_id, mxc, keys, mimetype, expiry - FROM public_media WHERE bridge_id=$1 AND public_id=$2 - ` -) - -func (pmq *PublicMediaQuery) Put(ctx context.Context, pm *PublicMedia) error { - ensureBridgeIDMatches(&pm.BridgeID, pmq.BridgeID) - return pmq.Exec(ctx, upsertPublicMediaQuery, pm.sqlVariables()...) -} - -func (pmq *PublicMediaQuery) Get(ctx context.Context, publicID string) (*PublicMedia, error) { - return pmq.QueryOne(ctx, getPublicMediaQuery, pmq.BridgeID, publicID) -} - -func (pm *PublicMedia) Scan(row dbutil.Scannable) (*PublicMedia, error) { - var expiry sql.NullInt64 - var mimetype sql.NullString - err := row.Scan(&pm.BridgeID, &pm.PublicID, &pm.MXC, dbutil.JSON{Data: &pm.Keys}, &mimetype, &expiry) - if err != nil { - return nil, err - } - if expiry.Valid { - pm.Expiry = time.Unix(0, expiry.Int64) - } - pm.MimeType = mimetype.String - return pm, nil -} - -func (pm *PublicMedia) sqlVariables() []any { - return []any{pm.BridgeID, pm.PublicID, &pm.MXC, dbutil.JSONPtr(pm.Keys), dbutil.StrPtr(pm.MimeType), dbutil.ConvertedPtr(pm.Expiry, time.Time.UnixNano)} -} diff --git a/mautrix-patched/bridgev2/database/reaction.go b/mautrix-patched/bridgev2/database/reaction.go deleted file mode 100644 index b65a5c38..00000000 --- a/mautrix-patched/bridgev2/database/reaction.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "time" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" -) - -type ReactionQuery struct { - BridgeID networkid.BridgeID - MetaType MetaTypeCreator - *dbutil.QueryHelper[*Reaction] -} - -type Reaction struct { - BridgeID networkid.BridgeID - Room networkid.PortalKey - MessageID networkid.MessageID - MessagePartID networkid.PartID - SenderID networkid.UserID - SenderMXID id.UserID - EmojiID networkid.EmojiID - MXID id.EventID - - Timestamp time.Time - Emoji string - Metadata any -} - -const ( - getReactionBaseQuery = ` - SELECT bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, emoji, room_id, room_receiver, mxid, timestamp, metadata FROM reaction - ` - getReactionByIDQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4 AND sender_id=$5 AND emoji_id=$6` - getReactionByIDWithoutMessagePartQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND sender_id=$4 AND emoji_id=$5 ORDER BY message_part_id ASC LIMIT 1` - getAllReactionsToMessageBySenderQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND sender_id=$4 ORDER BY timestamp DESC` - getAllReactionsToMessageQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3` - getAllReactionsToMessagePartQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4` - getReactionByMXIDQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` - upsertReactionQuery = ` - INSERT INTO reaction (bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, emoji, room_id, room_receiver, mxid, timestamp, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id) - DO UPDATE SET sender_mxid=excluded.sender_mxid, mxid=excluded.mxid, timestamp=excluded.timestamp, emoji=excluded.emoji, metadata=excluded.metadata - ` - deleteReactionQuery = ` - DELETE FROM reaction WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4 AND sender_id=$5 AND emoji_id=$6 - ` -) - -func (rq *ReactionQuery) GetByID(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, messagePartID networkid.PartID, senderID networkid.UserID, emojiID networkid.EmojiID) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionByIDQuery, rq.BridgeID, receiver, messageID, messagePartID, senderID, emojiID) -} - -func (rq *ReactionQuery) GetByIDWithoutMessagePart(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, senderID networkid.UserID, emojiID networkid.EmojiID) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionByIDWithoutMessagePartQuery, rq.BridgeID, receiver, messageID, senderID, emojiID) -} - -func (rq *ReactionQuery) GetAllToMessageBySender(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, senderID networkid.UserID) ([]*Reaction, error) { - return rq.QueryMany(ctx, getAllReactionsToMessageBySenderQuery, rq.BridgeID, receiver, messageID, senderID) -} - -func (rq *ReactionQuery) GetAllToMessage(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID) ([]*Reaction, error) { - return rq.QueryMany(ctx, getAllReactionsToMessageQuery, rq.BridgeID, receiver, messageID) -} - -func (rq *ReactionQuery) GetAllToMessagePart(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, partID networkid.PartID) ([]*Reaction, error) { - return rq.QueryMany(ctx, getAllReactionsToMessagePartQuery, rq.BridgeID, receiver, messageID, partID) -} - -func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionByMXIDQuery, rq.BridgeID, mxid) -} - -func (rq *ReactionQuery) Upsert(ctx context.Context, reaction *Reaction) error { - ensureBridgeIDMatches(&reaction.BridgeID, rq.BridgeID) - return rq.Exec(ctx, upsertReactionQuery, reaction.ensureHasMetadata(rq.MetaType).sqlVariables()...) -} - -func (rq *ReactionQuery) Delete(ctx context.Context, reaction *Reaction) error { - ensureBridgeIDMatches(&reaction.BridgeID, rq.BridgeID) - return rq.Exec(ctx, deleteReactionQuery, reaction.BridgeID, reaction.Room.Receiver, reaction.MessageID, reaction.MessagePartID, reaction.SenderID, reaction.EmojiID) -} - -func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) { - var timestamp int64 - err := row.Scan( - &r.BridgeID, &r.MessageID, &r.MessagePartID, &r.SenderID, &r.SenderMXID, &r.EmojiID, &r.Emoji, - &r.Room.ID, &r.Room.Receiver, &r.MXID, ×tamp, dbutil.JSON{Data: r.Metadata}, - ) - if err != nil { - return nil, err - } - r.Timestamp = time.Unix(0, timestamp) - return r, nil -} - -func (r *Reaction) ensureHasMetadata(metaType MetaTypeCreator) *Reaction { - if r.Metadata == nil { - r.Metadata = metaType() - } - return r -} - -func (r *Reaction) sqlVariables() []any { - return []any{ - r.BridgeID, r.MessageID, r.MessagePartID, r.SenderID, r.SenderMXID, r.EmojiID, r.Emoji, - r.Room.ID, r.Room.Receiver, r.MXID, r.Timestamp.UnixNano(), dbutil.JSON{Data: r.Metadata}, - } -} diff --git a/mautrix-patched/bridgev2/database/upgrades/00-latest.sql b/mautrix-patched/bridgev2/database/upgrades/00-latest.sql deleted file mode 100644 index e169be87..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/00-latest.sql +++ /dev/null @@ -1,234 +0,0 @@ --- v0 -> v29 (compatible with v9+): Latest revision -CREATE TABLE "user" ( - bridge_id TEXT NOT NULL, - mxid TEXT NOT NULL, - - management_room TEXT, - access_token TEXT, - - PRIMARY KEY (bridge_id, mxid) -); - -CREATE TABLE user_login ( - bridge_id TEXT NOT NULL, - user_mxid TEXT NOT NULL, - id TEXT NOT NULL, - remote_name TEXT NOT NULL, - remote_profile jsonb, - space_room TEXT, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, id), - CONSTRAINT user_login_user_fkey FOREIGN KEY (bridge_id, user_mxid) - REFERENCES "user" (bridge_id, mxid) - ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE TABLE portal ( - bridge_id TEXT NOT NULL, - id TEXT NOT NULL, - receiver TEXT NOT NULL, - mxid TEXT, - - parent_id TEXT, - parent_receiver TEXT NOT NULL DEFAULT '', - - relay_bridge_id TEXT, - relay_login_id TEXT, - - other_user_id TEXT, - - name TEXT NOT NULL, - topic TEXT NOT NULL, - avatar_id TEXT NOT NULL, - avatar_hash TEXT NOT NULL, - avatar_mxc TEXT NOT NULL, - name_set BOOLEAN NOT NULL, - avatar_set BOOLEAN NOT NULL, - topic_set BOOLEAN NOT NULL, - name_is_custom BOOLEAN NOT NULL DEFAULT false, - in_space BOOLEAN NOT NULL, - message_request BOOLEAN NOT NULL DEFAULT false, - room_type TEXT NOT NULL, - disappear_type TEXT, - disappear_timer BIGINT, - cap_state jsonb, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, id, receiver), - CONSTRAINT portal_parent_fkey FOREIGN KEY (bridge_id, parent_id, parent_receiver) - -- Deletes aren't allowed to cascade here: - -- children should be re-parented or cleaned up manually - REFERENCES portal (bridge_id, id, receiver) ON UPDATE CASCADE, - CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id) - REFERENCES user_login (bridge_id, id) - ON DELETE SET NULL ON UPDATE CASCADE -); -CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); -CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver); - -CREATE TABLE ghost ( - bridge_id TEXT NOT NULL, - id TEXT NOT NULL, - - name TEXT NOT NULL, - avatar_id TEXT NOT NULL, - avatar_hash TEXT NOT NULL, - avatar_mxc TEXT NOT NULL, - name_set BOOLEAN NOT NULL, - avatar_set BOOLEAN NOT NULL, - contact_info_set BOOLEAN NOT NULL, - is_bot BOOLEAN NOT NULL, - identifiers jsonb NOT NULL, - extra_profile jsonb, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, id) -); - -CREATE TABLE message ( - -- Messages have an extra rowid to allow a single relates_to column with ON DELETE SET NULL - -- If the foreign key used (bridge_id, relates_to), then deleting the target column - -- would try to set bridge_id to null as well. - - -- only: sqlite (line commented) --- rowid INTEGER PRIMARY KEY, - -- only: postgres - rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - - bridge_id TEXT NOT NULL, - id TEXT NOT NULL, - part_id TEXT NOT NULL, - mxid TEXT NOT NULL, - - room_id TEXT NOT NULL, - room_receiver TEXT NOT NULL, - sender_id TEXT NOT NULL, - sender_mxid TEXT NOT NULL, - timestamp BIGINT NOT NULL, - edit_count INTEGER NOT NULL, - double_puppeted BOOLEAN, - thread_root_id TEXT, - reply_to_id TEXT, - reply_to_part_id TEXT, - send_txn_id TEXT, - metadata jsonb NOT NULL, - - CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id) - REFERENCES ghost (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id), - CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid), - CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id) -); -CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver); - -CREATE TABLE disappearing_message ( - bridge_id TEXT NOT NULL, - mx_room TEXT NOT NULL, - mxid TEXT NOT NULL, - timestamp BIGINT NOT NULL DEFAULT 0, - type TEXT NOT NULL, - timer BIGINT NOT NULL, - disappear_at BIGINT, - - PRIMARY KEY (bridge_id, mxid), - CONSTRAINT disappearing_message_portal_fkey - FOREIGN KEY (bridge_id, mx_room) - REFERENCES portal (bridge_id, mxid) - ON DELETE CASCADE -); -CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room); - -CREATE TABLE reaction ( - bridge_id TEXT NOT NULL, - message_id TEXT NOT NULL, - message_part_id TEXT NOT NULL, - sender_id TEXT NOT NULL, - sender_mxid TEXT NOT NULL DEFAULT '', - emoji_id TEXT NOT NULL, - room_id TEXT NOT NULL, - room_receiver TEXT NOT NULL, - mxid TEXT NOT NULL, - - timestamp BIGINT NOT NULL, - emoji TEXT NOT NULL, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id), - CONSTRAINT reaction_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id) - REFERENCES message (bridge_id, room_receiver, id, part_id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_sender_fkey FOREIGN KEY (bridge_id, sender_id) - REFERENCES ghost (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_mxid_unique UNIQUE (bridge_id, mxid) -); -CREATE INDEX reaction_room_idx ON reaction (bridge_id, room_id, room_receiver); - -CREATE TABLE user_portal ( - bridge_id TEXT NOT NULL, - user_mxid TEXT NOT NULL, - login_id TEXT NOT NULL, - portal_id TEXT NOT NULL, - portal_receiver TEXT NOT NULL, - in_space BOOLEAN NOT NULL, - preferred BOOLEAN NOT NULL, - last_read BIGINT, - - PRIMARY KEY (bridge_id, user_mxid, login_id, portal_id, portal_receiver), - CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id) - REFERENCES user_login (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT user_portal_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE -); -CREATE INDEX user_portal_login_idx ON user_portal (bridge_id, login_id); -CREATE INDEX user_portal_portal_idx ON user_portal (bridge_id, portal_id, portal_receiver); - -CREATE TABLE backfill_task ( - bridge_id TEXT NOT NULL, - portal_id TEXT NOT NULL, - portal_receiver TEXT NOT NULL, - user_login_id TEXT NOT NULL, - - batch_count INTEGER NOT NULL, - is_done BOOLEAN NOT NULL, - queue_done BOOLEAN NOT NULL DEFAULT false, - cursor TEXT, - oldest_message_id TEXT, - dispatched_at BIGINT, - completed_at BIGINT, - next_dispatch_min_ts BIGINT NOT NULL, - - PRIMARY KEY (bridge_id, portal_id, portal_receiver), - CONSTRAINT backfill_queue_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE TABLE kv_store ( - bridge_id TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - - PRIMARY KEY (bridge_id, key) -); - -CREATE TABLE public_media ( - bridge_id TEXT NOT NULL, - public_id TEXT NOT NULL, - mxc TEXT NOT NULL, - keys jsonb, - mimetype TEXT, - expiry BIGINT, - - PRIMARY KEY (bridge_id, public_id) -); diff --git a/mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql b/mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql deleted file mode 100644 index e1425e75..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/02-disappearing-messages.sql +++ /dev/null @@ -1,11 +0,0 @@ --- v2 (compatible with v1+): Add disappearing messages table -CREATE TABLE disappearing_message ( - bridge_id TEXT NOT NULL, - mx_room TEXT NOT NULL, - mxid TEXT NOT NULL, - type TEXT NOT NULL, - timer BIGINT NOT NULL, - disappear_at BIGINT, - - PRIMARY KEY (bridge_id, mxid) -); diff --git a/mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql b/mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql deleted file mode 100644 index 4ea52ac6..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/03-portal-relay-postgres.sql +++ /dev/null @@ -1,13 +0,0 @@ --- v3 (compatible with v1+): Add relay column for portals (Postgres) --- only: postgres -ALTER TABLE portal ADD COLUMN relay_bridge_id TEXT; -ALTER TABLE portal ADD COLUMN relay_login_id TEXT; -ALTER TABLE user_portal DROP CONSTRAINT user_portal_user_login_fkey; -ALTER TABLE user_login DROP CONSTRAINT user_login_pkey; -ALTER TABLE user_login ADD CONSTRAINT user_login_pkey PRIMARY KEY (bridge_id, id); -ALTER TABLE user_portal ADD CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id) - REFERENCES user_login (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE portal ADD CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id) - REFERENCES user_login (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql deleted file mode 100644 index 04385958..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/04-portal-relay-sqlite.sql +++ /dev/null @@ -1,100 +0,0 @@ --- v4 (compatible with v1+): Add relay column for portals (SQLite) --- transaction: off --- only: sqlite - -PRAGMA foreign_keys = OFF; -BEGIN; - -CREATE TABLE user_login_new ( - bridge_id TEXT NOT NULL, - user_mxid TEXT NOT NULL, - id TEXT NOT NULL, - space_room TEXT, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, id), - CONSTRAINT user_login_user_fkey FOREIGN KEY (bridge_id, user_mxid) - REFERENCES "user" (bridge_id, mxid) - ON DELETE CASCADE ON UPDATE CASCADE -); - -INSERT INTO user_login_new -SELECT bridge_id, user_mxid, id, space_room, metadata -FROM user_login; - -DROP TABLE user_login; -ALTER TABLE user_login_new RENAME TO user_login; - - -CREATE TABLE user_portal_new ( - bridge_id TEXT NOT NULL, - user_mxid TEXT NOT NULL, - login_id TEXT NOT NULL, - portal_id TEXT NOT NULL, - portal_receiver TEXT NOT NULL, - in_space BOOLEAN NOT NULL, - preferred BOOLEAN NOT NULL, - last_read BIGINT, - - PRIMARY KEY (bridge_id, user_mxid, login_id, portal_id, portal_receiver), - CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id) - REFERENCES user_login (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT user_portal_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE -); - -INSERT INTO user_portal_new -SELECT bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read -FROM user_portal; - -DROP TABLE user_portal; -ALTER TABLE user_portal_new RENAME TO user_portal; - -CREATE TABLE portal_new ( - bridge_id TEXT NOT NULL, - id TEXT NOT NULL, - receiver TEXT NOT NULL, - mxid TEXT, - - parent_id TEXT, - -- This is not accessed by the bridge, it's only used for the portal parent foreign key. - -- Parent groups are probably never DMs, so they don't need a receiver. - parent_receiver TEXT NOT NULL DEFAULT '', - - relay_bridge_id TEXT, - relay_login_id TEXT, - - name TEXT NOT NULL, - topic TEXT NOT NULL, - avatar_id TEXT NOT NULL, - avatar_hash TEXT NOT NULL, - avatar_mxc TEXT NOT NULL, - name_set BOOLEAN NOT NULL, - avatar_set BOOLEAN NOT NULL, - topic_set BOOLEAN NOT NULL, - in_space BOOLEAN NOT NULL, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, id, receiver), - CONSTRAINT portal_parent_fkey FOREIGN KEY (bridge_id, parent_id, parent_receiver) - -- Deletes aren't allowed to cascade here: - -- children should be re-parented or cleaned up manually - REFERENCES portal (bridge_id, id, receiver) ON UPDATE CASCADE, - CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id) - REFERENCES user_login (bridge_id, id) - ON DELETE SET NULL ON UPDATE CASCADE -); - -INSERT INTO portal_new -SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, NULL, NULL, - name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, in_space, metadata -FROM portal; - -DROP TABLE portal; -ALTER TABLE portal_new RENAME TO portal; - -PRAGMA foreign_key_check; -COMMIT; -PRAGMA foreign_keys = ON; diff --git a/mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql b/mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql deleted file mode 100644 index 1cdbcccf..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/05-message-receiver-pkey-postgres.sql +++ /dev/null @@ -1,10 +0,0 @@ --- v5 (compatible with v1+): Add room_receiver to message unique key (Postgres) --- only: postgres -ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; -ALTER TABLE reaction DROP CONSTRAINT reaction_pkey1; -ALTER TABLE reaction ADD PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id); -ALTER TABLE message DROP CONSTRAINT message_real_pkey; -ALTER TABLE message ADD CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id); -ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id) - REFERENCES message (bridge_id, room_receiver, id, part_id) - ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql deleted file mode 100644 index b88c5052..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/06-message-receiver-pkey-sqlite.sql +++ /dev/null @@ -1,75 +0,0 @@ --- v6 (compatible with v1+): Add room_receiver to message unique key (SQLite) --- transaction: off --- only: sqlite - -PRAGMA foreign_keys = OFF; -BEGIN; - -CREATE TABLE message_new ( - rowid INTEGER PRIMARY KEY, - - bridge_id TEXT NOT NULL, - id TEXT NOT NULL, - part_id TEXT NOT NULL, - mxid TEXT NOT NULL, - - room_id TEXT NOT NULL, - room_receiver TEXT NOT NULL, - sender_id TEXT NOT NULL, - timestamp BIGINT NOT NULL, - relates_to BIGINT, - metadata jsonb NOT NULL, - - CONSTRAINT message_relation_fkey FOREIGN KEY (relates_to) - REFERENCES message (rowid) ON DELETE SET NULL, - CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id) - REFERENCES ghost (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id) -); - -INSERT INTO message_new (rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, relates_to, metadata) -SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, relates_to, metadata -FROM message; - -DROP TABLE message; -ALTER TABLE message_new RENAME TO message; - -CREATE TABLE reaction_new ( - bridge_id TEXT NOT NULL, - message_id TEXT NOT NULL, - message_part_id TEXT NOT NULL, - sender_id TEXT NOT NULL, - emoji_id TEXT NOT NULL, - room_id TEXT NOT NULL, - room_receiver TEXT NOT NULL, - mxid TEXT NOT NULL, - - timestamp BIGINT NOT NULL, - metadata jsonb NOT NULL, - - PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id), - CONSTRAINT reaction_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id) - REFERENCES message (bridge_id, room_receiver, id, part_id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_sender_fkey FOREIGN KEY (bridge_id, sender_id) - REFERENCES ghost (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE -); - -INSERT INTO reaction_new -SELECT bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, metadata -FROM reaction; - -DROP TABLE reaction; -ALTER TABLE reaction_new RENAME TO reaction; - -PRAGMA foreign_key_check; -COMMIT; -PRAGMA foreign_keys = ON; diff --git a/mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql b/mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql deleted file mode 100644 index 9c4c9fd5..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/07-message-relation-without-fkey.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v7: Add new relation columns to messages -ALTER TABLE message ADD COLUMN thread_root_id TEXT; -ALTER TABLE message ADD COLUMN reply_to_id TEXT; -ALTER TABLE message ADD COLUMN reply_to_part_id TEXT; diff --git a/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql b/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql deleted file mode 100644 index 284f6b0e..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.postgres.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v8: Drop relates_to column in messages --- transaction: off -ALTER TABLE message DROP COLUMN relates_to; diff --git a/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql deleted file mode 100644 index 307a876e..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/08-drop-message-relates-to.sqlite.sql +++ /dev/null @@ -1,41 +0,0 @@ --- v8: Drop relates_to column in messages --- transaction: off -PRAGMA foreign_keys = OFF; -BEGIN; - -CREATE TABLE message_new ( - rowid INTEGER PRIMARY KEY, - - bridge_id TEXT NOT NULL, - id TEXT NOT NULL, - part_id TEXT NOT NULL, - mxid TEXT NOT NULL, - - room_id TEXT NOT NULL, - room_receiver TEXT NOT NULL, - sender_id TEXT NOT NULL, - timestamp BIGINT NOT NULL, - thread_root_id TEXT, - reply_to_id TEXT, - reply_to_part_id TEXT, - metadata jsonb NOT NULL, - - CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id) - REFERENCES ghost (bridge_id, id) - ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id) -); - -INSERT INTO message_new (rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, metadata) -SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, metadata -FROM message; - -DROP TABLE message; -ALTER TABLE message_new RENAME TO message; - -PRAGMA foreign_key_check; -COMMIT; -PRAGMA foreign_keys = ON; diff --git a/mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql b/mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql deleted file mode 100644 index 3f348007..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/09-remove-standard-metadata.sql +++ /dev/null @@ -1,45 +0,0 @@ --- v9: Move standard metadata to separate columns -ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT ''; -UPDATE message SET sender_mxid=COALESCE((metadata->>'sender_mxid'), ''); - -ALTER TABLE message ADD COLUMN edit_count INTEGER NOT NULL DEFAULT 0; -UPDATE message SET edit_count=COALESCE(CAST((metadata->>'edit_count') AS INTEGER), 0); - -ALTER TABLE portal ADD COLUMN disappear_type TEXT; -UPDATE portal SET disappear_type=(metadata->>'disappear_type'); - -ALTER TABLE portal ADD COLUMN disappear_timer BIGINT; --- only: postgres -UPDATE portal SET disappear_timer=(metadata->>'disappear_timer')::BIGINT; --- only: sqlite -UPDATE portal SET disappear_timer=CAST(metadata->>'disappear_timer' AS INTEGER); - -ALTER TABLE portal ADD COLUMN room_type TEXT NOT NULL DEFAULT ''; -UPDATE portal SET room_type='dm' WHERE CAST(metadata->>'is_direct' AS BOOLEAN) IS true; -UPDATE portal SET room_type='space' WHERE CAST(metadata->>'is_space' AS BOOLEAN) IS true; - -ALTER TABLE reaction ADD COLUMN emoji TEXT NOT NULL DEFAULT ''; -UPDATE reaction SET emoji=COALESCE((metadata->>'emoji'), ''); - -ALTER TABLE user_login ADD COLUMN remote_name TEXT NOT NULL DEFAULT ''; -UPDATE user_login SET remote_name=COALESCE((metadata->>'remote_name'), ''); - -ALTER TABLE ghost ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false; -UPDATE ghost SET contact_info_set=COALESCE(CAST((metadata->>'contact_info_set') AS BOOLEAN), false); - -ALTER TABLE ghost ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false; -UPDATE ghost SET is_bot=COALESCE(CAST((metadata->>'is_bot') AS BOOLEAN), false); - -ALTER TABLE ghost ADD COLUMN identifiers jsonb NOT NULL DEFAULT '[]'; -UPDATE ghost SET identifiers=COALESCE((metadata->'identifiers'), '[]'); - --- only: postgres until "end only" -ALTER TABLE message ALTER COLUMN sender_mxid DROP DEFAULT; -ALTER TABLE message ALTER COLUMN edit_count DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN room_type DROP DEFAULT; -ALTER TABLE reaction ALTER COLUMN emoji DROP DEFAULT; -ALTER TABLE user_login ALTER COLUMN remote_name DROP DEFAULT; -ALTER TABLE ghost ALTER COLUMN contact_info_set DROP DEFAULT; -ALTER TABLE ghost ALTER COLUMN is_bot DROP DEFAULT; -ALTER TABLE ghost ALTER COLUMN identifiers DROP DEFAULT; --- end only postgres diff --git a/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql b/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql deleted file mode 100644 index f42402f3..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.postgres.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v10 (compatible with v9+): Fix Signal portal revisions -UPDATE portal -SET metadata=jsonb_set(metadata, '{revision}', CAST((metadata->>'revision') AS jsonb)) -WHERE jsonb_typeof(metadata->'revision')='string'; diff --git a/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql deleted file mode 100644 index 0fd67c80..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/10-fix-signal-portal-revision.sqlite.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v10 (compatible with v9+): Fix Signal portal revisions -UPDATE portal -SET metadata=json_set(metadata, '$.revision', CAST(json_extract(metadata, '$.revision') AS INTEGER)) -WHERE json_type(metadata, '$.revision')='text'; diff --git a/mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql b/mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql deleted file mode 100644 index d6a67713..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/11-room-fkey-idx.sql +++ /dev/null @@ -1,5 +0,0 @@ --- v11: Add indexes for some foreign keys -CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver); -CREATE INDEX reaction_room_idx ON reaction (bridge_id, room_id, room_receiver); -CREATE INDEX user_portal_portal_idx ON user_portal (bridge_id, portal_id, portal_receiver); -CREATE INDEX user_portal_login_idx ON user_portal (bridge_id, login_id); diff --git a/mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql b/mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql deleted file mode 100644 index 2d2cb900..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/12-dm-portal-other-user.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v12 (compatible with v9+): Save other user ID in DM portals -ALTER TABLE portal ADD COLUMN other_user_id TEXT; diff --git a/mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql b/mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql deleted file mode 100644 index dada993c..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/13-backfill-queue.sql +++ /dev/null @@ -1,20 +0,0 @@ --- v13 (compatible with v9+): Add backfill queue -CREATE TABLE backfill_task ( - bridge_id TEXT NOT NULL, - portal_id TEXT NOT NULL, - portal_receiver TEXT NOT NULL, - user_login_id TEXT NOT NULL, - - batch_count INTEGER NOT NULL, - is_done BOOLEAN NOT NULL, - cursor TEXT, - oldest_message_id TEXT, - dispatched_at BIGINT, - completed_at BIGINT, - next_dispatch_min_ts BIGINT NOT NULL, - - PRIMARY KEY (bridge_id, portal_id, portal_receiver), - CONSTRAINT backfill_queue_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver) - REFERENCES portal (bridge_id, id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE -); diff --git a/mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql b/mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql deleted file mode 100644 index 2c8dfc8f..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/14-portal-name-custom.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v14 (compatible with v9+): Save whether name is custom in portals -ALTER TABLE portal ADD COLUMN name_is_custom BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql b/mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql deleted file mode 100644 index e32bd832..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/15-reaction-sender-mxid.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v15 (compatible with v9+): Save sender MXID for reactions -ALTER TABLE reaction ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT ''; diff --git a/mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql b/mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql deleted file mode 100644 index e143fcee..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/16-user-login-profile.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v16 (compatible with v9+): Save remote profile in user logins -ALTER TABLE user_login ADD COLUMN remote_profile jsonb; diff --git a/mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql b/mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql deleted file mode 100644 index ee53b3f0..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/17-message-mxid-unique.sql +++ /dev/null @@ -1,8 +0,0 @@ --- v17 (compatible with v9+): Add unique constraint for message and reaction mxids -DELETE FROM message WHERE mxid IN (SELECT mxid FROM message GROUP BY mxid HAVING COUNT(*) > 1); --- only: postgres for next 2 lines -ALTER TABLE message ADD CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid); -ALTER TABLE reaction ADD CONSTRAINT reaction_mxid_unique UNIQUE (bridge_id, mxid); --- only: sqlite for next 2 lines -CREATE UNIQUE INDEX message_mxid_unique ON message (bridge_id, mxid); -CREATE UNIQUE INDEX reaction_mxid_unique ON reaction (bridge_id, mxid); diff --git a/mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql b/mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql deleted file mode 100644 index 9d233095..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/18-kv-store.sql +++ /dev/null @@ -1,8 +0,0 @@ --- v18 (compatible with v9+): Add generic key-value store -CREATE TABLE kv_store ( - bridge_id TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - - PRIMARY KEY (bridge_id, key) -); diff --git a/mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql b/mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql deleted file mode 100644 index ec6fe836..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/19-add-double-puppeted-to-message.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v19 (compatible with v9+): Add double puppeted state to messages -ALTER TABLE message ADD COLUMN double_puppeted BOOLEAN; diff --git a/mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql b/mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql deleted file mode 100644 index 00bd96ca..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/20-portal-capabilities.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v20 (compatible with v9+): Add portal capability state -ALTER TABLE portal ADD COLUMN cap_state jsonb; diff --git a/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql b/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql deleted file mode 100644 index d1c1ad9a..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.postgres.sql +++ /dev/null @@ -1,8 +0,0 @@ --- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid -CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); -DELETE FROM disappearing_message WHERE mx_room NOT IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL); -ALTER TABLE disappearing_message - ADD CONSTRAINT disappearing_message_portal_fkey - FOREIGN KEY (bridge_id, mx_room) - REFERENCES portal (bridge_id, mxid) - ON DELETE CASCADE; diff --git a/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql b/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql deleted file mode 100644 index f5468c6b..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/21-disappearing-message-fkey.sqlite.sql +++ /dev/null @@ -1,24 +0,0 @@ --- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid -CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid); -CREATE TABLE disappearing_message_new ( - bridge_id TEXT NOT NULL, - mx_room TEXT NOT NULL, - mxid TEXT NOT NULL, - type TEXT NOT NULL, - timer BIGINT NOT NULL, - disappear_at BIGINT, - - PRIMARY KEY (bridge_id, mxid), - CONSTRAINT disappearing_message_portal_fkey - FOREIGN KEY (bridge_id, mx_room) - REFERENCES portal (bridge_id, mxid) - ON DELETE CASCADE -); - -WITH portal_mxids AS (SELECT mxid FROM portal WHERE mxid IS NOT NULL) -INSERT INTO disappearing_message_new (bridge_id, mx_room, mxid, type, timer, disappear_at) -SELECT bridge_id, mx_room, mxid, type, timer, disappear_at -FROM disappearing_message WHERE mx_room IN portal_mxids; - -DROP TABLE disappearing_message; -ALTER TABLE disappearing_message_new RENAME TO disappearing_message; diff --git a/mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql b/mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql deleted file mode 100644 index 8933984e..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/22-message-send-txn-id.sql +++ /dev/null @@ -1,6 +0,0 @@ --- v22 (compatible with v9+): Add message send transaction ID column -ALTER TABLE message ADD COLUMN send_txn_id TEXT; --- only: postgres -ALTER TABLE message ADD CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id); --- only: sqlite -CREATE UNIQUE INDEX message_txn_id_unique ON message (bridge_id, room_receiver, send_txn_id); diff --git a/mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql b/mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql deleted file mode 100644 index ecd00b8d..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/23-disappearing-timer-ts.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v23 (compatible with v9+): Add event timestamp for disappearing messages -ALTER TABLE disappearing_message ADD COLUMN timestamp BIGINT NOT NULL DEFAULT 0; diff --git a/mautrix-patched/bridgev2/database/upgrades/24-public-media.sql b/mautrix-patched/bridgev2/database/upgrades/24-public-media.sql deleted file mode 100644 index c4290090..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/24-public-media.sql +++ /dev/null @@ -1,11 +0,0 @@ --- v24 (compatible with v9+): Custom URLs for public media -CREATE TABLE public_media ( - bridge_id TEXT NOT NULL, - public_id TEXT NOT NULL, - mxc TEXT NOT NULL, - keys jsonb, - mimetype TEXT, - expiry BIGINT, - - PRIMARY KEY (bridge_id, public_id) -); diff --git a/mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql b/mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql deleted file mode 100644 index b9d82a7a..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/25-message-requests.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v25 (compatible with v9+): Flag for message request portals -ALTER TABLE portal ADD COLUMN message_request BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql b/mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql deleted file mode 100644 index ae5d8cad..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/26-disappearing-message-portal-index.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v26 (compatible with v9+): Add room index for disappearing message table and portal parents -CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room); -CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver); diff --git a/mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql b/mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql deleted file mode 100644 index e8e0549a..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/27-ghost-extra-profile.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v27 (compatible with v9+): Add column for extra ghost profile metadata -ALTER TABLE ghost ADD COLUMN extra_profile jsonb; diff --git a/mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql b/mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql deleted file mode 100644 index c21e15ea..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/28-backfill-queue-done-flag.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v28 (compatible with v9+): Add separate queue_done flag for backfill queue -ALTER TABLE backfill_task ADD COLUMN queue_done BOOLEAN NOT NULL DEFAULT false; -UPDATE backfill_task SET queue_done=true WHERE is_done=true; diff --git a/mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql b/mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql deleted file mode 100644 index acb4c738..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/29-clear-incorrect-personal-filtering-space.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v29 (compatible with v9+): Clear incorrect personal filtering space values -UPDATE user_login SET space_room = NULL WHERE space_room LIKE '!management:%'; diff --git a/mautrix-patched/bridgev2/database/upgrades/upgrades.go b/mautrix-patched/bridgev2/database/upgrades/upgrades.go deleted file mode 100644 index 4fef472e..00000000 --- a/mautrix-patched/bridgev2/database/upgrades/upgrades.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package upgrades - -import ( - "embed" - - "go.mau.fi/util/dbutil" -) - -var Table dbutil.UpgradeTable - -//go:embed *.sql -var rawUpgrades embed.FS - -func init() { - Table.RegisterFS(rawUpgrades) -} diff --git a/mautrix-patched/bridgev2/database/user.go b/mautrix-patched/bridgev2/database/user.go deleted file mode 100644 index 00eae7ca..00000000 --- a/mautrix-patched/bridgev2/database/user.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" -) - -type UserQuery struct { - BridgeID networkid.BridgeID - *dbutil.QueryHelper[*User] -} - -type User struct { - BridgeID networkid.BridgeID - MXID id.UserID - - ManagementRoom id.RoomID - AccessToken string -} - -const ( - getUserBaseQuery = ` - SELECT bridge_id, mxid, management_room, access_token FROM "user" - ` - getUserByMXIDQuery = getUserBaseQuery + `WHERE bridge_id=$1 AND mxid=$2` - insertUserQuery = ` - INSERT INTO "user" (bridge_id, mxid, management_room, access_token) - VALUES ($1, $2, $3, $4) - ` - updateUserQuery = ` - UPDATE "user" SET management_room=$3, access_token=$4 - WHERE bridge_id=$1 AND mxid=$2 - ` -) - -func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) { - return uq.QueryOne(ctx, getUserByMXIDQuery, uq.BridgeID, userID) -} - -func (uq *UserQuery) Insert(ctx context.Context, user *User) error { - ensureBridgeIDMatches(&user.BridgeID, uq.BridgeID) - return uq.Exec(ctx, insertUserQuery, user.sqlVariables()...) -} - -func (uq *UserQuery) Update(ctx context.Context, user *User) error { - ensureBridgeIDMatches(&user.BridgeID, uq.BridgeID) - return uq.Exec(ctx, updateUserQuery, user.sqlVariables()...) -} - -func (u *User) Scan(row dbutil.Scannable) (*User, error) { - var managementRoom, accessToken sql.NullString - err := row.Scan(&u.BridgeID, &u.MXID, &managementRoom, &accessToken) - if err != nil { - return nil, err - } - u.ManagementRoom = id.RoomID(managementRoom.String) - u.AccessToken = accessToken.String - return u, nil -} - -func (u *User) sqlVariables() []any { - return []any{u.BridgeID, u.MXID, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.AccessToken)} -} diff --git a/mautrix-patched/bridgev2/database/userlogin.go b/mautrix-patched/bridgev2/database/userlogin.go deleted file mode 100644 index 00ff01c9..00000000 --- a/mautrix-patched/bridgev2/database/userlogin.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/id" -) - -type UserLoginQuery struct { - BridgeID networkid.BridgeID - MetaType MetaTypeCreator - *dbutil.QueryHelper[*UserLogin] -} - -type UserLogin struct { - BridgeID networkid.BridgeID - UserMXID id.UserID - ID networkid.UserLoginID - RemoteName string - RemoteProfile status.RemoteProfile - SpaceRoom id.RoomID - Metadata any -} - -const ( - getUserLoginBaseQuery = ` - SELECT bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata FROM user_login - ` - getLoginByIDQuery = getUserLoginBaseQuery + `WHERE bridge_id=$1 AND id=$2` - getAllUsersWithLoginsQuery = `SELECT DISTINCT user_mxid FROM user_login WHERE bridge_id=$1` - getAllLoginsForUserQuery = getUserLoginBaseQuery + `WHERE bridge_id=$1 AND user_mxid=$2` - getAllLoginsInPortalQuery = ` - SELECT ul.bridge_id, ul.user_mxid, ul.id, ul.remote_name, ul.remote_profile, ul.space_room, ul.metadata FROM user_portal - LEFT JOIN user_login ul ON user_portal.bridge_id=ul.bridge_id AND user_portal.user_mxid=ul.user_mxid AND user_portal.login_id=ul.id - WHERE user_portal.bridge_id=$1 AND user_portal.portal_id=$2 AND user_portal.portal_receiver=$3 - ` - insertUserLoginQuery = ` - INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ` - updateUserLoginQuery = ` - UPDATE user_login SET remote_name=$4, remote_profile=$5, space_room=$6, metadata=$7 - WHERE bridge_id=$1 AND user_mxid=$2 AND id=$3 - ` - deleteUserLoginQuery = ` - DELETE FROM user_login WHERE bridge_id=$1 AND id=$2 - ` -) - -func (uq *UserLoginQuery) GetByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) { - return uq.QueryOne(ctx, getLoginByIDQuery, uq.BridgeID, id) -} - -func (uq *UserLoginQuery) GetAllUserIDsWithLogins(ctx context.Context) ([]id.UserID, error) { - rows, err := uq.GetDB().Query(ctx, getAllUsersWithLoginsQuery, uq.BridgeID) - return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() -} - -func (uq *UserLoginQuery) GetAllInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserLogin, error) { - return uq.QueryMany(ctx, getAllLoginsInPortalQuery, uq.BridgeID, portal.ID, portal.Receiver) -} - -func (uq *UserLoginQuery) GetAllForUser(ctx context.Context, userID id.UserID) ([]*UserLogin, error) { - return uq.QueryMany(ctx, getAllLoginsForUserQuery, uq.BridgeID, userID) -} - -func (uq *UserLoginQuery) Insert(ctx context.Context, login *UserLogin) error { - ensureBridgeIDMatches(&login.BridgeID, uq.BridgeID) - return uq.Exec(ctx, insertUserLoginQuery, login.ensureHasMetadata(uq.MetaType).sqlVariables()...) -} - -func (uq *UserLoginQuery) Update(ctx context.Context, login *UserLogin) error { - ensureBridgeIDMatches(&login.BridgeID, uq.BridgeID) - return uq.Exec(ctx, updateUserLoginQuery, login.ensureHasMetadata(uq.MetaType).sqlVariables()...) -} - -func (uq *UserLoginQuery) Delete(ctx context.Context, loginID networkid.UserLoginID) error { - return uq.Exec(ctx, deleteUserLoginQuery, uq.BridgeID, loginID) -} - -func (u *UserLogin) Scan(row dbutil.Scannable) (*UserLogin, error) { - var spaceRoom sql.NullString - err := row.Scan( - &u.BridgeID, - &u.UserMXID, - &u.ID, - &u.RemoteName, - dbutil.JSON{Data: &u.RemoteProfile}, - &spaceRoom, - dbutil.JSON{Data: u.Metadata}, - ) - if err != nil { - return nil, err - } - u.SpaceRoom = id.RoomID(spaceRoom.String) - return u, nil -} - -func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin { - if u.Metadata == nil { - u.Metadata = metaType() - } - return u -} - -func (u *UserLogin) sqlVariables() []any { - var remoteProfile dbutil.JSON - if !u.RemoteProfile.IsZero() { - remoteProfile.Data = &u.RemoteProfile - } - return []any{u.BridgeID, u.UserMXID, u.ID, u.RemoteName, remoteProfile, dbutil.StrPtr(u.SpaceRoom), dbutil.JSON{Data: u.Metadata}} -} diff --git a/mautrix-patched/bridgev2/database/userportal.go b/mautrix-patched/bridgev2/database/userportal.go deleted file mode 100644 index e928a4c7..00000000 --- a/mautrix-patched/bridgev2/database/userportal.go +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package database - -import ( - "context" - "database/sql" - "time" - - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" -) - -type UserPortalQuery struct { - BridgeID networkid.BridgeID - *dbutil.QueryHelper[*UserPortal] -} - -type UserPortal struct { - BridgeID networkid.BridgeID - UserMXID id.UserID - LoginID networkid.UserLoginID - Portal networkid.PortalKey - InSpace *bool - Preferred *bool - LastRead time.Time -} - -const ( - getUserPortalBaseQuery = ` - SELECT bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read - FROM user_portal - ` - getUserPortalQuery = getUserPortalBaseQuery + ` - WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5 - ` - findUserLoginsOfUserByPortalIDQuery = getUserPortalBaseQuery + ` - WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$3 AND portal_receiver=$4 - ORDER BY CASE WHEN preferred THEN 0 ELSE 1 END, login_id - ` - getAllUserLoginsInPortalQuery = getUserPortalBaseQuery + ` - WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3 - ` - getAllPortalsForLoginQuery = getUserPortalBaseQuery + ` - WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 - ` - getOrCreateUserPortalQuery = ` - INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred) - VALUES ($1, $2, $3, $4, $5, false, false) - ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO UPDATE SET portal_id=user_portal.portal_id - RETURNING bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read - ` - upsertUserPortalQuery = ` - INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read) - VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), COALESCE($7, false), $8) - ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO UPDATE - SET in_space=COALESCE($6, user_portal.in_space), - preferred=COALESCE($7, user_portal.preferred), - last_read=COALESCE($8, user_portal.last_read) - ` - markLoginAsPreferredQuery = ` - UPDATE user_portal SET preferred=(login_id=$3) WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$4 AND portal_receiver=$5 - ` - markAllNotInSpaceQuery = ` - UPDATE user_portal SET in_space=false WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3 - ` - deleteUserPortalQuery = ` - DELETE FROM user_portal WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5 - ` -) - -func UserPortalFor(ul *UserLogin, portal networkid.PortalKey) *UserPortal { - return &UserPortal{ - BridgeID: ul.BridgeID, - UserMXID: ul.UserMXID, - LoginID: ul.ID, - Portal: portal, - } -} - -func (upq *UserPortalQuery) GetAllForUserInPortal(ctx context.Context, userID id.UserID, portal networkid.PortalKey) ([]*UserPortal, error) { - return upq.QueryMany(ctx, findUserLoginsOfUserByPortalIDQuery, upq.BridgeID, userID, portal.ID, portal.Receiver) -} - -func (upq *UserPortalQuery) GetAllForLogin(ctx context.Context, login *UserLogin) ([]*UserPortal, error) { - return upq.QueryMany(ctx, getAllPortalsForLoginQuery, upq.BridgeID, login.UserMXID, login.ID) -} - -func (upq *UserPortalQuery) GetAllInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserPortal, error) { - return upq.QueryMany(ctx, getAllUserLoginsInPortalQuery, upq.BridgeID, portal.ID, portal.Receiver) -} - -func (upq *UserPortalQuery) Get(ctx context.Context, login *UserLogin, portal networkid.PortalKey) (*UserPortal, error) { - return upq.QueryOne(ctx, getUserPortalQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) -} - -func (upq *UserPortalQuery) GetOrCreate(ctx context.Context, login *UserLogin, portal networkid.PortalKey) (*UserPortal, error) { - return upq.QueryOne(ctx, getOrCreateUserPortalQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) -} - -func (upq *UserPortalQuery) Put(ctx context.Context, up *UserPortal) error { - ensureBridgeIDMatches(&up.BridgeID, upq.BridgeID) - return upq.Exec(ctx, upsertUserPortalQuery, up.sqlVariables()...) -} - -func (upq *UserPortalQuery) MarkAsPreferred(ctx context.Context, login *UserLogin, portal networkid.PortalKey) error { - return upq.Exec(ctx, markLoginAsPreferredQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver) -} - -func (upq *UserPortalQuery) MarkAllNotInSpace(ctx context.Context, portal networkid.PortalKey) error { - return upq.Exec(ctx, markAllNotInSpaceQuery, upq.BridgeID, portal.ID, portal.Receiver) -} - -func (upq *UserPortalQuery) Delete(ctx context.Context, up *UserPortal) error { - return upq.Exec(ctx, deleteUserPortalQuery, up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver) -} - -func (up *UserPortal) Scan(row dbutil.Scannable) (*UserPortal, error) { - var lastRead sql.NullInt64 - err := row.Scan( - &up.BridgeID, &up.UserMXID, &up.LoginID, &up.Portal.ID, &up.Portal.Receiver, - &up.InSpace, &up.Preferred, &lastRead, - ) - if err != nil { - return nil, err - } - if lastRead.Valid { - up.LastRead = time.Unix(0, lastRead.Int64) - } - return up, nil -} - -func (up *UserPortal) sqlVariables() []any { - return []any{ - up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver, - up.InSpace, - up.Preferred, - dbutil.ConvertedPtr(up.LastRead, time.Time.UnixNano), - } -} - -func (up *UserPortal) CopyWithoutValues() *UserPortal { - return &UserPortal{ - BridgeID: up.BridgeID, - UserMXID: up.UserMXID, - LoginID: up.LoginID, - Portal: up.Portal, - } -} diff --git a/mautrix-patched/bridgev2/disappear.go b/mautrix-patched/bridgev2/disappear.go deleted file mode 100644 index b5c37e8f..00000000 --- a/mautrix-patched/bridgev2/disappear.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type DisappearLoop struct { - br *Bridge - nextCheck atomic.Pointer[time.Time] - stop atomic.Pointer[context.CancelFunc] -} - -const DisappearCheckInterval = 1 * time.Hour - -func (dl *DisappearLoop) Start() { - log := dl.br.Log.With().Str("component", "disappear loop").Logger() - ctx, stop := context.WithCancel(log.WithContext(context.Background())) - if oldStop := dl.stop.Swap(&stop); oldStop != nil { - (*oldStop)() - } - log.Debug().Msg("Disappearing message loop starting") - for { - nextCheck := time.Now().Add(DisappearCheckInterval) - dl.nextCheck.Store(&nextCheck) - const MessageLimit = 200 - messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval, MessageLimit) - if err != nil { - log.Err(err).Msg("Failed to get upcoming disappearing messages") - } else if len(messages) > 0 { - if len(messages) >= MessageLimit { - lastDisappearTime := messages[len(messages)-1].DisappearAt - log.Debug(). - Int("message_count", len(messages)). - Time("last_due", lastDisappearTime). - Msg("Deleting disappearing messages synchronously and checking again immediately") - // Store the expected next check time to avoid Add spawning unnecessary goroutines. - // This can be in the past, in which case Add will put everything in the db, which is also fine. - dl.nextCheck.Store(&lastDisappearTime) - // If there are many messages, process them synchronously and then check again. - dl.sleepAndDisappear(ctx, messages...) - continue - } - go dl.sleepAndDisappear(ctx, messages...) - } - select { - case <-time.After(time.Until(dl.GetNextCheck())): - case <-ctx.Done(): - log.Debug().Msg("Disappearing message loop stopping") - return - } - } -} - -func (dl *DisappearLoop) GetNextCheck() time.Time { - if dl == nil { - return time.Time{} - } - nextCheck := dl.nextCheck.Load() - if nextCheck == nil { - return time.Time{} - } - return *nextCheck -} - -func (dl *DisappearLoop) Stop() { - if dl == nil { - return - } - if stop := dl.stop.Load(); stop != nil { - (*stop)() - } -} - -func (dl *DisappearLoop) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) { - startedMessages, err := dl.br.DB.DisappearingMessage.StartAllBefore(ctx, roomID, beforeTS) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to start disappearing messages") - return - } - startedMessages = slices.DeleteFunc(startedMessages, func(dm *database.DisappearingMessage) bool { - return dm.DisappearAt.After(dl.GetNextCheck()) - }) - slices.SortFunc(startedMessages, func(a, b *database.DisappearingMessage) int { - return a.DisappearAt.Compare(b.DisappearAt) - }) - if len(startedMessages) > 0 { - go dl.sleepAndDisappear(ctx, startedMessages...) - } -} - -func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessage) { - err := dl.br.DB.DisappearingMessage.Put(ctx, dm) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("event_id", dm.EventID). - Msg("Failed to save disappearing message") - } - if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.GetNextCheck()) { - go dl.sleepAndDisappear(zerolog.Ctx(ctx).WithContext(dl.br.BackgroundCtx), dm) - } -} - -func (dl *DisappearLoop) sleepAndDisappear(ctx context.Context, dms ...*database.DisappearingMessage) { - for _, msg := range dms { - timeUntilDisappear := time.Until(msg.DisappearAt) - if timeUntilDisappear <= 0 { - if ctx.Err() != nil { - return - } - } else { - select { - case <-time.After(timeUntilDisappear): - case <-ctx.Done(): - return - } - } - resp, err := dl.br.Bot.SendMessage(ctx, msg.RoomID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: msg.EventID, - Reason: "Message disappeared", - }, - }, nil) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", msg.EventID).Msg("Failed to disappear message") - } else { - zerolog.Ctx(ctx).Debug(). - Stringer("target_event_id", msg.EventID). - Stringer("redaction_event_id", resp.EventID). - Msg("Disappeared message") - } - err = dl.br.DB.DisappearingMessage.Delete(ctx, msg.EventID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("event_id", msg.EventID). - Msg("Failed to delete disappearing message entry from database") - } - } -} diff --git a/mautrix-patched/bridgev2/errors.go b/mautrix-patched/bridgev2/errors.go deleted file mode 100644 index c7f83989..00000000 --- a/mautrix-patched/bridgev2/errors.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "errors" - "fmt" - "net/http" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" -) - -// ErrIgnoringRemoteEvent can be returned by [RemoteMessage.ConvertMessage] or [RemoteEdit.ConvertEdit] -// to indicate that the event should be ignored after all. Handling the event will be cancelled immediately. -var ErrIgnoringRemoteEvent = errors.New("ignoring remote event") - -// ErrNoStatus can be returned by [MatrixMessageResponse.HandleEcho] to indicate that the message is still in-flight -// and a status should not be sent yet. The message will still be saved into the database. -var ErrNoStatus = errors.New("omit message status") - -// ErrResolveIdentifierTryNext can be returned by ResolveIdentifier or CreateChatWithGhost to signal that -// the identifier is valid, but can't be reached by the current login, and the caller should try the next -// login if there are more. -// -// This should generally only be returned when resolving internal IDs (which happens when initiating chats via Matrix). -// For example, Google Messages would return this when trying to resolve another login's user ID, -// and Telegram would return this when the access hash isn't available. -var ErrResolveIdentifierTryNext = errors.New("that identifier is not available via this login") - -var ErrNotLoggedIn = errors.New("not logged in") - -// ErrDirectMediaNotEnabled may be returned by Matrix connectors if [MatrixConnector.GenerateContentURI] is called, -// but direct media is not enabled. -var ErrDirectMediaNotEnabled = errors.New("direct media is not enabled") - -var ErrPortalIsDeleted = errors.New("portal is deleted") -var ErrPortalNotFoundInEventHandler = errors.New("portal not found to handle remote event") -var ErrSplitPortalMigrationFailed = errors.New("failed to migrate to split portals") - -// Common message status errors -var ( - ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage() - ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrIgnoringAcceptRequestRelayedUser error = WrapErrorInStatus(errors.New("ignoring accept message request event from relayed user")).WithIsCertain(true).WithSendNotice(false) - ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrUnknownPoll error = WrapErrorInStatus(errors.New("vote target poll not found")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) - ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) - ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) - ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true) - ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) - ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrVoiceMessageDurationTooLong error = WrapErrorInStatus(errors.New("voice message too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) - ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true) - ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) - ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) - ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) - ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) - ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) - - ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) - ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) - ErrPublicMediaGenerateFailed = WrapErrorInStatus(errors.New("failed to generate public media URL")).WithIsCertain(true).WithMessage("failed to generate public media URL").WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true) - - ErrDisappearingTimerUnsupported error = WrapErrorInStatus(errors.New("invalid disappearing timer")).WithIsCertain(true) - - ErrFailedToGetIntent error = errors.New("failed to get intent for event") - ErrHandlerBackgrounded error = errors.New("event handling took too long and was backgrounded") -) - -// Common login interface errors -var ( - ErrInvalidLoginFlowID error = RespError(mautrix.MNotFound.WithMessage("Invalid login flow ID")) -) - -// RespError is a class of error that certain network interface methods can return to ensure that the error -// is properly translated into an HTTP error when the method is called via the provisioning API. -// -// However, unlike mautrix.RespError, this does not include the error code -// in the message shown to users when used outside HTTP contexts. -type RespError mautrix.RespError - -func (re RespError) Error() string { - return re.Err -} - -func (re RespError) Is(err error) bool { - var e2 RespError - if errors.As(err, &e2) { - return e2.Err == re.Err - } - return errors.Is(err, mautrix.RespError(re)) -} - -func (re RespError) Write(w http.ResponseWriter) { - mautrix.RespError(re).Write(w) -} - -func (re RespError) WithMessage(msg string, args ...any) RespError { - return RespError(mautrix.RespError(re).WithMessage(msg, args...)) -} - -func (re RespError) AppendMessage(append string, args ...any) RespError { - re.Err += fmt.Sprintf(append, args...) - return re -} - -func WrapRespErrManual(err error, code string, status int) RespError { - return RespError{ErrCode: code, Err: err.Error(), StatusCode: status} -} - -func WrapRespErr(err error, target mautrix.RespError) RespError { - return RespError{ErrCode: target.ErrCode, Err: err.Error(), StatusCode: target.StatusCode} -} diff --git a/mautrix-patched/bridgev2/ghost.go b/mautrix-patched/bridgev2/ghost.go deleted file mode 100644 index 22e1d2a8..00000000 --- a/mautrix-patched/bridgev2/ghost.go +++ /dev/null @@ -1,405 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "maps" - "net/http" - "slices" - "strings" - - "github.com/rs/zerolog" - "github.com/tidwall/sjson" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exmime" - - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type Ghost struct { - *database.Ghost - Bridge *Bridge - Log zerolog.Logger - Intent MatrixAPI -} - -func (br *Bridge) loadGhost(ctx context.Context, dbGhost *database.Ghost, queryErr error, id *networkid.UserID) (*Ghost, error) { - if queryErr != nil { - return nil, fmt.Errorf("failed to query db: %w", queryErr) - } - if dbGhost == nil { - if id == nil { - return nil, nil - } - dbGhost = &database.Ghost{ - BridgeID: br.ID, - ID: *id, - } - err := br.DB.Ghost.Insert(ctx, dbGhost) - if err != nil { - return nil, fmt.Errorf("failed to insert new ghost: %w", err) - } - } - ghost := &Ghost{ - Ghost: dbGhost, - Bridge: br, - Log: br.Log.With().Str("ghost_id", string(dbGhost.ID)).Logger(), - Intent: br.Matrix.GhostIntent(dbGhost.ID), - } - br.ghostsByID[ghost.ID] = ghost - return ghost, nil -} - -func (br *Bridge) unlockedGetGhostByID(ctx context.Context, id networkid.UserID, onlyIfExists bool) (*Ghost, error) { - cached, ok := br.ghostsByID[id] - if ok { - return cached, nil - } - idPtr := &id - if onlyIfExists { - idPtr = nil - } - db, err := br.DB.Ghost.GetByID(ctx, id) - return br.loadGhost(ctx, db, err, idPtr) -} - -func (br *Bridge) IsGhostMXID(userID id.UserID) bool { - _, isGhost := br.Matrix.ParseGhostMXID(userID) - return isGhost -} - -func (br *Bridge) GetGhostByMXID(ctx context.Context, mxid id.UserID) (*Ghost, error) { - ghostID, ok := br.Matrix.ParseGhostMXID(mxid) - if !ok { - return nil, nil - } - return br.GetGhostByID(ctx, ghostID) -} - -func (br *Bridge) GetGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - ghost, err := br.unlockedGetGhostByID(ctx, id, false) - if err != nil { - return nil, err - } else if ghost == nil { - panic(fmt.Errorf("unlockedGetGhostByID(ctx, %q, false) returned nil", id)) - } - return ghost, nil -} - -func (br *Bridge) GetExistingGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.unlockedGetGhostByID(ctx, id, true) -} - -type Avatar struct { - ID networkid.AvatarID - Get func(ctx context.Context) ([]byte, error) - Remove bool - - // For pre-uploaded avatars, the MXC URI and hash can be provided directly - MXC id.ContentURIString - Hash [32]byte -} - -func (a *Avatar) Reupload(ctx context.Context, intent MatrixAPI, currentHash [32]byte, currentMXC id.ContentURIString) (id.ContentURIString, [32]byte, error) { - if a.MXC != "" || a.Hash != [32]byte{} { - return a.MXC, a.Hash, nil - } else if a.Get == nil { - return "", [32]byte{}, fmt.Errorf("no Get function provided for avatar") - } - data, err := a.Get(ctx) - if err != nil { - return "", [32]byte{}, err - } - hash := sha256.Sum256(data) - if hash == currentHash && currentMXC != "" { - return currentMXC, hash, nil - } - mime := http.DetectContentType(data) - fileName := "avatar" + exmime.ExtensionFromMimetype(mime) - uri, _, err := intent.UploadMedia(ctx, "", data, fileName, mime) - if err != nil { - return "", hash, err - } - return uri, hash, nil -} - -type UserInfo struct { - Identifiers []string - Name *string - Avatar *Avatar - IsBot *bool - ExtraProfile database.ExtraProfile - - ExtraUpdates ExtraUpdater[*Ghost] -} - -func (ghost *Ghost) prepareName(name string) bool { - if ghost.Name == name && ghost.NameSet { - return false - } - ghost.Name = name - ghost.NameSet = false - return true -} - -func (ghost *Ghost) UpdateName(ctx context.Context, name string) bool { - if !ghost.prepareName(name) { - return false - } - if err := ghost.Intent.SetDisplayName(ctx, name); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set display name") - } else { - ghost.NameSet = true - } - return true -} - -func (ghost *Ghost) prepareAvatar(ctx context.Context, avatar *Avatar) (changed, mxcChanged bool) { - if ghost.AvatarID == avatar.ID && (avatar.Remove || ghost.AvatarMXC != "") && ghost.AvatarSet { - return false, false - } - ghost.AvatarID = avatar.ID - if !avatar.Remove { - newMXC, newHash, err := avatar.Reupload(ctx, ghost.Intent, ghost.AvatarHash, ghost.AvatarMXC) - if err != nil { - ghost.AvatarSet = false - zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload avatar") - return true, false - } else if newHash == ghost.AvatarHash && ghost.AvatarMXC != "" && ghost.AvatarSet { - return true, false - } - ghost.AvatarHash = newHash - ghost.AvatarMXC = newMXC - } else { - ghost.AvatarMXC = "" - } - ghost.AvatarSet = false - return true, true -} - -func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { - changed, mxcChanged := ghost.prepareAvatar(ctx, avatar) - if !changed { - return false - } - if mxcChanged { - if err := ghost.Intent.SetAvatarURL(ctx, ghost.AvatarMXC); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar URL") - } else { - ghost.AvatarSet = true - } - } - return true -} - -func (ghost *Ghost) getExtraProfileMeta() any { - bridgeName := ghost.Bridge.Network.GetName() - baseExtra := &event.BeeperProfileExtra{ - RemoteID: string(ghost.ID), - Identifiers: ghost.Identifiers, - Service: bridgeName.BeeperBridgeType, - Network: bridgeName.NetworkID, - IsBridgeBot: false, - IsNetworkBot: ghost.IsBot, - } - if len(ghost.ExtraProfile) == 0 { - return baseExtra - } - mergedExtra := maps.Clone(ghost.ExtraProfile) - baseExtraMarshaled := exerrors.Must(json.Marshal(baseExtra)) - exerrors.PanicIfNotNil(json.Unmarshal(baseExtraMarshaled, &mergedExtra)) - return mergedExtra -} - -func (ghost *Ghost) getFullProfile() json.RawMessage { - marshaled := exerrors.Must(json.Marshal(ghost.getExtraProfileMeta())) - if ghost.Name != "" { - marshaled = exerrors.Must(sjson.SetBytes(marshaled, "displayname", ghost.Name)) - } - if ghost.AvatarMXC != "" { - marshaled = exerrors.Must(sjson.SetBytes(marshaled, "avatar_url", ghost.AvatarMXC)) - } - return marshaled -} - -func (ghost *Ghost) prepareContactInfo(identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool { - caps := ghost.Bridge.Matrix.GetCapabilities() - if !caps.ExtraProfileMeta && !caps.ReplaceEntireProfile { - ghost.ContactInfoSet = false - return false - } - if identifiers != nil { - slices.Sort(identifiers) - if !ghost.Bridge.Config.PhoneNumbersInProfile { - identifiers = slices.DeleteFunc(identifiers, func(id string) bool { - return strings.HasPrefix(id, "tel:") - }) - } - } - changed := extraProfile.CopyTo(&ghost.ExtraProfile) - if identifiers != nil { - changed = changed || !slices.Equal(identifiers, ghost.Identifiers) - ghost.Identifiers = identifiers - } - if isBot != nil { - changed = changed || *isBot != ghost.IsBot - ghost.IsBot = *isBot - } - if ghost.ContactInfoSet && !changed { - return false - } - ghost.ContactInfoSet = false - return true -} - -func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool { - if !ghost.prepareContactInfo(identifiers, isBot, extraProfile) { - return false - } - if err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta()); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") - } else { - ghost.ContactInfoSet = true - } - return true -} - -func (br *Bridge) allowAggressiveUpdateForType(evtType RemoteEventType) bool { - if !br.Network.GetCapabilities().AggressiveUpdateInfo { - return false - } - switch evtType { - case RemoteEventUnknown, RemoteEventMessage, RemoteEventEdit, RemoteEventReaction: - return true - default: - return false - } -} - -func (ghost *Ghost) UpdateInfoIfNecessary(ctx context.Context, source *UserLogin, evtType RemoteEventType) { - if ghost.Name != "" && ghost.NameSet && ghost.AvatarSet && !ghost.Bridge.allowAggressiveUpdateForType(evtType) { - return - } - info, err := source.Client.GetUserInfo(ctx, ghost) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("ghost_id", string(ghost.ID)).Msg("Failed to get info to update ghost") - } else if info != nil { - zerolog.Ctx(ctx).Debug(). - Bool("has_name", ghost.Name != ""). - Bool("name_set", ghost.NameSet). - Bool("has_avatar", ghost.AvatarMXC != ""). - Bool("avatar_set", ghost.AvatarSet). - Msg("Updating ghost info in IfNecessary call") - ghost.UpdateInfo(ctx, info) - } else { - zerolog.Ctx(ctx).Trace(). - Bool("has_name", ghost.Name != ""). - Bool("name_set", ghost.NameSet). - Bool("has_avatar", ghost.AvatarMXC != ""). - Bool("avatar_set", ghost.AvatarSet). - Msg("No ghost info received in IfNecessary call") - } -} - -func (ghost *Ghost) updateDMPortals(ctx context.Context) { - if !ghost.Bridge.Config.PrivateChatPortalMeta { - return - } - dmPortals, err := ghost.Bridge.GetDMPortalsWith(ctx, ghost.ID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM portals to update info") - return - } - for _, portal := range dmPortals { - go portal.lockedUpdateInfoFromGhost(ctx, ghost) - } -} - -func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) { - oldName := ghost.Name - oldAvatar := ghost.AvatarMXC - - nameChanged := info.Name != nil && ghost.prepareName(*info.Name) - - var avatarChanged, avatarMXCChanged bool - if info.Avatar != nil { - avatarChanged, avatarMXCChanged = ghost.prepareAvatar(ctx, info.Avatar) - } else if oldAvatar == "" && !ghost.AvatarSet { - // Special case: nil avatar means we're not expecting one ever, if we don't currently have - // one we flag it as set to avoid constantly refetching in UpdateInfoIfNecessary. - ghost.AvatarSet = true - avatarChanged = true - } - - var contactInfoChanged bool - if info.Identifiers != nil || info.IsBot != nil || info.ExtraProfile != nil { - contactInfoChanged = ghost.prepareContactInfo(info.Identifiers, info.IsBot, info.ExtraProfile) - } - - update := nameChanged || avatarChanged || contactInfoChanged - if info.ExtraUpdates != nil { - update = info.ExtraUpdates(ctx, ghost) || update - } - ghost.pushProfileChanges(ctx, nameChanged, avatarMXCChanged, contactInfoChanged) - if oldName != ghost.Name || oldAvatar != ghost.AvatarMXC { - ghost.updateDMPortals(ctx) - } - if update { - err := ghost.Bridge.DB.Ghost.Update(ctx, ghost.Ghost) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update ghost in database after updating info") - } - } -} - -func (ghost *Ghost) pushProfileChanges(ctx context.Context, nameChanged, avatarChanged, contactInfoChanged bool) { - if !nameChanged && !avatarChanged && !contactInfoChanged { - return - } - if ghost.Bridge.Matrix.GetCapabilities().ReplaceEntireProfile { - if err := ghost.Intent.SetProfile(ctx, ghost.getFullProfile()); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set profile") - } else { - ghost.NameSet = true - ghost.AvatarSet = true - ghost.ContactInfoSet = true - } - } else { - if nameChanged { - if err := ghost.Intent.SetDisplayName(ctx, ghost.Name); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set display name") - } else { - ghost.NameSet = true - } - } - if avatarChanged { - if err := ghost.Intent.SetAvatarURL(ctx, ghost.AvatarMXC); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar URL") - } else { - ghost.AvatarSet = true - } - } - if contactInfoChanged { - if err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta()); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") - } else { - ghost.ContactInfoSet = true - } - } - } -} diff --git a/mautrix-patched/bridgev2/login.go b/mautrix-patched/bridgev2/login.go deleted file mode 100644 index 76b42d4d..00000000 --- a/mautrix-patched/bridgev2/login.go +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "encoding/json" - "fmt" - "regexp" - "strings" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" -) - -// LoginProcess represents a single occurrence of a user logging into the remote network. -type LoginProcess interface { - // Start starts the process and returns the first step. - // - // For example, a network using QR login may connect to the network, fetch a QR code, - // and return a DisplayAndWait-type step. - // - // This will only ever be called once. - Start(ctx context.Context) (*LoginStep, error) - // Cancel stops the login process and cleans up any resources. - // No other methods will be called after cancel. - // - // Cancel will not be called if any other method returned an error: - // errors are always treated as fatal and the process is assumed to be automatically cancelled. - Cancel() -} - -type LoginProcessWithOverride interface { - LoginProcess - // StartWithOverride starts the process with the intent of re-authenticating an existing login. - // - // The call to this is mutually exclusive with the call to the default Start method. - // - // The user login being overridden will still be logged out automatically - // in case the complete step returns a different login. - StartWithOverride(ctx context.Context, override *UserLogin) (*LoginStep, error) -} - -type LoginProcessDisplayAndWait interface { - LoginProcess - Wait(ctx context.Context) (*LoginStep, error) -} - -type LoginProcessUserInput interface { - LoginProcess - SubmitUserInput(ctx context.Context, input map[string]string) (*LoginStep, error) -} - -type LoginProcessCookies interface { - LoginProcess - SubmitCookies(ctx context.Context, cookies map[string]string) (*LoginStep, error) -} - -type LoginProcessWebAuthn interface { - LoginProcess - SubmitWebAuthnResponse(ctx context.Context, response json.RawMessage) (*LoginStep, error) -} - -type LoginFlow struct { - Name string `json:"name"` - Description string `json:"description"` - ID string `json:"id"` -} - -type LoginStepType string - -const ( - LoginStepTypeUserInput LoginStepType = "user_input" - LoginStepTypeCookies LoginStepType = "cookies" - LoginStepTypeDisplayAndWait LoginStepType = "display_and_wait" - LoginStepTypeWebAuthn LoginStepType = "webauthn" - LoginStepTypeComplete LoginStepType = "complete" -) - -type LoginDisplayType string - -const ( - LoginDisplayTypeQR LoginDisplayType = "qr" - LoginDisplayTypeEmoji LoginDisplayType = "emoji" - LoginDisplayTypeCode LoginDisplayType = "code" - LoginDisplayTypeNothing LoginDisplayType = "nothing" -) - -type LoginStep struct { - // The type of login step - Type LoginStepType `json:"type"` - // A unique ID for this step. The ID should be same for every login using the same flow, - // but it should be different for different bridges and step types. - // - // For example, Telegram's QR scan followed by a 2-factor password - // might use the IDs `fi.mau.telegram.qr` and `fi.mau.telegram.2fa_password`. - StepID string `json:"step_id"` - // Instructions contains human-readable instructions for completing the login step. - Instructions string `json:"instructions"` - - // Exactly one of the following structs must be filled depending on the step type. - - DisplayAndWaitParams *LoginDisplayAndWaitParams `json:"display_and_wait,omitempty"` - CookiesParams *LoginCookiesParams `json:"cookies,omitempty"` - UserInputParams *LoginUserInputParams `json:"user_input,omitempty"` - WebAuthnParams *LoginWebAuthnParams `json:"webauthn,omitempty"` - CompleteParams *LoginCompleteParams `json:"complete,omitempty"` -} - -type LoginWebAuthnParams struct { - // The origin URL where the credential should be extracted from. - URL string `json:"url,omitempty"` - - // Standard parameters for https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get - // Only publicKey seems to be used in practice, so the other options aren't allowed yet. - PublicKey json.RawMessage `json:"publicKey,omitempty"` -} - -type LoginDisplayAndWaitParams struct { - // The type of thing to display (QR, emoji or text code) - Type LoginDisplayType `json:"type"` - // The thing to display (raw data for QR, unicode emoji for emoji, plain string for code, omitted for nothing) - Data string `json:"data,omitempty"` - // An image containing the thing to display. If present, this is recommended over using data directly. - // For emojis, the URL to the canonical image representation of the emoji - ImageURL string `json:"image_url,omitempty"` -} - -type LoginCookieFieldSourceType string - -const ( - LoginCookieTypeCookie LoginCookieFieldSourceType = "cookie" - LoginCookieTypeLocalStorage LoginCookieFieldSourceType = "local_storage" - LoginCookieTypeRequestHeader LoginCookieFieldSourceType = "request_header" - LoginCookieTypeRequestBody LoginCookieFieldSourceType = "request_body" - LoginCookieTypeSpecial LoginCookieFieldSourceType = "special" -) - -type LoginCookieFieldSource struct { - // The type of source. - Type LoginCookieFieldSourceType `json:"type"` - // The name of the field. The exact meaning depends on the type of source. - // Cookie: cookie name - // Local storage: key in local storage - // Request header: header name - // Request body: field name inside body after it's parsed (as JSON or multipart form data) - // Special: a namespaced identifier that clients can implement special handling for - Name string `json:"name"` - - // For request header & body types, a regex matching request URLs where the value can be extracted from. - RequestURLRegex string `json:"request_url_regex,omitempty"` - // For cookie types, the domain the cookie is present on. - CookieDomain string `json:"cookie_domain,omitempty"` -} - -type LoginCookieField struct { - // The key in the map that is submitted to the connector. - ID string `json:"id"` - Required bool `json:"required"` - // The sources that can be used to acquire the field value. Only one of these needs to be used. - Sources []LoginCookieFieldSource `json:"sources"` - // A regex pattern that the client can use to validate value client-side. - Pattern string `json:"pattern,omitempty"` -} - -type LoginCookiesParams struct { - URL string `json:"url"` - UserAgent string `json:"user_agent,omitempty"` - - // The fields that are needed for this cookie login. - Fields []LoginCookieField `json:"fields"` - // A JavaScript snippet that can extract some or all of the fields. - // The snippet will evaluate to a promise that resolves when the relevant fields are found. - // Fields that are not present in the promise result must be extracted another way. - ExtractJS string `json:"extract_js,omitempty"` - // A regex pattern that the URL should match before the client closes the webview. - // - // The client may submit the login if the user closes the webview after all cookies are collected - // even if this URL is not reached, but it should only automatically close the webview after - // both cookies and the URL match. - WaitForURLPattern string `json:"wait_for_url_pattern,omitempty"` -} - -type LoginInputFieldType string - -const ( - LoginInputFieldTypeUsername LoginInputFieldType = "username" - LoginInputFieldTypePassword LoginInputFieldType = "password" - LoginInputFieldTypePhoneNumber LoginInputFieldType = "phone_number" - LoginInputFieldTypeEmail LoginInputFieldType = "email" - LoginInputFieldType2FACode LoginInputFieldType = "2fa_code" - LoginInputFieldTypeToken LoginInputFieldType = "token" - LoginInputFieldTypeURL LoginInputFieldType = "url" - LoginInputFieldTypeDomain LoginInputFieldType = "domain" - LoginInputFieldTypeSelect LoginInputFieldType = "select" - LoginInputFieldTypeCaptchaCode LoginInputFieldType = "captcha_code" -) - -type LoginInputDataField struct { - // The type of input field as a hint for the client. - Type LoginInputFieldType `json:"type"` - // The ID of the field to be used as the key in the map that is submitted to the connector. - ID string `json:"id"` - // The name of the field shown to the user. - Name string `json:"name"` - // The description of the field shown to the user. - Description string `json:"description"` - // A default value that the client can pre-fill the field with. - DefaultValue string `json:"default_value,omitempty"` - // A regex pattern that the client can use to validate input client-side. - Pattern string `json:"pattern,omitempty"` - // For fields of type select, the valid options. - // Pattern may also be filled with a regex that matches the same options. - Options []string `json:"options,omitempty"` - // A function that validates the input and optionally cleans it up before it's submitted to the connector. - Validate func(string) (string, error) `json:"-"` -} - -var numberCleaner = strings.NewReplacer("-", "", " ", "", "(", "", ")", "") - -func isOnlyNumbers(input string) bool { - for _, r := range input { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func CleanNonInternationalPhoneNumber(phone string) (string, error) { - phone = numberCleaner.Replace(phone) - if !isOnlyNumbers(strings.TrimPrefix(phone, "+")) { - return "", fmt.Errorf("phone number must only contain numbers") - } - return phone, nil -} - -func CleanPhoneNumber(phone string) (string, error) { - phone = numberCleaner.Replace(phone) - if len(phone) < 2 { - return "", fmt.Errorf("phone number must start with + and contain numbers") - } else if phone[0] != '+' { - return "", fmt.Errorf("phone number must start with +") - } else if !isOnlyNumbers(phone[1:]) { - return "", fmt.Errorf("phone number must only contain numbers") - } - return phone, nil -} - -func noopValidate(input string) (string, error) { - return input, nil -} - -func (f *LoginInputDataField) FillDefaultValidate() { - if f.Validate != nil { - return - } - switch f.Type { - case LoginInputFieldTypePhoneNumber: - f.Validate = CleanPhoneNumber - case LoginInputFieldTypeEmail: - f.Validate = func(email string) (string, error) { - if !strings.ContainsRune(email, '@') { - return "", fmt.Errorf("invalid email") - } - return email, nil - } - default: - if f.Pattern != "" { - f.Validate = func(s string) (string, error) { - match, err := regexp.MatchString(f.Pattern, s) - if err != nil { - return "", err - } else if !match { - return "", fmt.Errorf("doesn't match regex `%s`", f.Pattern) - } else { - return s, nil - } - } - } else { - f.Validate = noopValidate - } - } -} - -type LoginUserInputParams struct { - // The fields that the user needs to fill in. - Fields []LoginInputDataField `json:"fields"` - - // Attachments to display alongside the input fields. - Attachments []*LoginUserInputAttachment `json:"attachments"` -} - -type LoginUserInputAttachment struct { - Type event.MessageType `json:"type,omitempty"` - FileName string `json:"filename,omitempty"` - Content []byte `json:"content,omitempty"` - Info LoginUserInputAttachmentInfo `json:"info,omitempty"` -} - -type LoginUserInputAttachmentInfo struct { - MimeType string `json:"mimetype,omitempty"` - Width int `json:"w,omitempty"` - Height int `json:"h,omitempty"` - Size int `json:"size,omitempty"` -} - -type LoginCompleteParams struct { - UserLoginID networkid.UserLoginID `json:"user_login_id"` - UserLogin *UserLogin `json:"-"` -} - -type LoginSubmit struct { -} diff --git a/mautrix-patched/bridgev2/matrix/analytics.go b/mautrix-patched/bridgev2/matrix/analytics.go deleted file mode 100644 index 7eb2a33a..00000000 --- a/mautrix-patched/bridgev2/matrix/analytics.go +++ /dev/null @@ -1,62 +0,0 @@ -package matrix - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - - "maunium.net/go/mautrix/id" -) - -func (br *Connector) trackSync(userID id.UserID, event string, properties map[string]any) error { - var buf bytes.Buffer - var analyticsUserID string - if br.Config.Analytics.UserID != "" { - analyticsUserID = br.Config.Analytics.UserID - } else { - analyticsUserID = userID.String() - } - err := json.NewEncoder(&buf).Encode(map[string]any{ - "userId": analyticsUserID, - "event": event, - "properties": properties, - }) - if err != nil { - return err - } - - req, err := http.NewRequest(http.MethodPost, br.Config.Analytics.URL, &buf) - if err != nil { - return err - } - req.SetBasicAuth(br.Config.Analytics.Token, "") - resp, err := br.AS.HTTPClient.Do(req) - if err != nil { - return err - } - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code %d", resp.StatusCode) - } - return nil -} - -func (br *Connector) TrackAnalytics(userID id.UserID, event string, props map[string]any) { - if br.Config.Analytics.Token == "" || br.Config.Analytics.URL == "" { - return - } - - if props == nil { - props = map[string]any{} - } - props["bridge"] = br.Bridge.Network.GetName().BeeperBridgeType - go func() { - err := br.trackSync(userID, event, props) - if err != nil { - br.Log.Err(err).Str("component", "analytics").Str("event", event).Msg("Error tracking event") - } else { - br.Log.Debug().Str("component", "analytics").Str("event", event).Msg("Tracked event") - } - }() -} diff --git a/mautrix-patched/bridgev2/matrix/cmdadmin.go b/mautrix-patched/bridgev2/matrix/cmdadmin.go deleted file mode 100644 index 0bd3eb82..00000000 --- a/mautrix-patched/bridgev2/matrix/cmdadmin.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "strconv" - - "maunium.net/go/mautrix/bridgev2/commands" - "maunium.net/go/mautrix/id" -) - -var CommandDiscardMegolmSession = &commands.FullHandler{ - Func: func(ce *commands.Event) { - matrix := ce.Bridge.Matrix.(*Connector) - if matrix.Crypto == nil { - ce.Reply("This bridge instance doesn't have end-to-bridge encryption enabled") - } else { - matrix.Crypto.ResetSession(ce.Ctx, ce.RoomID) - ce.Reply("Successfully reset Megolm session in this room. New decryption keys will be shared the next time a message is sent from the remote network.") - } - }, - Name: "discard-megolm-session", - Aliases: []string{"discard-session"}, - Help: commands.HelpMeta{ - Section: commands.HelpSectionAdmin, - Description: "Discard the Megolm session in the room", - }, - RequiresAdmin: true, -} - -func fnSetPowerLevel(ce *commands.Event) { - var level int - var userID id.UserID - var err error - if len(ce.Args) == 1 { - level, err = strconv.Atoi(ce.Args[0]) - if err != nil { - ce.Reply("Invalid power level \"%s\"", ce.Args[0]) - return - } - userID = ce.User.MXID - } else if len(ce.Args) == 2 { - userID = id.UserID(ce.Args[0]) - _, _, err := userID.Parse() - if err != nil { - ce.Reply("Invalid user ID \"%s\"", ce.Args[0]) - return - } - level, err = strconv.Atoi(ce.Args[1]) - if err != nil { - ce.Reply("Invalid power level \"%s\"", ce.Args[1]) - return - } - } else { - ce.Reply("**Usage:** `set-pl [user] `") - return - } - _, err = ce.Bot.(*ASIntent).Matrix.SetPowerLevel(ce.Ctx, ce.RoomID, userID, level) - if err != nil { - ce.Reply("Failed to set power levels: %v", err) - } -} - -var CommandSetPowerLevel = &commands.FullHandler{ - Func: fnSetPowerLevel, - Name: "set-pl", - Aliases: []string{"set-power-level"}, - Help: commands.HelpMeta{ - Section: commands.HelpSectionAdmin, - Description: "Change the power level in a portal room.", - Args: "[_user ID_] <_power level_>", - }, - RequiresAdmin: true, - RequiresPortal: true, -} diff --git a/mautrix-patched/bridgev2/matrix/cmddoublepuppet.go b/mautrix-patched/bridgev2/matrix/cmddoublepuppet.go deleted file mode 100644 index 2f3a3dc2..00000000 --- a/mautrix-patched/bridgev2/matrix/cmddoublepuppet.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "maunium.net/go/mautrix/bridgev2/commands" -) - -var CommandLoginMatrix = &commands.FullHandler{ - Func: fnLoginMatrix, - Name: "login-matrix", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Enable double puppeting.", - Args: "<_access token_>", - }, - RequiresLogin: true, -} - -func fnLoginMatrix(ce *commands.Event) { - if !ce.User.Permissions.DoublePuppet { - ce.Reply("You don't have permission to manage double puppeting.") - return - } - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `login-matrix `") - return - } - err := ce.User.LoginDoublePuppet(ce.Ctx, ce.Args[0]) - if err != nil { - ce.Reply("Failed to enable double puppeting: %v", err) - } else { - ce.Reply("Successfully switched puppets") - } -} - -var CommandPingMatrix = &commands.FullHandler{ - Func: fnPingMatrix, - Name: "ping-matrix", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Ping the Matrix server with the double puppet.", - }, -} - -func fnPingMatrix(ce *commands.Event) { - intent := ce.User.DoublePuppet(ce.Ctx) - if intent == nil { - ce.Reply("You don't have double puppeting enabled.") - return - } - asIntent := intent.(*ASIntent) - resp, err := asIntent.Matrix.Whoami(ce.Ctx) - if err != nil { - ce.Reply("Failed to validate Matrix login: %v", err) - } else { - if asIntent.Matrix.SetAppServiceUserID && resp.DeviceID == "" { - ce.Reply("Confirmed valid access token for %s (appservice double puppeting)", resp.UserID) - } else { - ce.Reply("Confirmed valid access token for %s / %s", resp.UserID, resp.DeviceID) - } - } -} - -var CommandLogoutMatrix = &commands.FullHandler{ - Func: fnLogoutMatrix, - Name: "logout-matrix", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Disable double puppeting.", - }, - RequiresLogin: true, -} - -func fnLogoutMatrix(ce *commands.Event) { - if !ce.User.Permissions.DoublePuppet { - ce.Reply("You don't have permission to manage double puppeting.") - return - } - if ce.User.AccessToken == "" { - ce.Reply("You don't have double puppeting enabled.") - return - } - ce.User.LogoutDoublePuppet(ce.Ctx) - ce.Reply("Successfully disabled double puppeting.") -} diff --git a/mautrix-patched/bridgev2/matrix/connector.go b/mautrix-patched/bridgev2/matrix/connector.go deleted file mode 100644 index caaf7d2e..00000000 --- a/mautrix-patched/bridgev2/matrix/connector.go +++ /dev/null @@ -1,786 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "net/http" - "net/url" - "regexp" - "strings" - "sync" - "time" - - _ "github.com/lib/pq" - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - _ "go.mau.fi/util/dbutil/litestream" - "go.mau.fi/util/exbytes" - "go.mau.fi/util/exsync" - "go.mau.fi/util/ptr" - "go.mau.fi/util/random" - "golang.org/x/sync/semaphore" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/bridgev2/commands" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/mediaproxy" - "maunium.net/go/mautrix/sqlstatestore" -) - -type Crypto interface { - HandleMemberEvent(context.Context, *event.Event) - Decrypt(context.Context, *event.Event) (*event.Event, error) - Encrypt(context.Context, id.RoomID, event.Type, *event.Content) error - WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool - RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) - ResetSession(context.Context, id.RoomID) - Init(ctx context.Context) error - Start() - Stop() - Reset(ctx context.Context, startAfterReset bool) error - Client() *mautrix.Client - ShareKeys(context.Context) error - BeeperStreamPublisher() bridgev2.BeeperStreamPublisher -} - -type Connector struct { - AS *appservice.AppService - Bot *appservice.IntentAPI - StateStore *sqlstatestore.SQLStateStore - Crypto Crypto - Log *zerolog.Logger - Config *bridgeconfig.Config - Bridge *bridgev2.Bridge - Provisioning *ProvisioningAPI - DoublePuppet *doublePuppetUtil - MediaProxy *mediaproxy.MediaProxy - - uploadSema *semaphore.Weighted - dmaSigKey [32]byte - pubMediaSigKey []byte - - doublePuppetIntents *exsync.Map[id.UserID, *appservice.IntentAPI] - - deterministicEventIDServer string - - MediaConfig mautrix.RespMediaConfig - SpecVersions *mautrix.RespVersions - SpecCaps *mautrix.RespCapabilities - specCapsLock sync.Mutex - Capabilities *bridgev2.MatrixCapabilities - IgnoreUnsupportedServer bool - - EventProcessor *appservice.EventProcessor - - userIDRegex *regexp.Regexp - - Websocket bool - wsStopPinger chan struct{} - wsStarted chan struct{} - wsStopped chan struct{} - wsShortCircuitReconnectBackoff chan struct{} - wsStartupWait *sync.WaitGroup - stopping bool - hasSentAnyStates bool - OnWebsocketReplaced func() -} - -var ( - _ bridgev2.MatrixConnector = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithServer = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithArbitraryRoomState = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithPostRoomBridgeHandling = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithPublicMedia = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithNameDisambiguation = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithURLPreviews = (*Connector)(nil) - _ bridgev2.MatrixConnectorWithAnalytics = (*Connector)(nil) -) - -func NewConnector(cfg *bridgeconfig.Config) *Connector { - c := &Connector{} - c.Config = cfg - c.userIDRegex = cfg.MakeUserIDRegex("(.+)") - c.MediaConfig.UploadSize = 50 * 1024 * 1024 - c.uploadSema = semaphore.NewWeighted(c.MediaConfig.UploadSize + 1) - c.Capabilities = &bridgev2.MatrixCapabilities{} - c.doublePuppetIntents = exsync.NewMap[id.UserID, *appservice.IntentAPI]() - return c -} - -func (br *Connector) Init(bridge *bridgev2.Bridge) { - br.Bridge = bridge - br.Log = &bridge.Log - br.StateStore = sqlstatestore.NewSQLStateStore(bridge.DB.Database, dbutil.ZeroLogger(br.Log.With().Str("db_section", "matrix_state").Logger()), false) - br.AS = br.Config.MakeAppService() - br.AS.Log = bridge.Log - br.AS.StateStore = br.StateStore - br.EventProcessor = appservice.NewEventProcessor(br.AS) - if !br.Config.AppService.AsyncTransactions { - br.EventProcessor.ExecMode = appservice.Sync - } - for evtType := range status.CheckpointTypes { - br.EventProcessor.On(evtType, br.sendBridgeCheckpoint) - } - for _, evtType := range []event.Type{ - event.EventMessage, - event.EventSticker, - event.EventUnstablePollStart, - event.EventUnstablePollResponse, - event.EventReaction, - event.EventRedaction, - event.StateMember, - event.StatePowerLevels, - event.StateRoomName, - event.BeeperSendState, - event.StateRoomAvatar, - event.StateTopic, - event.StateTombstone, - event.StateBeeperDisappearingTimer, - event.BeeperDeleteChat, - event.BeeperAcceptMessageRequest, - } { - br.EventProcessor.On(evtType, br.handleRoomEvent) - } - br.EventProcessor.On(event.EventEncrypted, br.handleEncryptedEvent) - br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) - br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) - br.Bot = br.AS.BotIntent() - br.Crypto = NewCryptoHelper(br) - br.Bridge.Commands.(*commands.Processor).AddHandlers( - CommandDiscardMegolmSession, CommandSetPowerLevel, - CommandLoginMatrix, CommandPingMatrix, CommandLogoutMatrix, - ) - br.Provisioning = &ProvisioningAPI{br: br} - br.DoublePuppet = newDoublePuppetUtil(br) - br.deterministicEventIDServer = "backfill." + br.Config.Homeserver.Domain -} - -func (br *Connector) Start(ctx context.Context) error { - br.Provisioning.Init() - err := br.initDirectMedia() - if err != nil { - return err - } - err = br.initPublicMedia() - if err != nil { - return err - } - needsStateResync := br.Config.Encryption.Default && - br.Bridge.DB.KV.Get(ctx, database.KeyEncryptionStateResynced) != "true" - if needsStateResync { - dbExists, err := br.StateStore.TableExists(ctx, "mx_version") - if err != nil { - return fmt.Errorf("failed to check if mx_version table exists: %w", err) - } else if !dbExists { - needsStateResync = false - br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true") - } - } - err = br.StateStore.Upgrade(ctx) - if err != nil { - return bridgev2.DBUpgradeError{Section: "matrix_state", Err: err} - } - if br.Config.Homeserver.Websocket || len(br.Config.Homeserver.WSProxy) > 0 { - br.Websocket = true - br.Log.Debug().Msg("Starting appservice websocket") - var wg sync.WaitGroup - wg.Add(1) - br.wsStartupWait = &wg - br.wsShortCircuitReconnectBackoff = make(chan struct{}) - go br.startWebsocket(&wg) - } else if br.Config.AppService.NoServer { - br.Log.Debug().Msg("Not starting appservice HTTP server, assuming someone else is routing traffic") - } else if br.AS.Host.IsConfigured() { - br.Log.Debug().Msg("Starting appservice HTTP server") - go br.AS.Start() - } else { - br.Log.WithLevel(zerolog.FatalLevel).Msg("Neither appservice HTTP listener nor websocket is enabled") - return ExitError{23} - } - - br.Log.Debug().Msg("Checking connection to homeserver") - if err := br.ensureConnection(ctx); err != nil { - return err - } - go br.fetchMediaConfig(ctx) - if br.Crypto != nil { - err = br.Crypto.Init(ctx) - if err != nil { - return err - } - } - br.EventProcessor.Start(ctx) - go br.UpdateBotProfile(ctx) - if br.Crypto != nil { - go br.Crypto.Start() - } - parsed, _ := url.Parse(br.Bridge.Network.GetName().NetworkURL) - if parsed != nil { - br.deterministicEventIDServer = strings.TrimPrefix(parsed.Hostname(), "www.") - } - br.AS.Ready = true - if br.Websocket && br.Config.Homeserver.WSPingInterval > 0 { - br.wsStopPinger = make(chan struct{}, 1) - go br.websocketServerPinger() - } - if needsStateResync { - br.ResyncEncryptionState(ctx) - } - return nil -} - -func (br *Connector) ResyncEncryptionState(ctx context.Context) { - log := zerolog.Ctx(ctx) - roomIDScanner := dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) - rooms, err := roomIDScanner.NewRowIter(br.Bridge.DB.Query(ctx, ` - SELECT rooms.room_id - FROM (SELECT DISTINCT(room_id) FROM mx_user_profile WHERE room_id<>'') rooms - LEFT JOIN mx_room_state ON rooms.room_id = mx_room_state.room_id - WHERE mx_room_state.encryption IS NULL - `)).AsList() - if err != nil { - log.Err(err).Msg("Failed to get room list to resync state") - return - } - var failedCount, successCount, forbiddenCount int - for _, roomID := range rooms { - if roomID == "" { - continue - } - var outContent *event.EncryptionEventContent - err = br.Bot.Client.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent) - if errors.Is(err, mautrix.MForbidden) { - // Most likely non-existent room - log.Debug().Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") - forbiddenCount++ - } else if err != nil { - log.Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room") - failedCount++ - } else { - successCount++ - } - } - br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true") - log.Info(). - Int("success_count", successCount). - Int("forbidden_count", forbiddenCount). - Int("failed_count", failedCount). - Msg("Resynced rooms") -} - -func (br *Connector) GetPublicAddress() string { - if br.Config.AppService.PublicAddress == "https://bridge.example.com" { - return "" - } - return strings.TrimRight(br.Config.AppService.PublicAddress, "/") -} - -func (br *Connector) GetRouter() *http.ServeMux { - if br.GetPublicAddress() != "" { - return br.AS.Router - } - return nil -} - -func (br *Connector) GetCapabilities() *bridgev2.MatrixCapabilities { - return br.Capabilities -} - -func (br *Connector) GetBeeperStreamPublisher() bridgev2.BeeperStreamPublisher { - if br == nil || br.Crypto == nil { - return nil - } - return br.Crypto.BeeperStreamPublisher() -} - -func sendStopSignal(ch chan struct{}) { - if ch != nil { - select { - case ch <- struct{}{}: - default: - } - } -} - -func (br *Connector) PreStop() { - br.stopping = true - br.AS.Stop() - if stopWebsocket := br.AS.StopWebsocket; stopWebsocket != nil { - stopWebsocket(appservice.ErrWebsocketManualStop) - } - sendStopSignal(br.wsStopPinger) - sendStopSignal(br.wsShortCircuitReconnectBackoff) -} - -func (br *Connector) Stop() { - br.EventProcessor.Stop() - if br.Crypto != nil { - br.Crypto.Stop() - } - if wsStopChan := br.wsStopped; wsStopChan != nil { - select { - case <-wsStopChan: - case <-time.After(4 * time.Second): - br.Log.Warn().Msg("Timed out waiting for websocket to close") - } - } -} - -var MinSpecVersion = mautrix.SpecV14 - -func (br *Connector) logInitialRequestError(err error, defaultMessage string) { - if errors.Is(err, mautrix.MUnknownToken) { - br.Log.WithLevel(zerolog.FatalLevel).Msg("The as_token was not accepted. Is the registration file installed in your homeserver correctly?") - br.Log.Info().Msg("See https://docs.mau.fi/faq/as-token for more info") - } else if errors.Is(err, mautrix.MExclusive) { - br.Log.WithLevel(zerolog.FatalLevel).Msg("The as_token was accepted, but the /register request was not. Are the homeserver domain, bot username and username template in the config correct, and do they match the values in the registration?") - br.Log.Info().Msg("See https://docs.mau.fi/faq/as-register for more info") - } else { - br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg(defaultMessage) - } -} - -func (br *Connector) ensureConnection(ctx context.Context) error { - triedToRegister := false - for { - versions, err := br.Bot.Versions(ctx) - if err != nil { - if errors.Is(err, mautrix.MForbidden) && !triedToRegister { - br.Log.Debug().Msg("M_FORBIDDEN in /versions, trying to register before retrying") - err = br.Bot.EnsureRegistered(ctx) - if err != nil { - br.logInitialRequestError(err, "Failed to register after /versions failed with M_FORBIDDEN") - return fmt.Errorf("%w: failed to register after /versions failed with M_FORBIDDEN: %w", ExitError{16}, err) - } - triedToRegister = true - } else if errors.Is(err, mautrix.MUnknownToken) || errors.Is(err, mautrix.MExclusive) { - br.logInitialRequestError(err, "/versions request failed with auth error") - return fmt.Errorf("%w: /versions request failed with auth error: %w", ExitError{16}, err) - } else if errors.Is(err, context.Canceled) { - br.logInitialRequestError(err, "/versions request was canceled") - return fmt.Errorf("%w: /versions request was canceled: %w", ExitError{16}, err) - } else { - br.Log.Err(err).Msg("Failed to connect to homeserver, retrying in 10 seconds...") - select { - case <-time.After(10 * time.Second): - case <-ctx.Done(): - return fmt.Errorf("%w: %w", ExitError{16}, ctx.Err()) - } - } - } else { - br.SpecVersions = versions - *br.AS.SpecVersions = *versions - br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) - br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) - br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange) - br.Capabilities.ReplaceEntireProfile = br.SpecVersions.Supports(mautrix.FeatureUnstableReplaceProfile) || - br.SpecVersions.Supports(mautrix.FeatureStableReplaceProfile) - br.Capabilities.ExtraProfileMeta = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || - (br.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && br.Config.Matrix.GhostExtraProfileInfo) - break - } - } - - unsupportedServerLogLevel := zerolog.FatalLevel - if br.IgnoreUnsupportedServer { - unsupportedServerLogLevel = zerolog.ErrorLevel - } - if br.Config.Homeserver.Software == bridgeconfig.SoftwareHungry && !br.SpecVersions.Supports(mautrix.BeeperFeatureHungry) { - br.Log.WithLevel(zerolog.FatalLevel).Msg("The config claims the homeserver is hungryserv, but the /versions response didn't confirm it") - return ExitError{18} - } else if !br.SpecVersions.ContainsGreaterOrEqual(MinSpecVersion) { - br.Log.WithLevel(unsupportedServerLogLevel). - Stringer("server_supports", br.SpecVersions.GetLatest()). - Stringer("bridge_requires", MinSpecVersion). - Msg("The homeserver is outdated (supported spec versions are below minimum required by bridge)") - if !br.IgnoreUnsupportedServer { - return ExitError{18} - } - } - - resp, err := br.Bot.Whoami(ctx) - if err != nil { - br.logInitialRequestError(err, "/whoami request failed with unknown error") - return fmt.Errorf("%w: /whoami request failed with unknown error: %w", ExitError{16}, err) - } else if resp.UserID != br.Bot.UserID { - br.Log.WithLevel(zerolog.FatalLevel). - Stringer("got_user_id", resp.UserID). - Stringer("expected_user_id", br.Bot.UserID). - Msg("Unexpected user ID in whoami call") - return ExitError{17} - } - - if br.Websocket { - br.Log.Debug().Msg("Websocket mode: no need to check status of homeserver -> bridge connection") - } else if !br.SpecVersions.Supports(mautrix.FeatureAppservicePing) { - br.Log.Debug().Msg("Homeserver does not support checking status of homeserver -> bridge connection") - } else if !br.Bot.EnsureAppserviceConnection(ctx) { - return ExitError{13} - } - return nil -} - -func (br *Connector) fetchCapabilities(ctx context.Context) *mautrix.RespCapabilities { - br.specCapsLock.Lock() - defer br.specCapsLock.Unlock() - if br.SpecCaps != nil { - return br.SpecCaps - } - caps, err := br.Bot.Capabilities(ctx) - if err != nil { - br.Log.Err(err).Msg("Failed to fetch capabilities from homeserver") - return nil - } - br.SpecCaps = caps - return caps -} - -func (br *Connector) fetchMediaConfig(ctx context.Context) { - cfg, err := br.Bot.GetMediaConfig(ctx) - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to fetch media config") - } else { - if cfg.UploadSize == 0 { - cfg.UploadSize = 50 * 1024 * 1024 - } - br.MediaConfig = *cfg - mfsn, ok := br.Bridge.Network.(bridgev2.MaxFileSizeingNetwork) - if ok { - mfsn.SetMaxFileSize(br.MediaConfig.UploadSize) - } - br.uploadSema = semaphore.NewWeighted(br.MediaConfig.UploadSize + 1) - } -} - -func (br *Connector) UpdateBotProfile(ctx context.Context) { - br.Log.Debug().Msg("Updating bot profile") - botConfig := &br.Config.AppService.Bot - - var err error - var mxc id.ContentURI - if botConfig.Avatar == "remove" { - err = br.Bot.SetAvatarURL(ctx, mxc) - } else if !botConfig.ParsedAvatar.IsEmpty() { - err = br.Bot.SetAvatarURL(ctx, botConfig.ParsedAvatar) - } - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to update bot avatar") - } - - if botConfig.Displayname == "remove" { - err = br.Bot.SetDisplayName(ctx, "") - } else if len(botConfig.Displayname) > 0 { - err = br.Bot.SetDisplayName(ctx, botConfig.Displayname) - } - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to update bot displayname") - } - - if br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { - br.Log.Debug().Msg("Setting contact info on the appservice bot") - netName := br.Bridge.Network.GetName() - err = br.Bot.BeeperUpdateProfile(ctx, event.BeeperProfileExtra{ - Service: netName.BeeperBridgeType, - Network: netName.NetworkID, - IsBridgeBot: true, - }) - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to update bot contact info") - } - } -} - -func (br *Connector) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI { - return &ASIntent{ - Matrix: br.AS.Intent(br.FormatGhostMXID(userID)), - Connector: br, - } -} - -func (br *Connector) SendBridgeStatus(ctx context.Context, state *status.BridgeState) error { - if br.Websocket { - br.hasSentAnyStates = true - return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{ - Command: "bridge_status", - Data: state, - }) - } else if br.Config.Homeserver.StatusEndpoint != "" { - // Connecting states aren't really relevant unless the bridge runs somewhere with an unreliable network - if state.StateEvent == status.StateConnecting { - return nil - } - return state.SendHTTP(ctx, br.Config.Homeserver.StatusEndpoint, br.Config.AppService.ASToken) - } else { - return nil - } -} - -func (br *Connector) SendMessageStatus(ctx context.Context, ms *bridgev2.MessageStatus, evt *bridgev2.MessageStatusEventInfo) { - go br.internalSendMessageStatus(ctx, ms, evt, "") -} - -func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2.MessageStatus, evt *bridgev2.MessageStatusEventInfo, editEvent id.EventID) id.EventID { - if evt.EventType.IsEphemeral() || evt.SourceEventID == "" { - return "" - } - log := zerolog.Ctx(ctx) - - if !evt.IsSourceEventDoublePuppeted { - err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{ms.ToCheckpoint(evt)}) - if err != nil { - log.Err(err).Msg("Failed to send message checkpoint") - } - } - - if !ms.DisableMSS && br.Config.Matrix.MessageStatusEvents { - mssEvt := ms.ToMSSEvent(evt) - _, err := br.Bot.SendMessageEvent(ctx, evt.RoomID, event.BeeperMessageStatus, mssEvt) - if err != nil { - log.Err(err). - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.SourceEventID). - Any("mss_content", mssEvt). - Msg("Failed to send MSS event") - } - } - if ms.SendNotice && br.Config.Matrix.MessageErrorNotices && evt.MessageType != event.MsgNotice && - (ms.Status == event.MessageStatusFail || ms.Status == event.MessageStatusRetriable || ms.Step == status.MsgStepDecrypted) { - content := ms.ToNoticeEvent(evt) - if editEvent != "" { - content.SetEdit(editEvent) - } - resp, err := br.Bot.SendMessageEvent(ctx, evt.RoomID, event.EventMessage, content) - if err != nil { - log.Err(err). - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.SourceEventID). - Str("notice_message", content.Body). - Msg("Failed to send notice event") - } else { - return resp.EventID - } - } - if ms.Status == event.MessageStatusSuccess && br.Config.Matrix.DeliveryReceipts { - err := br.Bot.SendReceipt(ctx, evt.RoomID, evt.SourceEventID, event.ReceiptTypeRead, nil) - if err != nil { - log.Err(err). - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.SourceEventID). - Msg("Failed to send Matrix delivery receipt") - } - } - return "" -} - -func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []*status.MessageCheckpoint) error { - checkpointsJSON := status.CheckpointsJSON{Checkpoints: checkpoints} - - if br.Websocket { - return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{ - Command: "message_checkpoint", - Data: checkpointsJSON, - }) - } - - endpoint := br.Config.Homeserver.MessageSendCheckpointEndpoint - if endpoint == "" { - return nil - } - - return checkpointsJSON.SendHTTP(ctx, br.AS.HTTPClient, endpoint, br.AS.Registration.AppToken) -} - -func (br *Connector) ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) { - match := br.userIDRegex.FindStringSubmatch(string(userID)) - if match == nil || userID == br.Bot.UserID { - return "", false - } - decoded, err := id.DecodeUserLocalpart(match[1]) - if err != nil { - return "", false - } - return networkid.UserID(decoded), true -} - -func (br *Connector) FormatGhostMXID(userID networkid.UserID) id.UserID { - localpart := br.Config.AppService.FormatUsername(id.EncodeUserLocalpart(string(userID))) - return id.NewUserID(localpart, br.Config.Homeserver.Domain) -} - -func (br *Connector) NewUserIntent(ctx context.Context, userID id.UserID, accessToken string) (bridgev2.MatrixAPI, string, error) { - intent, newToken, err := br.DoublePuppet.Setup(ctx, userID, accessToken) - if err != nil { - if errors.Is(err, ErrNoAccessToken) { - err = nil - } - return nil, accessToken, err - } - br.doublePuppetIntents.Set(userID, intent) - return &ASIntent{Connector: br, Matrix: intent}, newToken, nil -} - -func (br *Connector) BotIntent() bridgev2.MatrixAPI { - return &ASIntent{Connector: br, Matrix: br.Bot} -} - -func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) { - return br.Bot.PowerLevels(ctx, roomID) -} - -func (br *Connector) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) { - if stateKey == "" { - switch eventType { - case event.StateCreate: - createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID) - if err != nil || createEvt != nil { - return createEvt, err - } - case event.StateJoinRules: - joinRulesContent, err := br.Bot.StateStore.GetJoinRules(ctx, roomID) - if err != nil { - return nil, err - } else if joinRulesContent != nil { - return &event.Event{ - Type: event.StateJoinRules, - RoomID: roomID, - StateKey: ptr.Ptr(""), - Content: event.Content{Parsed: joinRulesContent}, - }, nil - } - } - } - return br.Bot.FullStateEvent(ctx, roomID, eventType, stateKey) -} - -func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { - fetched, err := br.Bot.StateStore.HasFetchedMembers(ctx, roomID) - if err != nil { - return nil, err - } else if fetched { - return br.Bot.StateStore.GetAllMembers(ctx, roomID) - } - members, err := br.Bot.Members(ctx, roomID) - if err != nil { - return nil, err - } - output := make(map[id.UserID]*event.MemberEventContent, len(members.Chunk)) - for _, evt := range members.Chunk { - output[id.UserID(evt.GetStateKey())] = evt.Content.AsMember() - } - return output, nil -} - -func (br *Connector) GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { - // TODO fetch from network sometimes? - return br.AS.StateStore.GetMember(ctx, roomID, userID) -} - -func (br *Connector) IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error) { - return br.AS.StateStore.IsConfusableName(ctx, roomID, userID, name) -} - -func (br *Connector) GetUniqueBridgeID() string { - return fmt.Sprintf("%s/%s", br.Config.Homeserver.Domain, br.Config.AppService.ID) -} - -func (br *Connector) isEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { - if br.Config.Encryption.Require { - return true, nil - } - return br.StateStore.IsEncrypted(ctx, roomID) -} - -func (br *Connector) BatchSend(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBeeperBatchSend, extras []*bridgev2.MatrixSendExtra) (*mautrix.RespBeeperBatchSend, error) { - if encrypted, err := br.isEncrypted(ctx, roomID); err != nil { - return nil, fmt.Errorf("failed to check if room is encrypted: %w", err) - } else if encrypted { - for _, evt := range req.Events { - intent, _ := br.doublePuppetIntents.Get(evt.Sender) - if intent != nil { - intent.AddDoublePuppetValueWithTS(&evt.Content, evt.Timestamp) - } - if evt.Type != event.EventEncrypted && evt.Type != event.EventReaction { - err = br.Crypto.Encrypt(ctx, roomID, evt.Type, &evt.Content) - if err != nil { - return nil, err - } - evt.Type = event.EventEncrypted - if intent != nil { - intent.AddDoublePuppetValueWithTS(&evt.Content, evt.Timestamp) - } - } - } - } - return br.Bot.BeeperBatchSend(ctx, roomID, req) -} - -func (br *Connector) GenerateDeterministicEventID(roomID id.RoomID, _ networkid.PortalKey, messageID networkid.MessageID, partID networkid.PartID) id.EventID { - data := make([]byte, 0, len(roomID)+1+len(messageID)+1+len(partID)) - data = append(data, roomID...) - data = append(data, 0) - data = append(data, messageID...) - data = append(data, 0) - data = append(data, partID...) - - hash := sha256.Sum256(data) - hashB64Len := base64.RawURLEncoding.EncodedLen(len(hash)) - - eventID := make([]byte, 1+hashB64Len+1+len(br.deterministicEventIDServer)) - eventID[0] = '$' - base64.RawURLEncoding.Encode(eventID[1:1+hashB64Len], hash[:]) - eventID[1+hashB64Len] = ':' - copy(eventID[1+hashB64Len+1:], br.deterministicEventIDServer) - - return id.EventID(exbytes.UnsafeString(eventID)) -} - -func (br *Connector) GenerateDeterministicRoomID(key networkid.PortalKey) id.RoomID { - return id.RoomID(fmt.Sprintf("!%s.%s:%s", key.ID, key.Receiver, br.ServerName())) -} - -func (br *Connector) GenerateReactionEventID(roomID id.RoomID, targetMessage *database.Message, sender networkid.UserID, emojiID networkid.EmojiID) id.EventID { - // We don't care about determinism for reactions - return id.EventID(fmt.Sprintf("$%s:%s", base64.RawURLEncoding.EncodeToString(random.Bytes(32)), br.deterministicEventIDServer)) -} - -func (br *Connector) ServerName() string { - return br.Config.Homeserver.Domain -} - -func (br *Connector) HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error { - _, err := br.Bot.Members(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch members in newly bridged room") - } - if !br.Config.Encryption.Default { - return nil - } - _, err = br.Bot.SendStateEvent(ctx, roomID, event.StateEncryption, "", &event.Content{ - Parsed: br.getDefaultEncryptionEvent(), - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to enable encryption in newly bridged room") - return fmt.Errorf("failed to enable encryption") - } - return nil -} - -func (br *Connector) GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error) { - return br.Bot.GetURLPreview(ctx, url) -} diff --git a/mautrix-patched/bridgev2/matrix/crypto.go b/mautrix-patched/bridgev2/matrix/crypto.go deleted file mode 100644 index 073e7b85..00000000 --- a/mautrix-patched/bridgev2/matrix/crypto.go +++ /dev/null @@ -1,607 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build cgo && !nocrypto - -package matrix - -import ( - "context" - "errors" - "fmt" - "os" - "runtime/debug" - "strings" - "sync" - "time" - - "github.com/lib/pq" - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/beeperstream" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/sqlstatestore" -) - -func init() { - crypto.PostgresArrayWrapper = pq.Array -} - -var _ crypto.StateStore = (*sqlstatestore.SQLStateStore)(nil) - -var NoSessionFound = crypto.ErrNoSessionFound -var DuplicateMessageIndex = crypto.ErrDuplicateMessageIndex -var UnknownMessageIndex = olm.ErrUnknownMessageIndex - -type CryptoHelper struct { - bridge *Connector - client *mautrix.Client - mach *crypto.OlmMachine - store *SQLCryptoStore - log *zerolog.Logger - streams *beeperstream.Helper - - lock sync.RWMutex - syncDone sync.WaitGroup - cancelSync func() - - cancelPeriodicDeleteLoop func() -} - -func NewCryptoHelper(c *Connector) Crypto { - if !c.Config.Encryption.Allow { - c.Log.Debug().Msg("Bridge built with end-to-bridge encryption, but disabled in config") - return nil - } - log := c.Log.With().Str("component", "crypto").Logger() - return &CryptoHelper{ - bridge: c, - log: &log, - } -} - -func (helper *CryptoHelper) Init(ctx context.Context) error { - if len(helper.bridge.Config.Encryption.PickleKey) == 0 { - panic("CryptoPickleKey not set") - } - helper.log.Debug().Msg("Initializing end-to-bridge encryption...") - - helper.store = NewSQLCryptoStore( - helper.bridge.Bridge.DB.Database, - dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()), - string(helper.bridge.Bridge.ID), - helper.bridge.AS.BotMXID(), - fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain), - helper.bridge.Config.Encryption.PickleKey, - ) - - err := helper.store.DB.Upgrade(ctx) - if err != nil { - return bridgev2.DBUpgradeError{Section: "crypto", Err: err} - } - - var isExistingDevice bool - helper.client, isExistingDevice, err = helper.loginBot(ctx) - if err != nil { - return err - } - - helper.log.Debug(). - Str("device_id", helper.client.DeviceID.String()). - Msg("Logged in as bridge bot") - helper.mach = crypto.NewOlmMachine(helper.client, helper.log, helper.store, helper.bridge.StateStore) - helper.mach.DisableSharedGroupSessionTracking = true - helper.mach.AllowKeyShare = helper.allowKeyShare - - encryptionConfig := helper.bridge.Config.Encryption - helper.mach.SendKeysMinTrust = encryptionConfig.VerificationLevels.Receive - helper.mach.PlaintextMentions = encryptionConfig.PlaintextMentions - - helper.mach.DeleteOutboundKeysOnAck = encryptionConfig.DeleteKeys.DeleteOutboundOnAck - helper.mach.DontStoreOutboundKeys = encryptionConfig.DeleteKeys.DontStoreOutbound - helper.mach.RatchetKeysOnDecrypt = encryptionConfig.DeleteKeys.RatchetOnDecrypt - helper.mach.DeleteFullyUsedKeysOnDecrypt = encryptionConfig.DeleteKeys.DeleteFullyUsedOnDecrypt - helper.mach.DeletePreviousKeysOnReceive = encryptionConfig.DeleteKeys.DeletePrevOnNewSession - helper.mach.DeleteKeysOnDeviceDelete = encryptionConfig.DeleteKeys.DeleteOnDeviceDelete - helper.mach.DisableDeviceChangeKeyRotation = encryptionConfig.Rotation.DisableDeviceChangeKeyRotation - if encryptionConfig.DeleteKeys.PeriodicallyDeleteExpired { - ctx, cancel := context.WithCancel(context.Background()) - helper.cancelPeriodicDeleteLoop = cancel - go helper.mach.ExpiredKeyDeleteLoop(ctx) - } - - if encryptionConfig.DeleteKeys.DeleteOutdatedInbound { - deleted, err := helper.store.RedactOutdatedGroupSessions(ctx) - if err != nil { - return err - } - if len(deleted) > 0 { - helper.log.Debug().Int("deleted", len(deleted)).Msg("Deleted inbound keys which lacked expiration metadata") - } - } - - streams, err := beeperstream.New(helper.client) - if err != nil { - return err - } - helper.streams = streams - helper.client.Syncer = &cryptoSyncer{OlmMachine: helper.mach, handleSyncResponse: streams.HandleSyncResponse} - helper.client.Store = helper.store - - err = helper.mach.Load(ctx) - if err != nil { - return err - } - if isExistingDevice { - if ok, err := helper.verifyKeysAreOnServer(ctx); err != nil { - return err - } else if !ok { - return nil - } - } else { - err = helper.ShareKeys(ctx) - if err != nil { - return fmt.Errorf("failed to share device keys: %w", err) - } - } - if helper.bridge.Config.Encryption.SelfSign { - if !helper.doSelfSign(ctx) { - return ExitError{34} - } - } - - go helper.resyncEncryptionInfo(context.TODO()) - - return nil -} - -func (helper *CryptoHelper) doSelfSign(ctx context.Context) bool { - log := zerolog.Ctx(ctx) - hasKeys, isVerified, err := helper.mach.GetOwnVerificationStatus(ctx) - if err != nil { - log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check verification status") - return false - } - mkVerified, sskVerified, uskVerified, err := helper.mach.GetOwnCrossSigningVerificationStatus(ctx) - if err != nil { - log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check cross-signing key verification status") - return false - } - log.Debug(). - Bool("has_keys", hasKeys). - Bool("device_verified", isVerified). - Bool("mk_verified", mkVerified). - Bool("usk_verified", uskVerified). - Bool("ssk_verified", sskVerified). - Msg("Checked verification status") - keyInDB := helper.bridge.Bridge.DB.KV.Get(ctx, database.KeyRecoveryKey) - if !hasKeys || keyInDB == "overwrite" { - if keyInDB != "" && keyInDB != "overwrite" { - log.WithLevel(zerolog.FatalLevel). - Msg("No keys on server, but database already has recovery key. Delete `recovery_key` from `kv_store` manually to continue.") - return false - } - recoveryKey, err := helper.mach.GenerateAndVerifyWithRecoveryKey(ctx) - if recoveryKey != "" { - helper.bridge.Bridge.DB.KV.Set(ctx, database.KeyRecoveryKey, recoveryKey) - } - if err != nil { - log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to generate recovery key and self-sign") - return false - } - log.Info().Msg("Generated new recovery key and self-signed bot device") - } else if !isVerified || !mkVerified { - if keyInDB == "" { - log.WithLevel(zerolog.FatalLevel). - Msg("Server already has cross-signing keys, but no key in database. Add `recovery_key` to `kv_store`, or set it to `overwrite` to generate new keys.") - return false - } - err = helper.mach.VerifyWithRecoveryKey(ctx, keyInDB) - if err != nil { - log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to verify with recovery key") - return false - } - log.Info().Msg("Verified bot device with existing recovery key") - } - return true -} - -func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) { - log := helper.log.With().Str("action", "resync encryption event").Logger() - rows, err := helper.store.DB.Query(ctx, `SELECT room_id FROM mx_room_state WHERE encryption='{"resync":true}'`) - roomIDs, err := dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.RoomID], err).AsList() - if err != nil { - log.Err(err).Msg("Failed to query rooms for resync") - return - } - if len(roomIDs) > 0 { - log.Debug().Interface("room_ids", roomIDs).Msg("Resyncing rooms") - for _, roomID := range roomIDs { - var evt event.EncryptionEventContent - err = helper.client.StateEvent(ctx, roomID, event.StateEncryption, "", &evt) - if err != nil { - log.Err(err).Stringer("room_id", roomID).Msg("Failed to get encryption event") - _, err = helper.store.DB.Exec(ctx, ` - UPDATE mx_room_state SET encryption=NULL WHERE room_id=$1 AND encryption='{"resync":true}' - `, roomID) - if err != nil { - log.Err(err).Stringer("room_id", roomID).Msg("Failed to unmark room for resync after failed sync") - } - } else { - maxAge := evt.RotationPeriodMillis - if maxAge <= 0 { - maxAge = (7 * 24 * time.Hour).Milliseconds() - } - maxMessages := evt.RotationPeriodMessages - if maxMessages <= 0 { - maxMessages = 100 - } - log.Debug(). - Str("room_id", roomID.String()). - Int64("max_age_ms", maxAge). - Int("max_messages", maxMessages). - Interface("content", &evt). - Msg("Resynced encryption event") - _, err = helper.store.DB.Exec(ctx, ` - UPDATE crypto_megolm_inbound_session - SET max_age=$1, max_messages=$2 - WHERE room_id=$3 AND max_age IS NULL AND max_messages IS NULL - `, maxAge, maxMessages, roomID) - if err != nil { - log.Err(err).Stringer("room_id", roomID).Msg("Failed to update megolm session table") - } else { - log.Debug().Stringer("room_id", roomID).Msg("Updated megolm session table") - } - } - } - } -} - -func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device, info event.RequestedKeyInfo) *crypto.KeyShareRejection { - cfg := helper.bridge.Config.Encryption - if !cfg.AllowKeySharing { - return &crypto.KeyShareRejectNoResponse - } else if device.Trust == id.TrustStateBlacklisted { - return &crypto.KeyShareRejectBlacklisted - } else if trustState, _ := helper.mach.ResolveTrustContext(ctx, device); trustState >= cfg.VerificationLevels.Share { - portal, err := helper.bridge.Bridge.GetPortalByMXID(ctx, info.RoomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal to handle key request") - return &crypto.KeyShareRejectNoResponse - } else if portal == nil { - zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: room is not a portal") - return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"} - } - user, err := helper.bridge.Bridge.GetExistingUserByMXID(ctx, device.UserID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle key request") - return &crypto.KeyShareRejectNoResponse - } else if user == nil { - zerolog.Ctx(ctx).Debug().Msg("Couldn't find user to handle key request") - return &crypto.KeyShareRejectNoResponse - } else if !user.Permissions.Admin { - zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: user is not admin") - // TODO is in room check? - return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "Key sharing for non-admins is not yet implemented"} - } - zerolog.Ctx(ctx).Debug().Msg("Accepting key request") - return nil - } else { - return &crypto.KeyShareRejectUnverified - } -} - -func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool, error) { - deviceID, err := helper.store.FindDeviceID(ctx) - if err != nil { - return nil, false, fmt.Errorf("failed to find existing device ID: %w", err) - } else if len(deviceID) > 0 { - helper.log.Debug().Stringer("device_id", deviceID).Msg("Found existing device ID for bot in database") - } - // Create a new client instance with the default AS settings (including as_token), - // the Login call will then override the access token in the client. - client := helper.bridge.AS.NewMautrixClient(helper.bridge.AS.BotMXID()) - - initialDeviceDisplayName := fmt.Sprintf("%s bridge", helper.bridge.Bridge.Network.GetName().DisplayName) - if helper.bridge.Config.Encryption.MSC4190 { - helper.log.Debug().Msg("Creating bot device with MSC4190") - err = client.CreateDeviceMSC4190(ctx, deviceID, initialDeviceDisplayName) - if err != nil { - return nil, deviceID != "", fmt.Errorf("failed to create device for bridge bot: %w", err) - } - helper.store.DeviceID = client.DeviceID - return client, deviceID != "", nil - } - - flows, err := client.GetLoginFlows(ctx) - if err != nil { - return nil, deviceID != "", fmt.Errorf("failed to get supported login flows: %w", err) - } else if !flows.HasFlow(mautrix.AuthTypeAppservice) { - return nil, deviceID != "", fmt.Errorf("homeserver does not support appservice login") - } - - resp, err := client.Login(ctx, &mautrix.ReqLogin{ - Type: mautrix.AuthTypeAppservice, - Identifier: mautrix.UserIdentifier{ - Type: mautrix.IdentifierTypeUser, - User: string(helper.bridge.AS.BotMXID()), - }, - DeviceID: deviceID, - StoreCredentials: true, - InitialDeviceDisplayName: initialDeviceDisplayName, - }) - if err != nil { - return nil, deviceID != "", fmt.Errorf("failed to log in as bridge bot: %w", err) - } - helper.store.DeviceID = resp.DeviceID - return client, deviceID != "", nil -} - -func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) (bool, error) { - helper.log.Debug().Msg("Making sure keys are still on server") - resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ - DeviceKeys: map[id.UserID]mautrix.DeviceIDList{ - helper.client.UserID: {helper.client.DeviceID}, - }, - }) - if err != nil { - helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to query own keys to make sure device still exists") - return false, fmt.Errorf("%w: failed to query own keys to make sure device still exists:%w", ExitError{33}, err) - } - device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID] - if ok && len(device.Keys) > 0 { - return true, nil - } - helper.log.Warn().Msg("Existing device doesn't have keys on server, resetting crypto") - return false, helper.Reset(ctx, false) -} - -func (helper *CryptoHelper) Start() { - if helper.bridge.Config.Encryption.Appservice { - helper.log.Debug().Msg("End-to-bridge encryption is in appservice mode, registering event listeners and not starting syncer") - helper.bridge.AS.Registration.EphemeralEvents = true - helper.mach.AddAppserviceListener(helper.bridge.EventProcessor) - if helper.streams != nil { - err := helper.streams.InitAppservice(helper.bridge.EventProcessor) - if err != nil { - helper.log.Err(err).Msg("Failed to initialize beeper stream appservice listener") - } - } - return - } - helper.syncDone.Add(1) - defer helper.syncDone.Done() - helper.log.Debug().Msg("Starting syncer for receiving to-device messages") - var ctx context.Context - ctx, helper.cancelSync = context.WithCancel(context.Background()) - err := helper.client.SyncWithContext(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Fatal error syncing") - os.Exit(51) - } else { - helper.log.Info().Msg("Bridge bot to-device syncer stopped without error") - } -} - -func (helper *CryptoHelper) Stop() { - helper.log.Debug().Msg("CryptoHelper.Stop() called, stopping bridge bot sync") - helper.client.StopSync() - if helper.cancelSync != nil { - helper.cancelSync() - } - if helper.cancelPeriodicDeleteLoop != nil { - helper.cancelPeriodicDeleteLoop() - } - helper.syncDone.Wait() - if helper.streams != nil { - _ = helper.streams.Close() - } -} - -func (helper *CryptoHelper) clearDatabase(ctx context.Context) { - _, err := helper.store.DB.Exec(ctx, "DELETE FROM crypto_account") - if err != nil { - helper.log.Warn().Err(err).Msg("Failed to clear crypto_account table") - } - _, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_olm_session") - if err != nil { - helper.log.Warn().Err(err).Msg("Failed to clear crypto_olm_session table") - } - _, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_megolm_outbound_session") - if err != nil { - helper.log.Warn().Err(err).Msg("Failed to clear crypto_megolm_outbound_session table") - } - //_, _ = helper.store.DB.Exec("DELETE FROM crypto_device") - //_, _ = helper.store.DB.Exec("DELETE FROM crypto_tracked_user") - //_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_keys") - //_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_signatures") -} - -func (helper *CryptoHelper) Reset(ctx context.Context, startAfterReset bool) error { - helper.lock.Lock() - defer helper.lock.Unlock() - helper.log.Info().Msg("Resetting end-to-bridge encryption device") - helper.Stop() - helper.log.Debug().Msg("Crypto syncer stopped, clearing database") - helper.clearDatabase(ctx) - helper.log.Debug().Msg("Crypto database cleared, logging out of all sessions") - _, err := helper.client.LogoutAll(ctx) - if err != nil { - helper.log.Warn().Err(err).Msg("Failed to log out all devices") - } - helper.client = nil - helper.store = nil - helper.mach = nil - err = helper.Init(ctx) - if err != nil { - helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Error reinitializing end-to-bridge encryption") - return ExitError{50} - } - helper.log.Info().Msg("End-to-bridge encryption successfully reset") - if startAfterReset { - go helper.Start() - } - return nil -} - -func (helper *CryptoHelper) Client() *mautrix.Client { - return helper.client -} - -func (helper *CryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) { - return helper.mach.DecryptMegolmEvent(ctx, evt) -} - -func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content *event.Content) (err error) { - helper.lock.RLock() - defer helper.lock.RUnlock() - var encrypted *event.EncryptedEventContent - encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content) - if err != nil { - if !errors.Is(err, crypto.ErrSessionExpired) && !errors.Is(err, crypto.ErrSessionNotShared) && !errors.Is(err, crypto.ErrNoGroupSession) { - return - } - helper.log.Debug().Err(err). - Str("room_id", roomID.String()). - Msg("Got error while encrypting event for room, sharing group session and trying again...") - var users []id.UserID - users, err = helper.store.GetRoomJoinedOrInvitedMembers(ctx, roomID) - if err != nil { - err = fmt.Errorf("failed to get room member list: %w", err) - } else if err = helper.mach.ShareGroupSession(ctx, roomID, users); err != nil { - err = fmt.Errorf("failed to share group session: %w", err) - } else if encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content); err != nil { - err = fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err) - } - } - if encrypted != nil { - content.Parsed = encrypted - content.Raw = nil - } - return -} - -func (helper *CryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool { - helper.lock.RLock() - defer helper.lock.RUnlock() - return helper.mach.WaitForSession(ctx, roomID, senderKey, sessionID, timeout) -} - -func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) { - helper.lock.RLock() - defer helper.lock.RUnlock() - if deviceID == "" { - deviceID = "*" - } - err := helper.mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}}) - if err != nil { - helper.log.Warn().Err(err). - Str("user_id", userID.String()). - Str("device_id", deviceID.String()). - Str("session_id", sessionID.String()). - Str("room_id", roomID.String()). - Msg("Failed to send key request") - } else { - helper.log.Debug(). - Str("user_id", userID.String()). - Str("device_id", deviceID.String()). - Str("session_id", sessionID.String()). - Str("room_id", roomID.String()). - Msg("Sent key request") - } -} - -func (helper *CryptoHelper) ResetSession(ctx context.Context, roomID id.RoomID) { - helper.lock.RLock() - defer helper.lock.RUnlock() - err := helper.mach.CryptoStore.RemoveOutboundGroupSession(ctx, roomID) - if err != nil { - helper.log.Debug().Err(err). - Str("room_id", roomID.String()). - Msg("Error manually removing outbound group session in room") - } -} - -func (helper *CryptoHelper) HandleMemberEvent(ctx context.Context, evt *event.Event) { - helper.lock.RLock() - defer helper.lock.RUnlock() - helper.mach.HandleMemberEvent(ctx, evt) -} - -// ShareKeys uploads the given number of one-time-keys to the server. -func (helper *CryptoHelper) ShareKeys(ctx context.Context) error { - return helper.mach.ShareKeys(ctx, -1) -} - -func (helper *CryptoHelper) BeeperStreamPublisher() bridgev2.BeeperStreamPublisher { - if helper == nil { - return nil - } - return helper.streams -} - -type cryptoSyncer struct { - *crypto.OlmMachine - handleSyncResponse func(context.Context, *mautrix.RespSync) []*event.Event -} - -func (syncer *cryptoSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error { - done := make(chan struct{}) - go func() { - defer func() { - if err := recover(); err != nil { - syncer.Log.Error(). - Str("since", since). - Interface("error", err). - Str("stack", string(debug.Stack())). - Msg("Processing sync response panicked") - } - done <- struct{}{} - }() - syncer.Log.Trace().Str("since", since).Msg("Starting sync response handling") - syncer.ProcessSyncResponse(ctx, resp, since) - if syncer.handleSyncResponse != nil { - syncer.handleSyncResponse(ctx, resp) - } - syncer.Log.Trace().Str("since", since).Msg("Successfully handled sync response") - }() - select { - case <-done: - case <-time.After(30 * time.Second): - syncer.Log.Warn().Str("since", since).Msg("Handling sync response is taking unusually long") - } - return nil -} - -func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) { - if errors.Is(err, mautrix.MUnknownToken) { - return 0, err - } - syncer.Log.Error().Err(err).Msg("Error /syncing, waiting 10 seconds") - return 10 * time.Second, nil -} - -func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { - everything := []event.Type{{Type: "*"}} - return &mautrix.Filter{ - Presence: &mautrix.FilterPart{NotTypes: everything}, - AccountData: &mautrix.FilterPart{NotTypes: everything}, - Room: &mautrix.RoomFilter{ - IncludeLeave: false, - Ephemeral: &mautrix.FilterPart{NotTypes: everything}, - AccountData: &mautrix.FilterPart{NotTypes: everything}, - State: &mautrix.FilterPart{NotTypes: everything}, - Timeline: &mautrix.FilterPart{NotTypes: everything}, - }, - } -} diff --git a/mautrix-patched/bridgev2/matrix/cryptoerror.go b/mautrix-patched/bridgev2/matrix/cryptoerror.go deleted file mode 100644 index ea29703a..00000000 --- a/mautrix-patched/bridgev2/matrix/cryptoerror.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "errors" - "fmt" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ( - errDeviceNotTrusted = errors.New("your device is not trusted") - errMessageNotEncrypted = errors.New("unencrypted message") - errNoDecryptionKeys = errors.New("the bridge hasn't received the decryption keys") - errNoCrypto = errors.New("this bridge has not been configured to support encryption") -) - -func errorToHumanMessage(err error) string { - var withheld *event.RoomKeyWithheldEventContent - switch { - case errors.Is(err, errDeviceNotTrusted), errors.Is(err, errNoDecryptionKeys), errors.Is(err, errNoCrypto): - return err.Error() - case errors.Is(err, UnknownMessageIndex): - return "the keys received by the bridge can't decrypt the message" - case errors.Is(err, DuplicateMessageIndex): - return "your client encrypted multiple messages with the same key" - case errors.As(err, &withheld): - if withheld.Code == event.RoomKeyWithheldBeeperRedacted { - return "your client used an outdated encryption session" - } - return "your client refused to share decryption keys with the bridge" - case errors.Is(err, errMessageNotEncrypted): - return "the message is not encrypted" - default: - return "the bridge failed to decrypt the message" - } -} - -func deviceUnverifiedErrorWithExplanation(trust id.TrustState) error { - var explanation string - switch trust { - case id.TrustStateBlacklisted: - explanation = "device is blacklisted" - case id.TrustStateUnset: - explanation = "unverified" - case id.TrustStateUnknownDevice: - explanation = "device info not found" - case id.TrustStateForwarded: - explanation = "keys were forwarded from an unknown device" - case id.TrustStateCrossSignedUntrusted: - explanation = "cross-signing keys changed after setting up the bridge" - default: - return errDeviceNotTrusted - } - return fmt.Errorf("%w (%s)", errDeviceNotTrusted, explanation) -} - -func (br *Connector) sendCryptoStatusError(ctx context.Context, evt *event.Event, err error, errorEventID *id.EventID, retryNum int, isFinal bool) { - ms := &bridgev2.MessageStatus{ - Step: status.MsgStepDecrypted, - Status: event.MessageStatusRetriable, - ErrorReason: event.MessageStatusUndecryptable, - InternalError: err, - Message: errorToHumanMessage(err), - IsCertain: true, - SendNotice: true, - RetryNum: retryNum, - } - if !isFinal { - ms.Status = event.MessageStatusPending - // Don't send notice for first error - if retryNum == 0 { - ms.SendNotice = false - ms.DisableMSS = true - } - } - var editEventID id.EventID - if errorEventID != nil { - editEventID = *errorEventID - } - respEventID := br.internalSendMessageStatus(ctx, ms, bridgev2.StatusEventInfoFromEvent(evt), editEventID) - if errorEventID != nil && *errorEventID == "" { - *errorEventID = respEventID - } -} diff --git a/mautrix-patched/bridgev2/matrix/cryptostore.go b/mautrix-patched/bridgev2/matrix/cryptostore.go deleted file mode 100644 index 4c3b5d30..00000000 --- a/mautrix-patched/bridgev2/matrix/cryptostore.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build cgo && !nocrypto - -package matrix - -import ( - "context" - - "github.com/lib/pq" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/id" -) - -func init() { - crypto.PostgresArrayWrapper = pq.Array -} - -type SQLCryptoStore struct { - *crypto.SQLCryptoStore - UserID id.UserID - GhostIDFormat string -} - -var _ crypto.Store = (*SQLCryptoStore)(nil) - -func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, accountID string, userID id.UserID, ghostIDFormat, pickleKey string) *SQLCryptoStore { - return &SQLCryptoStore{ - SQLCryptoStore: crypto.NewSQLCryptoStore(db, log, accountID, "", []byte(pickleKey)), - UserID: userID, - GhostIDFormat: ghostIDFormat, - } -} - -func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) (members []id.UserID, err error) { - var rows dbutil.Rows - rows, err = store.DB.Query(ctx, ` - SELECT user_id FROM mx_user_profile - WHERE room_id=$1 - AND (membership='join' OR membership='invite') - AND user_id<>$2 - AND user_id NOT LIKE $3 ESCAPE '\' - `, roomID, store.UserID, store.GhostIDFormat) - if err != nil { - return - } - for rows.Next() { - var userID id.UserID - err = rows.Scan(&userID) - if err != nil { - return members, err - } else { - members = append(members, userID) - } - } - return -} diff --git a/mautrix-patched/bridgev2/matrix/directmedia.go b/mautrix-patched/bridgev2/matrix/directmedia.go deleted file mode 100644 index 3670ebab..00000000 --- a/mautrix-patched/bridgev2/matrix/directmedia.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "fmt" - "strings" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/mediaproxy" -) - -const MediaIDPrefix = "\U0001F408" -const MediaIDTruncatedHashLength = 16 -const ContentURIMaxLength = 255 - -func (br *Connector) initDirectMedia() error { - if !br.Config.DirectMedia.Enabled { - return nil - } - dmn, ok := br.Bridge.Network.(bridgev2.DirectMediableNetwork) - if !ok { - return fmt.Errorf("direct media is enabled in config, but the network connector does not support it") - } - var err error - br.MediaProxy, err = mediaproxy.NewFromConfig(br.Config.DirectMedia.BasicConfig, br.getDirectMedia) - if err != nil { - return fmt.Errorf("failed to initialize media proxy: %w", err) - } - br.MediaProxy.RegisterRoutes(br.AS.Router, br.Log.With().Str("component", "media proxy").Logger()) - br.dmaSigKey = sha256.Sum256(br.MediaProxy.GetServerKey().Priv.Seed()) - dmn.SetUseDirectMedia() - br.Log.Debug().Str("server_name", br.MediaProxy.GetServerName()).Msg("Enabled direct media access") - return nil -} - -func (br *Connector) hashMediaID(data []byte) []byte { - hasher := hmac.New(sha256.New, br.dmaSigKey[:]) - hasher.Write(data) - return hasher.Sum(nil)[:MediaIDTruncatedHashLength] -} - -func (br *Connector) GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) { - if br.MediaProxy == nil { - return "", bridgev2.ErrDirectMediaNotEnabled - } - buf := make([]byte, len(MediaIDPrefix)+len(mediaID)+MediaIDTruncatedHashLength) - copy(buf, MediaIDPrefix) - copy(buf[len(MediaIDPrefix):], mediaID) - truncatedHash := br.hashMediaID(buf[:len(MediaIDPrefix)+len(mediaID)]) - copy(buf[len(MediaIDPrefix)+len(mediaID):], truncatedHash) - mxc := id.ContentURI{ - Homeserver: br.MediaProxy.GetServerName(), - FileID: br.Config.DirectMedia.MediaIDPrefix + base64.RawURLEncoding.EncodeToString(buf), - }.CUString() - if len(mxc) > ContentURIMaxLength { - return "", fmt.Errorf("content URI too long (%d > %d)", len(mxc), ContentURIMaxLength) - } - return mxc, nil -} - -func (br *Connector) ParseContentURI(ctx context.Context, mxc id.ContentURIString) (networkid.MediaID, error) { - if br.MediaProxy == nil { - return nil, bridgev2.ErrDirectMediaNotEnabled - } - parsed, err := mxc.Parse() - if err != nil { - return nil, fmt.Errorf("failed to parse mxc URI: %w", err) - } else if parsed.Homeserver != br.MediaProxy.GetServerName() { - return nil, fmt.Errorf("mxc URI homeserver does not match media proxy server name") - } - return br.parseMediaID(parsed.FileID) -} - -func (br *Connector) parseMediaID(mediaIDStr string) (networkid.MediaID, error) { - mediaID, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(mediaIDStr, br.Config.DirectMedia.MediaIDPrefix)) - if err != nil || !bytes.HasPrefix(mediaID, []byte(MediaIDPrefix)) || len(mediaID) < len(MediaIDPrefix)+MediaIDTruncatedHashLength+1 { - return nil, mediaproxy.ErrInvalidMediaIDSyntax - } - receivedHash := mediaID[len(mediaID)-MediaIDTruncatedHashLength:] - expectedHash := br.hashMediaID(mediaID[:len(mediaID)-MediaIDTruncatedHashLength]) - if !hmac.Equal(receivedHash, expectedHash) { - return nil, mautrix.MNotFound.WithMessage("Invalid checksum in media ID part") - } - remoteMediaID := networkid.MediaID(mediaID[len(MediaIDPrefix) : len(mediaID)-MediaIDTruncatedHashLength]) - return remoteMediaID, nil -} - -func (br *Connector) getDirectMedia(ctx context.Context, mediaIDStr string, params map[string]string) (response mediaproxy.GetMediaResponse, err error) { - remoteMediaID, err := br.parseMediaID(mediaIDStr) - if err != nil { - return response, err - } - return br.Bridge.Network.(bridgev2.DirectMediableNetwork).Download(ctx, remoteMediaID, params) -} diff --git a/mautrix-patched/bridgev2/matrix/doublepuppet.go b/mautrix-patched/bridgev2/matrix/doublepuppet.go deleted file mode 100644 index ace33f30..00000000 --- a/mautrix-patched/bridgev2/matrix/doublepuppet.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/id" -) - -type doublePuppetUtil struct { - br *Connector - - discoveryCache map[string]string - discoveryCacheLock sync.Mutex -} - -func newDoublePuppetUtil(br *Connector) *doublePuppetUtil { - return &doublePuppetUtil{ - br: br, - discoveryCache: make(map[string]string), - } -} - -func (dp *doublePuppetUtil) newClient(ctx context.Context, mxid id.UserID, accessToken string) (*mautrix.Client, error) { - _, homeserver, err := mxid.Parse() - if err != nil { - return nil, err - } - homeserverURL, found := dp.br.Config.DoublePuppet.Servers[homeserver] - if !found { - if homeserver == dp.br.AS.HomeserverDomain { - homeserverURL = "" - } else if dp.br.Config.DoublePuppet.AllowDiscovery { - dp.discoveryCacheLock.Lock() - defer dp.discoveryCacheLock.Unlock() - if homeserverURL, found = dp.discoveryCache[homeserver]; !found { - resp, err := mautrix.DiscoverClientAPI(ctx, homeserver) - if err != nil { - return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err) - } - homeserverURL = resp.Homeserver.BaseURL - dp.discoveryCache[homeserver] = homeserverURL - zerolog.Ctx(ctx).Debug(). - Str("homeserver", homeserver). - Str("url", homeserverURL). - Str("user_id", mxid.String()). - Msg("Discovered URL to enable double puppeting for user") - } - } else { - return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver) - } - } - return dp.br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL) -} - -func (dp *doublePuppetUtil) newIntent(ctx context.Context, mxid id.UserID, accessToken string) (*appservice.IntentAPI, error) { - client, err := dp.newClient(ctx, mxid, accessToken) - if err != nil { - return nil, err - } - - ia := dp.br.AS.NewIntentAPI("custom") - ia.Client = client - ia.Localpart, _, _ = mxid.Parse() - ia.UserID = mxid - ia.IsCustomPuppet = true - return ia, nil -} - -var ( - ErrMismatchingMXID = errors.New("whoami result does not match custom mxid") - ErrNoAccessToken = errors.New("no access token provided") - ErrNoMXID = errors.New("no mxid provided") -) - -const useConfigASToken = "appservice-config" -const asTokenModePrefix = "as_token:" - -func (dp *doublePuppetUtil) Setup(ctx context.Context, mxid id.UserID, savedAccessToken string) (intent *appservice.IntentAPI, newAccessToken string, err error) { - if len(mxid) == 0 { - err = ErrNoMXID - return - } - _, homeserver, _ := mxid.Parse() - loginSecret, hasSecret := dp.br.Config.DoublePuppet.Secrets[homeserver] - if hasSecret && strings.HasPrefix(loginSecret, asTokenModePrefix) { - intent, err = dp.newIntent(ctx, mxid, strings.TrimPrefix(loginSecret, asTokenModePrefix)) - if err != nil { - return - } - intent.SetAppServiceUserID = true - if savedAccessToken != useConfigASToken { - var resp *mautrix.RespWhoami - resp, err = intent.Whoami(ctx) - if err == nil && resp.UserID != mxid { - err = ErrMismatchingMXID - } - } - return intent, useConfigASToken, err - } else if savedAccessToken == "" || savedAccessToken == useConfigASToken { - err = ErrNoAccessToken - return - } - intent, err = dp.newIntent(ctx, mxid, savedAccessToken) - if err != nil { - return - } - var resp *mautrix.RespWhoami - resp, err = intent.Whoami(ctx) - if err == nil { - if resp.UserID != mxid { - err = ErrMismatchingMXID - } else { - newAccessToken = savedAccessToken - } - } - return -} diff --git a/mautrix-patched/bridgev2/matrix/exiterror.go b/mautrix-patched/bridgev2/matrix/exiterror.go deleted file mode 100644 index 451d7cd3..00000000 --- a/mautrix-patched/bridgev2/matrix/exiterror.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "fmt" - "os" -) - -type ExitError struct { - ExitCode int -} - -func (e ExitError) Error() string { - return fmt.Sprintf("exit with code %d", e.ExitCode) -} - -func (e ExitError) Exit() { - os.Exit(e.ExitCode) -} diff --git a/mautrix-patched/bridgev2/matrix/intent.go b/mautrix-patched/bridgev2/matrix/intent.go deleted file mode 100644 index 9a1c0a94..00000000 --- a/mautrix-patched/bridgev2/matrix/intent.go +++ /dev/null @@ -1,791 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - "sync" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/fallocate" - "go.mau.fi/util/ptr" - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" -) - -// ASIntent implements the bridge ghost API interface using a real Matrix homeserver as the backend. -type ASIntent struct { - Matrix *appservice.IntentAPI - Connector *Connector - - dmUpdateLock sync.Mutex - directChatsCache event.DirectChatsEventContent -} - -var _ bridgev2.MatrixAPI = (*ASIntent)(nil) -var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil) - -func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { - if extra == nil { - extra = &bridgev2.MatrixSendExtra{} - } - if eventType == event.EventRedaction && !as.Connector.SpecVersions.Supports(mautrix.FeatureRedactSendAsEvent) { - parsedContent := content.Parsed.(*event.RedactionEventContent) - as.Matrix.AddDoublePuppetValue(content) - if parsedContent.DontRenderPlaceholder { - if content.Raw == nil { - content.Raw = make(map[string]any) - } - content.Raw["com.beeper.dont_render_redacted_placeholder"] = true - } - return as.Matrix.RedactEvent(ctx, roomID, parsedContent.Redacts, mautrix.ReqRedact{ - Reason: parsedContent.Reason, - Extra: content.Raw, - }) - } - if (eventType != event.EventReaction || as.Connector.Config.Encryption.MSC4392) && eventType != event.EventRedaction { - msgContent, ok := content.Parsed.(*event.MessageEventContent) - if ok && eventType == event.EventMessage { - msgContent.AddPerMessageProfileFallback() - } - if encrypted, err := as.Connector.isEncrypted(ctx, roomID); err != nil { - return nil, fmt.Errorf("failed to check if room is encrypted: %w", err) - } else if encrypted { - if as.Connector.Crypto == nil { - return nil, fmt.Errorf("room is encrypted, but bridge isn't configured to support encryption") - } - if as.Matrix.IsCustomPuppet { - if extra.Timestamp.IsZero() { - as.Matrix.AddDoublePuppetValue(content) - } else { - as.Matrix.AddDoublePuppetValueWithTS(content, extra.Timestamp.UnixMilli()) - } - } - err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content) - if err != nil { - return nil, err - } - eventType = event.EventEncrypted - } - } - return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()}) -} - -func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) { - targetContent, ok := content.Parsed.(*event.MemberEventContent) - if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" { - return - } - memberContent, err := as.Matrix.StateStore.TryGetMember(ctx, roomID, userID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("target_user_id", userID). - Str("membership", string(targetContent.Membership)). - Msg("Failed to get old member content from state store to fill new membership event") - } else if memberContent != nil { - targetContent.Displayname = memberContent.Displayname - targetContent.AvatarURL = memberContent.AvatarURL - } else if ghost, err := as.Connector.Bridge.GetGhostByMXID(ctx, userID); err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("target_user_id", userID). - Str("membership", string(targetContent.Membership)). - Msg("Failed to get ghost to fill new membership event") - } else if ghost != nil { - targetContent.Displayname = ghost.Name - targetContent.AvatarURL = ghost.AvatarMXC - } else if profile, err := as.Matrix.GetProfile(ctx, userID); err != nil { - zerolog.Ctx(ctx).Debug().Err(err). - Stringer("target_user_id", userID). - Str("membership", string(targetContent.Membership)). - Msg("Failed to get profile to fill new membership event") - } else if profile != nil { - targetContent.Displayname = profile.DisplayName - targetContent.AvatarURL = profile.AvatarURL.CUString() - } -} - -func (as *ASIntent) SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { - if eventType == event.StateMember { - as.fillMemberEvent(ctx, roomID, id.UserID(stateKey), content) - } - resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content, mautrix.ReqSendEvent{Timestamp: ts.UnixMilli()}) - if err != nil && eventType == event.StateMember { - var httpErr mautrix.HTTPError - if errors.As(err, &httpErr) && httpErr.RespError != nil && - (strings.Contains(httpErr.RespError.Err, "is already in the room") || strings.Contains(httpErr.RespError.Err, "is already joined to room")) { - err = as.Matrix.StateStore.SetMembership(ctx, roomID, id.UserID(stateKey), event.MembershipJoin) - } - } - return resp, err -} - -func (as *ASIntent) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, ts time.Time) (err error) { - extraData := map[string]any{} - if !ts.IsZero() { - extraData["ts"] = ts.UnixMilli() - } - as.Matrix.AddDoublePuppetValue(extraData) - req := mautrix.ReqSetReadMarkers{ - Read: eventID, - BeeperReadExtra: extraData, - } - if as.Matrix.IsCustomPuppet { - req.FullyRead = eventID - req.BeeperFullyReadExtra = extraData - } - if as.Matrix.IsCustomPuppet && as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureInboxState) && as.Connector.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { - err = as.Matrix.SetBeeperInboxState(ctx, roomID, &mautrix.ReqSetBeeperInboxState{ - //MarkedUnread: ptr.Ptr(false), - ReadMarkers: &req, - }) - } else { - err = as.Matrix.SetReadMarkers(ctx, roomID, &req) - if err == nil && as.Matrix.IsCustomPuppet && as.Connector.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { - err = as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataMarkedUnread.Type, &event.MarkedUnreadEventContent{ - Unread: false, - }) - } - } - return -} - -func (as *ASIntent) MarkUnread(ctx context.Context, roomID id.RoomID, unread bool) error { - if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - return nil - } - if as.Matrix.IsCustomPuppet && as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureInboxState) { - return as.Matrix.SetBeeperInboxState(ctx, roomID, &mautrix.ReqSetBeeperInboxState{ - MarkedUnread: ptr.Ptr(unread), - }) - } else { - return as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataMarkedUnread.Type, &event.MarkedUnreadEventContent{ - Unread: unread, - }) - } -} - -func (as *ASIntent) MarkTyping(ctx context.Context, roomID id.RoomID, typingType bridgev2.TypingType, timeout time.Duration) error { - if typingType != bridgev2.TypingTypeText { - return nil - } else if as.Matrix.IsCustomPuppet { - // Don't send double puppeted typing notifications, there's no good way to prevent echoing them - return nil - } - _, err := as.Matrix.UserTyping(ctx, roomID, timeout > 0, timeout) - return err -} - -func (as *ASIntent) DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) { - if file != nil { - uri = file.URL - } - parsedURI, err := uri.Parse() - if err != nil { - return nil, err - } - data, err := as.Matrix.DownloadBytes(ctx, parsedURI) - if err != nil { - return nil, err - } - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - return nil, err - } - } - return data, nil -} - -func (as *ASIntent) DownloadMediaToFile(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo, writable bool, callback func(*os.File) error) error { - if file != nil { - uri = file.URL - err := file.PrepareForDecryption() - if err != nil { - return err - } - } - parsedURI, err := uri.Parse() - if err != nil { - return err - } - tempFile, err := os.CreateTemp("", "mautrix-download-*") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - defer func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) - }() - resp, err := as.Matrix.Download(ctx, parsedURI) - if err != nil { - return fmt.Errorf("failed to send download request: %w", err) - } - defer resp.Body.Close() - reader := resp.Body - if file != nil { - reader = file.DecryptStream(reader) - } - if resp.ContentLength > 0 { - err = fallocate.Fallocate(tempFile, int(resp.ContentLength)) - if err != nil { - return fmt.Errorf("failed to preallocate file: %w", err) - } - } - _, err = io.Copy(tempFile, reader) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - err = reader.Close() - if err != nil { - return fmt.Errorf("failed to close response body: %w", err) - } - _, err = tempFile.Seek(0, io.SeekStart) - if err != nil { - return fmt.Errorf("failed to seek to start of temp file: %w", err) - } - err = callback(tempFile) - if err != nil { - return bridgev2.CallbackError{Type: "read", Wrapped: err} - } - return nil -} - -func (as *ASIntent) UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) { - if int64(len(data)) > as.Connector.MediaConfig.UploadSize { - return "", nil, fmt.Errorf("%w (%.2f MB > %.2f MB)", bridgev2.ErrMediaTooLarge, float64(len(data))/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) - } - if roomID != "" { - var encrypted bool - if encrypted, err = as.Connector.isEncrypted(ctx, roomID); err != nil { - err = fmt.Errorf("failed to check if room is encrypted: %w", err) - return - } else if encrypted { - file = &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - } - file.EncryptInPlace(data) - mimeType = "application/octet-stream" - fileName = "" - } - } - url, err = as.doUploadReq(ctx, file, mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: mimeType, - FileName: fileName, - }) - return -} - -func (as *ASIntent) UploadMediaStream( - ctx context.Context, - roomID id.RoomID, - size int64, - requireFile bool, - cb bridgev2.FileStreamCallback, -) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) { - if size > as.Connector.MediaConfig.UploadSize { - return "", nil, fmt.Errorf("%w (%.2f MB > %.2f MB)", bridgev2.ErrMediaTooLarge, float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) - } - if !requireFile && 0 < size && size < as.Connector.Config.Matrix.UploadFileThreshold { - var buf bytes.Buffer - res, err := cb(&buf) - if err != nil { - return "", nil, err - } else if res.ReplacementFile != "" { - panic(fmt.Errorf("logic error: replacement path must only be returned if requireFile is true")) - } - return as.UploadMedia(ctx, roomID, buf.Bytes(), res.FileName, res.MimeType) - } - var tempFile *os.File - tempFile, err = os.CreateTemp("", "mautrix-upload-*") - if err != nil { - err = fmt.Errorf("failed to create temp file: %w", err) - return - } - removeAndClose := func(f *os.File) { - _ = f.Close() - _ = os.Remove(f.Name()) - } - startedAsyncUpload := false - defer func() { - if !startedAsyncUpload { - removeAndClose(tempFile) - } - }() - if size > 0 { - err = fallocate.Fallocate(tempFile, int(size)) - if err != nil { - err = fmt.Errorf("failed to preallocate file: %w", err) - return - } - } - if roomID != "" { - var encrypted bool - if encrypted, err = as.Connector.isEncrypted(ctx, roomID); err != nil { - err = fmt.Errorf("failed to check if room is encrypted: %w", err) - return - } else if encrypted { - file = &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - } - } - } - var res *bridgev2.FileStreamResult - res, err = cb(tempFile) - if err != nil { - err = bridgev2.CallbackError{Type: "write", Wrapped: err} - return - } - var replFile *os.File - if res.ReplacementFile != "" { - replFile, err = os.OpenFile(res.ReplacementFile, os.O_RDWR, 0) - if err != nil { - err = fmt.Errorf("failed to open replacement file: %w", err) - return - } - defer func() { - if !startedAsyncUpload { - removeAndClose(replFile) - } - }() - } else { - replFile = tempFile - _, err = replFile.Seek(0, io.SeekStart) - if err != nil { - err = fmt.Errorf("failed to seek to start of temp file: %w", err) - return - } - } - if file != nil { - res.FileName = "" - res.MimeType = "application/octet-stream" - err = file.EncryptFile(replFile) - if err != nil { - err = fmt.Errorf("failed to encrypt file: %w", err) - return - } - _, err = replFile.Seek(0, io.SeekStart) - if err != nil { - err = fmt.Errorf("failed to seek to start of temp file after encrypting: %w", err) - return - } - } - info, err := replFile.Stat() - if err != nil { - err = fmt.Errorf("failed to get temp file info: %w", err) - return - } - size = info.Size() - if size > as.Connector.MediaConfig.UploadSize { - return "", nil, fmt.Errorf("%w (%.2f MB > %.2f MB)", bridgev2.ErrMediaTooLarge, float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000) - } - req := mautrix.ReqUploadMedia{ - Content: replFile, - ContentLength: size, - ContentType: res.MimeType, - FileName: res.FileName, - } - if as.Connector.Config.Homeserver.AsyncMedia { - req.DoneCallback = func() { - removeAndClose(replFile) - removeAndClose(tempFile) - } - req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx) - startedAsyncUpload = true - var resp *mautrix.RespCreateMXC - resp, err = as.Matrix.UploadAsync(ctx, req) - if resp != nil { - url = resp.ContentURI.CUString() - } - } else { - var resp *mautrix.RespMediaUpload - resp, err = as.Matrix.UploadMedia(ctx, req) - if resp != nil { - url = resp.ContentURI.CUString() - } - } - if file != nil { - file.URL = url - url = "" - } - return -} - -func (as *ASIntent) doUploadReq(ctx context.Context, file *event.EncryptedFileInfo, req mautrix.ReqUploadMedia) (url id.ContentURIString, err error) { - if as.Connector.Config.Homeserver.AsyncMedia { - if req.ContentBytes != nil { - // Prevent too many background uploads at once - err = as.Connector.uploadSema.Acquire(ctx, int64(len(req.ContentBytes))) - if err != nil { - return - } - req.DoneCallback = func() { - as.Connector.uploadSema.Release(int64(len(req.ContentBytes))) - } - } - req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx) - var resp *mautrix.RespCreateMXC - resp, err = as.Matrix.UploadAsync(ctx, req) - if resp != nil { - url = resp.ContentURI.CUString() - } - } else { - var resp *mautrix.RespMediaUpload - resp, err = as.Matrix.UploadMedia(ctx, req) - if resp != nil { - url = resp.ContentURI.CUString() - } - } - if file != nil { - file.URL = url - url = "" - } - return -} - -func (as *ASIntent) SetDisplayName(ctx context.Context, name string) error { - return as.Matrix.SetDisplayName(ctx, name) -} - -func (as *ASIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error { - parsedAvatarURL, err := avatarURL.Parse() - if err != nil { - return err - } - return as.Matrix.SetAvatarURL(ctx, parsedAvatarURL) -} - -func dataToFields(data any) (map[string]json.RawMessage, error) { - fields, ok := data.(map[string]json.RawMessage) - if ok { - return fields, nil - } - d, err := canonicaljson.Marshal(data) - if err != nil { - return nil, err - } - err = json.Unmarshal(d, &fields) - return fields, err -} - -func marshalField(val any) json.RawMessage { - data, _ := canonicaljson.Marshal(val) - return data -} - -var nullJSON = json.RawMessage("null") - -func (as *ASIntent) SetProfile(ctx context.Context, data any) error { - return as.Matrix.UnstableOverwriteProfile(ctx, data) -} - -func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error { - if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { - return as.Matrix.BeeperUpdateProfile(ctx, data) - } else if as.Connector.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && as.Connector.Config.Matrix.GhostExtraProfileInfo { - fields, err := dataToFields(data) - if err != nil { - return fmt.Errorf("failed to marshal fields: %w", err) - } - currentProfile, err := as.Matrix.GetProfile(ctx, as.Matrix.UserID) - if err != nil { - return fmt.Errorf("failed to get current profile: %w", err) - } - for key, val := range fields { - existing, ok := currentProfile.Extra[key] - if !ok { - if bytes.Equal(val, nullJSON) { - continue - } - err = as.Matrix.SetProfileField(ctx, key, val) - } else if !bytes.Equal(marshalField(existing), val) { - if bytes.Equal(val, nullJSON) { - err = as.Matrix.DeleteProfileField(ctx, key) - } else { - err = as.Matrix.SetProfileField(ctx, key, val) - } - } - if err != nil { - return fmt.Errorf("failed to set profile field %q: %w", key, err) - } - } - } - return nil -} - -func (as *ASIntent) GetMXID() id.UserID { - return as.Matrix.UserID -} - -func (as *ASIntent) IsDoublePuppet() bool { - return as.Matrix.IsDoublePuppet() -} - -func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...bridgev2.EnsureJoinedParams) error { - var params bridgev2.EnsureJoinedParams - if len(extra) > 0 { - params = extra[0] - } - err := as.Matrix.EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{Via: params.Via}) - if err != nil { - return err - } - if as.Connector.Bot.UserID == as.Matrix.UserID { - _, err = as.Matrix.State(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get state after joining room with bot") - } - } - return nil -} - -func (as *ASIntent) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error { - return as.Matrix.EnsureInvited(ctx, roomID, userID) -} - -func (br *Connector) getDefaultEncryptionEvent() *event.EncryptionEventContent { - content := &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} - if rot := br.Config.Encryption.Rotation; rot.EnableCustom { - content.RotationPeriodMillis = rot.Milliseconds - content.RotationPeriodMessages = rot.Messages - } - return content -} - -func (as *ASIntent) filterCreateRequestForV12(ctx context.Context, req *mautrix.ReqCreateRoom) { - if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - // Hungryserv doesn't override the capabilities endpoint nor do room versions - return - } - caps := as.Connector.fetchCapabilities(ctx) - roomVer := req.RoomVersion - if roomVer == "" && caps != nil && caps.RoomVersions != nil { - roomVer = id.RoomVersion(caps.RoomVersions.Default) - } - if roomVer != "" && !roomVer.PrivilegedRoomCreators() { - return - } - creators, _ := req.CreationContent["additional_creators"].([]id.UserID) - creators = append(slices.Clone(creators), as.GetMXID()) - if req.PowerLevelOverride != nil { - for _, creator := range creators { - delete(req.PowerLevelOverride.Users, creator) - } - } - for _, evt := range req.InitialState { - if evt.Type != event.StatePowerLevels { - continue - } - content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) - if ok { - for _, creator := range creators { - delete(content.Users, creator) - } - } - } -} - -func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) { - if as.Connector.Config.Encryption.Default { - req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateEncryption, - Content: event.Content{ - Parsed: as.Connector.getDefaultEncryptionEvent(), - }, - }) - } - if !as.Connector.Config.Matrix.FederateRooms { - if req.CreationContent == nil { - req.CreationContent = make(map[string]any) - } - req.CreationContent["m.federate"] = false - } - as.filterCreateRequestForV12(ctx, req) - resp, err := as.Matrix.CreateRoom(ctx, req) - if err != nil { - return "", err - } - return resp.RoomID, nil -} - -func (as *ASIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id.UserID) error { - if !as.Connector.Config.Matrix.SyncDirectChatList { - return nil - } - as.dmUpdateLock.Lock() - defer as.dmUpdateLock.Unlock() - cached, ok := as.directChatsCache[withUser] - if ok && slices.Contains(cached, roomID) { - return nil - } - var directChats event.DirectChatsEventContent - err := as.Matrix.GetAccountData(ctx, event.AccountDataDirectChats.Type, &directChats) - if errors.Is(err, mautrix.MNotFound) { - directChats = make(event.DirectChatsEventContent) - } else if err != nil { - return err - } - as.directChatsCache = directChats - rooms := directChats[withUser] - if slices.Contains(rooms, roomID) { - return nil - } - directChats[withUser] = append(rooms, roomID) - err = as.Matrix.SetAccountData(ctx, event.AccountDataDirectChats.Type, &directChats) - if err != nil { - if rooms == nil { - delete(directChats, withUser) - } else { - directChats[withUser] = rooms - } - return fmt.Errorf("failed to set direct chats account data: %w", err) - } - return nil -} - -func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error { - if roomID == "" { - return nil - } - if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - err := as.Matrix.BeeperDeleteRoom(ctx, roomID) - if err != nil { - return err - } - err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal") - } - return nil - } - members, err := as.Matrix.JoinedMembers(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to get portal members for cleanup: %w", err) - } - for member := range members.Joined { - if member == as.Matrix.UserID { - continue - } - if as.Connector.Bridge.IsGhostMXID(member) { - _, err = as.Connector.AS.Intent(member).LeaveRoom(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("user_id", member).Msg("Failed to leave room while cleaning up portal") - } - } else if !puppetsOnly { - _, err = as.Matrix.KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("user_id", member).Msg("Failed to kick user while cleaning up portal") - } - } - } - _, err = as.Matrix.LeaveRoom(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to leave room while cleaning up portal") - } - err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal") - } - return nil -} - -func (as *ASIntent) TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error { - tags, err := as.Matrix.GetTags(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to get room tags: %w", err) - } - if isTagged { - _, alreadyTagged := tags.Tags[tag] - if alreadyTagged { - return nil - } - err = as.Matrix.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{ - MauDoublePuppetSource: as.Connector.AS.DoublePuppetValue, - }) - if err != nil { - return err - } - } - for extraTag := range tags.Tags { - if extraTag == event.RoomTagFavourite || extraTag == event.RoomTagLowPriority { - err = as.Matrix.RemoveTag(ctx, roomID, extraTag) - if err != nil { - return fmt.Errorf("failed to remove extra tag %s: %w", extraTag, err) - } - } - } - return nil -} - -func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error { - var mutedUntil int64 - if until.Before(time.Now()) { - mutedUntil = 0 - } else if until == event.MutedForever { - mutedUntil = -1 - } else { - mutedUntil = until.UnixMilli() - } - if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureAccountDataMute) { - return as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataBeeperMute.Type, &event.BeeperMuteEventContent{ - MutedUntil: mutedUntil, - }) - } - if mutedUntil == 0 { - err := as.Matrix.DeletePushRule(ctx, "global", pushrules.RoomRule, string(roomID)) - // If the push rule doesn't exist, everything is fine - if errors.Is(err, mautrix.MNotFound) { - err = nil - } - return err - } else { - return as.Matrix.PutPushRule(ctx, "global", pushrules.RoomRule, string(roomID), &mautrix.ReqPutPushRule{ - Actions: []*pushrules.PushAction{}, - }) - } -} - -func (as *ASIntent) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) { - evt, err := as.Matrix.Client.GetEvent(ctx, roomID, eventID) - if err != nil { - return nil, err - } - err = evt.Content.ParseRaw(evt.Type) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("room_id", roomID).Stringer("event_id", eventID).Msg("failed to parse event content") - } - - if evt.Type == event.EventEncrypted { - if as.Connector.Crypto == nil || as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt { - return nil, errors.New("can't decrypt the event") - } - return as.Connector.Crypto.Decrypt(ctx, evt) - } - - return evt, nil -} - -func (as *ASIntent) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) { - return as.Matrix.FullStateEvent(ctx, roomID, eventType, stateKey) -} diff --git a/mautrix-patched/bridgev2/matrix/matrix.go b/mautrix-patched/bridgev2/matrix/matrix.go deleted file mode 100644 index 78181d18..00000000 --- a/mautrix-patched/bridgev2/matrix/matrix.go +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "errors" - "fmt" - "slices" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (br *Connector) handleRoomEvent(ctx context.Context, evt *event.Event) { - if evt.Type == event.StateMember && br.Crypto != nil && !br.Bridge.IsGhostMXID(id.UserID(evt.GetStateKey())) { - br.Crypto.HandleMemberEvent(ctx, evt) - } - if br.shouldIgnoreEvent(evt) { - return - } - if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents && evt.Type != event.StateMember { - zerolog.Ctx(ctx).Debug(). - Stringer("event_type", evt.Type). - Stringer("room_id", evt.RoomID). - Stringer("sender", evt.Sender). - Stringer("event_id", evt.ID). - Msg("Dropping event from user with no permission to send events") - br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt)) - return - } - if (evt.Type == event.EventMessage || evt.Type == event.EventSticker) && !evt.Mautrix.WasEncrypted && br.Config.Encryption.Require { - zerolog.Ctx(ctx).Warn(). - Stringer("room_id", evt.RoomID). - Stringer("sender", evt.Sender). - Stringer("event_id", evt.ID). - Msg("Dropping unencrypted event as encryption is configured to be required") - br.sendCryptoStatusError(ctx, evt, errMessageNotEncrypted, nil, 0, true) - return - } - br.Bridge.QueueMatrixEvent(ctx, evt) -} - -func (br *Connector) handleEphemeralEvent(ctx context.Context, evt *event.Event) { - switch evt.Type { - case event.EphemeralEventReceipt: - receiptContent := *evt.Content.AsReceipt() - for eventID, receipts := range receiptContent { - for receiptType, userReceipts := range receipts { - for userID, receipt := range userReceipts { - if br.shouldIgnoreEventFromUser(userID) || (br.AS.DoublePuppetValue != "" && receipt.Extra[appservice.DoublePuppetKey] == br.AS.DoublePuppetValue) { - delete(userReceipts, userID) - } - } - if len(userReceipts) == 0 { - delete(receipts, receiptType) - } - } - if len(receipts) == 0 { - delete(receiptContent, eventID) - } - } - if len(receiptContent) == 0 { - return - } - case event.EphemeralEventTyping: - typingContent := evt.Content.AsTyping() - typingContent.UserIDs = slices.DeleteFunc(typingContent.UserIDs, br.shouldIgnoreEventFromUser) - } - br.Bridge.QueueMatrixEvent(ctx, evt) -} - -func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event) { - if br.shouldIgnoreEvent(evt) { - return - } - content := evt.Content.AsEncrypted() - log := zerolog.Ctx(ctx).With(). - Str("event_id", evt.ID.String()). - Str("session_id", content.SessionID.String()). - Logger() - if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents { - log.Debug(). - Stringer("room_id", evt.RoomID). - Stringer("sender", evt.Sender). - Stringer("event_id", evt.ID). - Msg("Dropping encrypted event from user with no permission to send events") - br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt)) - return - } - ctx = log.WithContext(ctx) - if br.Crypto == nil { - br.sendCryptoStatusError(ctx, evt, errNoCrypto, nil, 0, true) - log.Error().Msg("Can't decrypt message: no crypto") - return - } - log.Debug().Msg("Decrypting received event") - - decryptionStart := time.Now() - decrypted, err := br.Crypto.Decrypt(ctx, evt) - decryptionRetryCount := 0 - var errorEventID id.EventID - if errors.Is(err, NoSessionFound) { - decryptionRetryCount = 1 - log.Debug(). - Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). - Msg("Couldn't find session, waiting for keys to arrive...") - go br.sendCryptoStatusError(ctx, evt, err, &errorEventID, 0, false) - if br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { - log.Debug().Msg("Got keys after waiting, trying to decrypt event again") - decrypted, err = br.Crypto.Decrypt(ctx, evt) - } else { - go br.waitLongerForSession(ctx, evt, decryptionStart, &errorEventID) - return - } - } - if err != nil { - log.Warn().Err(err).Msg("Failed to decrypt event") - go br.sendCryptoStatusError(ctx, evt, err, nil, decryptionRetryCount, true) - return - } - br.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, &errorEventID, time.Since(decryptionStart)) -} - -func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time, errorEventID *id.EventID) { - log := zerolog.Ctx(ctx) - content := evt.Content.AsEncrypted() - log.Debug(). - Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())). - Msg("Couldn't find session, requesting keys and waiting longer...") - - //lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank - go br.Crypto.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) - go br.sendCryptoStatusError(ctx, evt, fmt.Errorf("%w. The bridge will retry for %d seconds", errNoDecryptionKeys, int(extendedSessionWaitTimeout.Seconds())), errorEventID, 1, false) - - if !br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) { - log.Debug().Msg("Didn't get session, giving up trying to decrypt event") - go br.sendCryptoStatusError(ctx, evt, errNoDecryptionKeys, errorEventID, 2, true) - return - } - - log.Debug().Msg("Got keys after waiting longer, trying to decrypt event again") - decrypted, err := br.Crypto.Decrypt(ctx, evt) - if err != nil { - log.Error().Err(err).Msg("Failed to decrypt event") - go br.sendCryptoStatusError(ctx, evt, err, errorEventID, 2, true) - return - } - - br.postDecrypt(ctx, evt, decrypted, 2, errorEventID, time.Since(decryptionStart)) -} - -type CommandProcessor interface { - Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user bridgev2.User, message string, replyTo id.EventID) -} - -func (br *Connector) sendSuccessCheckpoint(ctx context.Context, evt *event.Event, step status.MessageCheckpointStep, retryNum int) { - err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{{ - RoomID: evt.RoomID, - EventID: evt.ID, - EventType: evt.Type, - MessageType: evt.Content.AsMessage().MsgType, - Step: step, - Timestamp: jsontime.UnixMilliNow(), - Status: status.MsgStatusSuccess, - ReportedBy: status.MsgReportedByBridge, - RetryNum: retryNum, - }}) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("checkpoint_step", string(step)).Msg("Failed to send checkpoint") - } -} - -func (br *Connector) sendBridgeCheckpoint(ctx context.Context, evt *event.Event) { - if !evt.Mautrix.CheckpointSent { - go br.sendSuccessCheckpoint(ctx, evt, status.MsgStepBridge, 0) - } -} - -func (br *Connector) shouldIgnoreEventFromUser(userID id.UserID) bool { - return userID == br.Bot.UserID || br.Bridge.IsGhostMXID(userID) -} - -func (br *Connector) shouldIgnoreEvent(evt *event.Event) bool { - if br.shouldIgnoreEventFromUser(evt.Sender) && evt.Type != event.StateTombstone { - return true - } - dpVal, ok := evt.Content.Raw[appservice.DoublePuppetKey] - if ok && dpVal == br.AS.DoublePuppetValue { - dpTS, ok := evt.Content.Raw[appservice.DoublePuppetTSKey].(float64) - if !ok || int64(dpTS) == evt.Timestamp { - return true - } - } - return false -} - -const initialSessionWaitTimeout = 3 * time.Second -const extendedSessionWaitTimeout = 22 * time.Second - -func copySomeKeys(original, decrypted *event.Event) { - isScheduled, _ := original.Content.Raw["com.beeper.scheduled"].(bool) - _, alreadyExists := decrypted.Content.Raw["com.beeper.scheduled"] - if isScheduled && !alreadyExists { - decrypted.Content.Raw["com.beeper.scheduled"] = true - } -} - -func (br *Connector) postDecrypt(ctx context.Context, original, decrypted *event.Event, retryCount int, errorEventID *id.EventID, duration time.Duration) { - log := zerolog.Ctx(ctx) - minLevel := br.Config.Encryption.VerificationLevels.Send - if decrypted.Mautrix.TrustState < minLevel { - logEvt := log.Warn(). - Str("user_id", decrypted.Sender.String()). - Bool("forwarded_keys", decrypted.Mautrix.ForwardedKeys). - Stringer("device_trust", decrypted.Mautrix.TrustState). - Stringer("min_trust", minLevel) - if decrypted.Mautrix.TrustSource != nil { - dev := decrypted.Mautrix.TrustSource - logEvt. - Str("device_id", dev.DeviceID.String()). - Str("device_signing_key", dev.SigningKey.String()) - } else { - logEvt.Str("device_id", "unknown") - } - logEvt.Msg("Dropping event due to insufficient verification level") - err := deviceUnverifiedErrorWithExplanation(decrypted.Mautrix.TrustState) - go br.sendCryptoStatusError(ctx, decrypted, err, errorEventID, retryCount, true) - return - } - copySomeKeys(original, decrypted) - - go br.sendSuccessCheckpoint(ctx, decrypted, status.MsgStepDecrypted, retryCount) - decrypted.Mautrix.CheckpointSent = true - decrypted.Mautrix.DecryptionDuration = duration - br.EventProcessor.Dispatch(ctx, decrypted) - if errorEventID != nil && *errorEventID != "" { - _, _ = br.Bot.RedactEvent(ctx, decrypted.RoomID, *errorEventID) - } -} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/config.go b/mautrix-patched/bridgev2/matrix/mxmain/config.go deleted file mode 100644 index a684d8a2..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/config.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mxmain - -import ( - _ "embed" - "strings" - "text/template" - - "go.mau.fi/util/exerrors" -) - -//go:embed example-config.yaml -var MatrixExampleConfigBase string - -var matrixExampleConfigBaseTemplate = exerrors.Must(template.New("example-config.yaml"). - Delims("$<<", ">>"). - Parse(MatrixExampleConfigBase)) - -func (br *BridgeMain) makeFullExampleConfig(networkExample string) string { - var buf strings.Builder - buf.WriteString("# Network-specific config options\n") - buf.WriteString("network:\n") - for _, line := range strings.Split(networkExample, "\n") { - buf.WriteString(" ") - buf.WriteString(line) - buf.WriteRune('\n') - } - buf.WriteRune('\n') - exerrors.PanicIfNotNil(matrixExampleConfigBaseTemplate.Execute(&buf, br.Connector.GetName())) - return buf.String() -} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/dberror.go b/mautrix-patched/bridgev2/matrix/mxmain/dberror.go deleted file mode 100644 index f5e438de..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/dberror.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mxmain - -import ( - "errors" - "os" - - "github.com/lib/pq" - "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog" - - "go.mau.fi/util/dbutil" -) - -type zerologPQError pq.Error - -func (zpe *zerologPQError) MarshalZerologObject(evt *zerolog.Event) { - maybeStr := func(field, value string) { - if value != "" { - evt.Str(field, value) - } - } - maybeStr("severity", zpe.Severity) - if name := zpe.Code.Name(); name != "" { - evt.Str("code", name) - } else if zpe.Code != "" { - evt.Str("code", string(zpe.Code)) - } - //maybeStr("message", zpe.Message) - maybeStr("detail", zpe.Detail) - maybeStr("hint", zpe.Hint) - maybeStr("position", zpe.Position) - maybeStr("internal_position", zpe.InternalPosition) - maybeStr("internal_query", zpe.InternalQuery) - maybeStr("where", zpe.Where) - maybeStr("schema", zpe.Schema) - maybeStr("table", zpe.Table) - maybeStr("column", zpe.Column) - maybeStr("data_type_name", zpe.DataTypeName) - maybeStr("constraint", zpe.Constraint) - maybeStr("file", zpe.File) - maybeStr("line", zpe.Line) - maybeStr("routine", zpe.Routine) -} - -func (br *BridgeMain) LogDBUpgradeErrorAndExit(name string, err error, message string) { - logEvt := br.Log.WithLevel(zerolog.FatalLevel). - Err(err). - Str("db_section", name) - var errWithLine *dbutil.PQErrorWithLine - if errors.As(err, &errWithLine) { - logEvt.Str("sql_line", errWithLine.Line) - } - var pqe *pq.Error - if errors.As(err, &pqe) { - logEvt.Object("pq_error", (*zerologPQError)(pqe)) - } - logEvt.Msg(message) - if sqlError := (&sqlite3.Error{}); errors.As(err, sqlError) && sqlError.Code == sqlite3.ErrCorrupt { - os.Exit(18) - } else if errors.Is(err, dbutil.ErrForeignTables) { - br.Log.Info().Msg("See https://docs.mau.fi/faq/foreign-tables for more info") - } else if errors.Is(err, dbutil.ErrNotOwned) { - var noe dbutil.NotOwnedError - if errors.As(err, &noe) && noe.Owner == br.Name { - br.Log.Info().Msg("The database appears to be on a very old pre-megabridge schema. Perhaps you need to run an older version of the bridge with migration support first?") - } else { - br.Log.Info().Msg("Sharing the same database with different programs is not supported") - } - } else if errors.Is(err, dbutil.ErrUnsupportedDatabaseVersion) { - br.Log.Info().Msg("Downgrading the bridge is not supported") - } - os.Exit(15) -} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/envconfig.go b/mautrix-patched/bridgev2/matrix/mxmain/envconfig.go deleted file mode 100644 index f291b828..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/envconfig.go +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mxmain - -import ( - "fmt" - "iter" - "os" - "reflect" - "strconv" - "strings" - - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/bridgev2" -) - -func LoadGlobalConfigEnv() { - peb, err := strconv.Atoi(os.Getenv("MAUTRIX_PORTAL_EVENT_BUFFER")) - if err == nil && peb >= 0 { - bridgev2.PortalEventBuffer = peb - } - pose, err := strconv.ParseBool(os.Getenv("MAUTRIX_PANIC_ON_STUCK_EVENT")) - if err == nil { - bridgev2.PanicOnStuckEvent = pose - } - ehtt, err := strconv.Atoi(os.Getenv("MAUTRIX_EVENT_HANDLING_TIMEOUT_TICKS")) - if err == nil && ehtt > 0 { - bridgev2.EventHandlingTimeoutTicks = ehtt - } -} - -var randomParseFilePrefix = random.String(16) + "READFILE:" - -func parseEnv(prefix string) iter.Seq2[[]string, string] { - return func(yield func([]string, string) bool) { - for _, s := range os.Environ() { - if !strings.HasPrefix(s, prefix) { - continue - } - kv := strings.SplitN(s, "=", 2) - key := strings.TrimPrefix(kv[0], prefix) - value := kv[1] - if strings.HasSuffix(key, "_FILE") { - key = strings.TrimSuffix(key, "_FILE") - value = randomParseFilePrefix + value - } - key = strings.ToLower(key) - if !strings.ContainsRune(key, '.') { - key = strings.ReplaceAll(key, "__", ".") - } - if !yield(strings.Split(key, "."), value) { - return - } - } - } -} - -func reflectYAMLFieldName(f *reflect.StructField) string { - parts := strings.SplitN(f.Tag.Get("yaml"), ",", 2) - fieldName := parts[0] - if fieldName == "-" && len(parts) == 1 { - return "" - } - if fieldName == "" { - return strings.ToLower(f.Name) - } - return fieldName -} - -type reflectGetResult struct { - val reflect.Value - valKind reflect.Kind - remainingPath []string -} - -func reflectGetYAML(rv reflect.Value, path []string) (*reflectGetResult, bool) { - if len(path) == 0 { - return &reflectGetResult{val: rv, valKind: rv.Kind()}, true - } - if rv.Kind() == reflect.Ptr { - rv = rv.Elem() - } - switch rv.Kind() { - case reflect.Map: - return &reflectGetResult{val: rv, remainingPath: path, valKind: rv.Type().Elem().Kind()}, true - case reflect.Struct: - fields := reflect.VisibleFields(rv.Type()) - for _, field := range fields { - fieldName := reflectYAMLFieldName(&field) - if fieldName != "" && fieldName == path[0] { - return reflectGetYAML(rv.FieldByIndex(field.Index), path[1:]) - } - } - default: - } - return nil, false -} - -func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectGetResult, bool) { - if len(path) > 0 && path[0] == "network" { - return reflectGetYAML(network, path[1:]) - } - return reflectGetYAML(main, path) -} - -func formatKeyString(key []string) string { - return strings.Join(key, "->") -} - -func UpdateConfigFromEnv(cfg, networkData any, prefix string) error { - cfgVal := reflect.ValueOf(cfg) - networkVal := reflect.ValueOf(networkData) - for key, value := range parseEnv(prefix) { - field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, key) - if !ok { - return fmt.Errorf("%s not found", formatKeyString(key)) - } - if strings.HasPrefix(value, randomParseFilePrefix) { - filepath := strings.TrimPrefix(value, randomParseFilePrefix) - fileData, err := os.ReadFile(filepath) - if err != nil { - return fmt.Errorf("failed to read file %s for %s: %w", filepath, formatKeyString(key), err) - } - value = strings.TrimSpace(string(fileData)) - } - var parsedVal any - var err error - switch field.valKind { - case reflect.String: - parsedVal = value - case reflect.Bool: - parsedVal, err = strconv.ParseBool(value) - if err != nil { - return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - parsedVal, err = strconv.ParseInt(value, 10, 64) - if err != nil { - return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - parsedVal, err = strconv.ParseUint(value, 10, 64) - if err != nil { - return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) - } - case reflect.Float32, reflect.Float64: - parsedVal, err = strconv.ParseFloat(value, 64) - if err != nil { - return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err) - } - default: - return fmt.Errorf("unsupported type %s in %s", field.valKind, formatKeyString(key)) - } - if field.val.Kind() == reflect.Ptr { - if field.val.IsNil() { - field.val.Set(reflect.New(field.val.Type().Elem())) - } - field.val = field.val.Elem() - } - if field.val.Kind() == reflect.Map { - key = key[:len(key)-len(field.remainingPath)] - mapKeyStr := strings.Join(field.remainingPath, ".") - key = append(key, mapKeyStr) - if field.val.Type().Key().Kind() != reflect.String { - return fmt.Errorf("unsupported map key type %s in %s", field.val.Type().Key().Kind(), formatKeyString(key)) - } - field.val.SetMapIndex(reflect.ValueOf(mapKeyStr), reflect.ValueOf(parsedVal)) - } else { - switch typedVal := parsedVal.(type) { - case int64: - field.val.SetInt(typedVal) - case uint64: - field.val.SetUint(typedVal) - case float64: - field.val.SetFloat(typedVal) - case bool: - field.val.SetBool(typedVal) - case string: - field.val.SetString(typedVal) - default: - return fmt.Errorf("unexpected parsed value type %T", parsedVal) - } - } - } - return nil -} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml b/mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml deleted file mode 100644 index 1740634c..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/example-config.yaml +++ /dev/null @@ -1,513 +0,0 @@ -# Config options that affect the central bridge module. -bridge: - # The prefix for commands. Only required in non-management rooms. - command_prefix: '$<>' - # Should the bridge create a space for each login containing the rooms that account is in? - personal_filtering_spaces: true - # Whether the bridge should set names and avatars explicitly for DM portals. - # This is only necessary when using clients that don't support MSC4171. - private_chat_portal_meta: true - # Should events be handled asynchronously within portal rooms? - # If true, events may end up being out of order, but slow events won't block other ones. - # This is not yet safe to use. - async_events: false - # Should every user have their own portals rather than sharing them? - # By default, users who are in the same group on the remote network will be - # in the same Matrix room bridged to that group. If this is set to true, - # every user will get their own Matrix room instead. - # SETTING THIS IS IRREVERSIBLE AND POTENTIALLY DESTRUCTIVE IF PORTALS ALREADY EXIST. - split_portals: false - # Should the bridge resend `m.bridge` events to all portals on startup? - resend_bridge_info: false - # Should `m.bridge` events be sent without a state key? - # By default, the bridge uses a unique key that won't conflict with other bridges. - no_bridge_info_state_key: false - # Should bridge connection status be sent to the management room as `m.notice` events? - # These contain the same data that can be posted to an external HTTP server using homeserver -> status_endpoint. - # Allowed values: none, errors, all - bridge_status_notices: errors - # How long after an unknown error should the bridge attempt a full reconnect? - # Must be at least 1 minute. The bridge will add an extra ±20% jitter to this value. - unknown_error_auto_reconnect: null - # Maximum number of times to do the auto-reconnect above. - # The counter is per login, but is never reset except on logout and restart. - unknown_error_max_auto_reconnects: 10 - - # Should leaving Matrix rooms be bridged as leaving groups on the remote network? - bridge_matrix_leave: false - # Should `m.notice` messages be bridged? - bridge_notices: false - # Should room tags only be synced when creating the portal? Tags mean things like favorite/pin and archive/low priority. - # Tags currently can't be synced back to the remote network, so a continuous sync means tagging from Matrix will be undone. - tag_only_on_create: true - # List of tags to allow bridging. If empty, no tags will be bridged. - only_bridge_tags: [m.favourite, m.lowpriority] - # Should room mute status only be synced when creating the portal? - # Like tags, mutes can't currently be synced back to the remote network. - mute_only_on_create: true - # Should the bridge check the db to ensure that incoming events haven't been handled before - deduplicate_matrix_messages: false - # Should cross-room reply metadata be bridged? - # Most Matrix clients don't support this and servers may reject such messages too. - cross_room_replies: false - # If a state event fails to bridge, should the bridge revert any state changes made by that event? - revert_failed_state_changes: false - # In portals with no relay set, should Matrix users be kicked if they're - # not logged into an account that's in the remote chat? - kick_matrix_users: true - # Should the bridge listen to com.beeper.state_request events? - # This is not necessary for anything outside of Beeper. - enable_send_state_requests: false - # Should the com.beeper.bridge.identifiers list in global ghost profiles include phone numbers? - phone_numbers_in_profile: false - - # What should be done to portal rooms when a user logs out or is logged out? - # Permitted values: - # nothing - Do nothing, let the user stay in the portals - # kick - Remove the user from the portal rooms, but don't delete them - # unbridge - Remove all ghosts in the room and disassociate it from the remote chat - # delete - Remove all ghosts and users from the room (i.e. delete it) - cleanup_on_logout: - # Should cleanup on logout be enabled at all? - enabled: false - # Settings for manual logouts (explicitly initiated by the Matrix user) - manual: - # Action for private portals which will never be shared with other Matrix users. - private: nothing - # Action for portals with a relay user configured. - relayed: nothing - # Action for portals which may be shared, but don't currently have any other Matrix users. - shared_no_users: nothing - # Action for portals which have other logged-in Matrix users. - shared_has_users: nothing - # Settings for credentials being invalidated (initiated by the remote network, possibly through user action). - # Keys have the same meanings as in the manual section. - bad_credentials: - private: nothing - relayed: nothing - shared_no_users: nothing - shared_has_users: nothing - - # Settings for relay mode - relay: - # Whether relay mode should be allowed. If allowed, the set-relay command can be used to turn any - # authenticated user into a relaybot for that chat. - enabled: false - # Should only admins be allowed to set themselves as relay users? - # If true, non-admins can only set users listed in default_relays as relays in a room. - admin_only: true - # Should default relays be preferred when an explicit login ID isn't specified even if the user is logged in? - # This applies to the set-relay and bridge commands sent by any user, including admins. - prefer_default: true - # Should non-admins be allowed to use the bridge and sync-chat commands via default relays specified below? - allow_bridge: true - # List of user login IDs which anyone can set as a relay, as long as the relay user is in the room. - default_relays: [] - # The formats to use when sending messages via the relaybot. - # Available variables: - # .Sender.UserID - The Matrix user ID of the sender. - # .Sender.Displayname - The display name of the sender (if set). - # .Sender.RequiresDisambiguation - Whether the sender's name may be confused with the name of another user in the room. - # .Sender.DisambiguatedName - The disambiguated name of the sender. This will be the displayname if set, - # plus the user ID in parentheses if the displayname is not unique. - # If the displayname is not set, this is just the user ID. - # .Message - The `formatted_body` field of the message. - # .Caption - The `formatted_body` field of the message, if it's a caption. Otherwise an empty string. - # .FileName - The name of the file being sent. - message_formats: - m.text: "{{ .Sender.DisambiguatedName }}: {{ .Message }}" - m.notice: "{{ .Sender.DisambiguatedName }}: {{ .Message }}" - m.emote: "* {{ .Sender.DisambiguatedName }} {{ .Message }}" - m.file: "{{ .Sender.DisambiguatedName }} sent a file{{ if .Caption }}: {{ .Caption }}{{ end }}" - m.image: "{{ .Sender.DisambiguatedName }} sent an image{{ if .Caption }}: {{ .Caption }}{{ end }}" - m.audio: "{{ .Sender.DisambiguatedName }} sent an audio file{{ if .Caption }}: {{ .Caption }}{{ end }}" - m.video: "{{ .Sender.DisambiguatedName }} sent a video{{ if .Caption }}: {{ .Caption }}{{ end }}" - m.location: "{{ .Sender.DisambiguatedName }} sent a location{{ if .Caption }}: {{ .Caption }}{{ end }}" - # For networks that support per-message displaynames (i.e. Slack and Discord), the template for those names. - # This has all the Sender variables available under message_formats (but without the .Sender prefix). - # Note that you need to manually remove the displayname from message_formats above. - displayname_format: "{{ .DisambiguatedName }}" - - # Filter for automatically creating portals. - portal_create_filter: - # The mode for filtering, either `deny` or `allow` - mode: deny - # The list of portal IDs to deny or allow depending on the mode config. - # Items here can either be the plain portal ID as a string, or an object with `id` and `receiver` fields. - # The receiver field is necessary if you want to target a specific DM portal for example. - list: [] - # A list of user login IDs from which to always deny creating portals. - # This is meant to be used with default relays, such that the relay bot - # being added to a group wouldn't automatically trigger portal creation. - always_deny_from_login: [] - - # Permissions for using the bridge. - # Permitted values: - # relay - Talk through the relaybot (if enabled), no access otherwise - # commands - Access to use commands in the bridge, but not login. - # user - Access to use the bridge with puppeting. - # admin - Full access, user level with some additional administration tools. - # Permitted keys: - # * - All Matrix users - # domain - All users on that homeserver - # mxid - Specific user - permissions: - "*": relay - "example.com": user - "@admin:example.com": admin - -# Config for the bridge's database. -database: - # The database type. "sqlite3-fk-wal" and "postgres" are supported. - type: postgres - # The database URI. - # SQLite: A raw file path is supported, but `file:?_txlock=immediate` is recommended. - # https://github.com/mattn/go-sqlite3#connection-string - # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable - # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql - uri: postgres://user:password@host/database?sslmode=disable - # Maximum number of connections. - max_open_conns: 5 - max_idle_conns: 1 - # Maximum connection idle time and lifetime before they're closed. Disabled if null. - # Parsed with https://pkg.go.dev/time#ParseDuration - max_conn_idle_time: null - max_conn_lifetime: null - -# Homeserver details. -homeserver: - # The address that this appservice can use to connect to the homeserver. - # Local addresses without HTTPS are generally recommended when the bridge is running on the same machine, - # but https also works if they run on different machines. - address: http://example.localhost:8008 - # The domain of the homeserver (also known as server_name, used for MXIDs, etc). - domain: example.com - - # What software is the homeserver running? - # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. - software: standard - # The URL to push real-time bridge status to. - # If set, the bridge will make POST requests to this URL whenever a user's remote network connection state changes. - # The bridge will use the appservice as_token to authorize requests. - status_endpoint: - # Endpoint for reporting per-message status. - # If set, the bridge will make POST requests to this URL when processing a message from Matrix. - # It will make one request when receiving the message (step BRIDGE), one after decrypting if applicable - # (step DECRYPTED) and one after sending to the remote network (step REMOTE). Errors will also be reported. - # The bridge will use the appservice as_token to authorize requests. - message_send_checkpoint_endpoint: - # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? - async_media: false - - # Should the bridge use a websocket for connecting to the homeserver? - # The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy, - # mautrix-asmux (deprecated), and hungryserv (proprietary). - websocket: false - # How often should the websocket be pinged? Pinging will be disabled if this is zero. - ping_interval_seconds: 0 - # When requests to the homeserver fail with a 502/503/504/429 status or a network error, - # how many times should the bridge retry the request before giving up? - retry_limit: 4 - -# Application service host/registration related details. -# Changing these values requires regeneration of the registration (except when noted otherwise) -appservice: - # The address that the homeserver can use to connect to this appservice. - # Like the homeserver address, a local non-https address is recommended when the bridge is on the same machine. - # If the bridge is elsewhere, you must secure the connection yourself (e.g. with https or wireguard) - # If you want to use https, you need to use a reverse proxy. The bridge does not have TLS support built in. - address: http://localhost:$<> - # A public address that external services can use to reach this appservice. - # This is only needed for things like public media. A reverse proxy is generally necessary when using this field. - # This value doesn't affect the registration file. - public_address: https://bridge.example.com - - # The hostname and port where this appservice should listen. - # For Docker, you generally have to change the hostname to 0.0.0.0. - hostname: 127.0.0.1 - port: $<> - - # The unique ID of this appservice. - id: $<<.NetworkID>> - # Appservice bot details. - bot: - # Username of the appservice bot. - username: $<<.NetworkID>>bot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. - displayname: $<<.DisplayName>> bridge bot - avatar: $<<.NetworkIcon>> - - # Whether to receive ephemeral events via appservice transactions. - ephemeral_events: true - # Should incoming events be handled asynchronously? - # This may be necessary for large public instances with lots of messages going through. - # However, messages will not be guaranteed to be bridged in the same order they were sent in. - # This value doesn't affect the registration file. - async_transactions: false - - # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. - as_token: "This value is generated when generating the registration" - hs_token: "This value is generated when generating the registration" - - # Localpart template of MXIDs for remote users. - # {{.}} is replaced with the internal ID of the user. - username_template: $<<.NetworkID>>_{{.}} - -# Config options that affect the Matrix connector of the bridge. -matrix: - # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. - message_status_events: false - # Whether the bridge should send a read receipt after successfully bridging a message. - delivery_receipts: false - # Whether the bridge should send error notices via m.notice events when a message fails to bridge. - message_error_notices: true - # Whether the bridge should update the m.direct account data event when double puppeting is enabled. - sync_direct_chat_list: true - # Whether created rooms should have federation enabled. If false, created portal rooms - # will never be federated. Changing this option requires recreating rooms. - federate_rooms: true - # The threshold as bytes after which the bridge should roundtrip uploads via the disk - # rather than keeping the whole file in memory. - upload_file_threshold: 5242880 - # Should the bridge set additional custom profile info for ghosts? - # This can make a lot of requests, as there's no batch profile update endpoint. - ghost_extra_profile_info: false - -# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors. -analytics: - # API key to send with tracking requests. Tracking is disabled if this is null. - token: null - # Address to send tracking requests to. - url: https://api.segment.io/v1/track - # Optional user ID for tracking events. If null, defaults to using Matrix user ID. - user_id: null - -# Settings for provisioning API -provisioning: - # Shared secret for authentication. If set to "generate" or null, a random secret will be generated, - # or if set to "disable", the provisioning API will be disabled. Must be at least 16 characters. - shared_secret: generate - # Whether to allow provisioning API requests to be authed using Matrix access tokens. - # This follows the same rules as double puppeting to determine which server to contact to check the token, - # which means that by default, it only works for users on the same server as the bridge. - allow_matrix_auth: true - # Enable debug API at /debug with provisioning authentication. - debug_endpoints: false - # Enable session transfers between bridges. Note that this only validates Matrix or shared secret - # auth before passing live network client credentials down in the response. - enable_session_transfers: false - # Should the provisioning API fail immediately if a webauthn step is returned? - # This is mostly useful for clients that don't yet support it and want a trackable error instead. - fail_on_webauthn: false - -# Some networks require publicly accessible media download links (e.g. for user avatars when using Discord webhooks). -# These settings control whether the bridge will provide such public media access. -public_media: - # Should public media be enabled at all? - # The public_address field under the appservice section MUST be set when enabling public media. - enabled: false - # A key for signing public media URLs. - # If set to "generate", a random key will be generated. - signing_key: generate - # Number of seconds that public media URLs are valid for. - # If set to 0, URLs will never expire. - expiry: 0 - # Length of hash to use for public media URLs. Must be between 0 and 32. - hash_length: 32 - # The path prefix for generated URLs. Note that this will NOT change the path where media is actually served. - # If you change this, you must configure your reverse proxy to rewrite the path accordingly. - path_prefix: /_mautrix/publicmedia - # Should the bridge store media metadata in the database in order to support encrypted media and generate shorter URLs? - # If false, the generated URLs will just have the MXC URI and a HMAC signature. - # The hash_length field will be used to decide the length of the generated URL. - # This also allows invalidating URLs by deleting the database entry. - use_database: false - -# Settings for converting remote media to custom mxc:// URIs instead of reuploading. -# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html -direct_media: - # Should custom mxc:// URIs be used instead of reuploading media? - enabled: false - # The server name to use for the custom mxc:// URIs. - # This server name will effectively be a real Matrix server, it just won't implement anything other than media. - # You must either set up .well-known delegation from this domain to the bridge, or proxy the domain directly to the bridge. - server_name: discord-media.example.com - # Optionally a custom .well-known response. This defaults to `server_name:443` - well_known_response: - # Optionally specify a custom prefix for the media ID part of the MXC URI. - media_id_prefix: - # If the remote network supports media downloads over HTTP, then the bridge will use MSC3860/MSC3916 - # media download redirects if the requester supports it. Optionally, you can force redirects - # and not allow proxying at all by setting this to false. - # This option does nothing if the remote network does not support media downloads over HTTP. - allow_proxy: true - # Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file. - # This key is also used to sign the mxc:// URIs to ensure only the bridge can generate them. - server_key: generate - -# Settings for backfilling messages. -# Note that the exact way settings are applied depends on the network connector. -# See https://docs.mau.fi/bridges/general/backfill.html for more details. -backfill: - # Whether to do backfilling at all. - enabled: false - # Maximum number of messages to backfill in empty rooms. - # If this is zero or negative, backfill will be disabled in new rooms. - max_initial_messages: 50 - # Maximum number of missed messages to backfill after bridge restarts. - max_catchup_messages: 500 - # If a backfilled chat is older than this number of hours, - # mark it as read even if it's unread on the remote network. - unread_hours_threshold: 720 - # Settings for backfilling threads within other backfills. - threads: - # Maximum number of messages to backfill in a new thread. - max_initial_messages: 50 - # Settings for the backwards backfill queue. This only applies when connecting to - # Beeper as standard Matrix servers don't support inserting messages into history. - queue: - # Should the backfill queue be enabled? - enabled: false - # Should manual calls to backfill queue tasks be allowed? - manual: false - # Number of messages to backfill in one batch. - batch_size: 100 - # Delay between batches in seconds. - batch_delay: 20 - # Maximum number of batches to backfill per portal. - # If set to -1, all available messages will be backfilled. - max_batches: -1 - # Optional network-specific overrides for max batches. - # Interpretation of this field depends on the network connector. - max_batches_override: {} - -# Settings for enabling double puppeting -double_puppet: - # Servers to always allow double puppeting from. - # This is only for other servers and should NOT contain the server the bridge is on. - servers: - anotherserver.example.org: https://matrix.anotherserver.example.org - # Whether to allow client API URL discovery for other servers. When using this option, - # users on other servers can use double puppeting even if their server URLs aren't - # explicitly added to the servers map above. - allow_discovery: false - # Shared secrets for automatic double puppeting. - # See https://docs.mau.fi/bridges/general/double-puppeting.html for instructions. - secrets: - example.com: as_token:foobar - -# End-to-bridge encryption support options. -# -# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. -encryption: - # Whether to enable encryption at all. If false, the bridge will not function in encrypted rooms. - allow: false - # Whether to force-enable encryption in all bridged rooms. - default: false - # Whether to require all messages to be encrypted and drop any unencrypted messages. - require: false - # Whether to use MSC3202/MSC4203 instead of /sync long polling for receiving encryption-related data. - # This is an experimental option, see the docs for more info. - # Changing this option requires updating the appservice registration file. - appservice: false - # Whether to use MSC4190 instead of appservice login to create the bridge bot device. - # Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202. - # Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861). - msc4190: false - # Whether to encrypt reactions and reply metadata as per MSC4392. - # This is not supported by most clients. - msc4392: false - # Should the bridge bot generate a recovery key and cross-signing keys and verify itself? - # Note that without the latest version of MSC4190, this will fail if you reset the bridge database. - # The generated recovery key will be saved in the kv_store table under `recovery_key`. - self_sign: false - # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. - # You must use a client that supports requesting keys from other users to use this feature. - allow_key_sharing: true - # Should m.mentions be sent in the unencrypted content? This is non-standard and should not be enabled. - plaintext_mentions: false - # Pickle key for encrypting encryption keys in the bridge database. - # If set to generate, a random key will be generated. - pickle_key: generate - # Options for deleting megolm sessions from the bridge. - delete_keys: - # Beeper-specific: delete outbound sessions when hungryserv confirms - # that the user has uploaded the key to key backup. - delete_outbound_on_ack: false - # Don't store outbound sessions in the inbound table. - dont_store_outbound: false - # Ratchet megolm sessions forward after decrypting messages. - ratchet_on_decrypt: false - # Delete fully used keys (index >= max_messages) after decrypting messages. - delete_fully_used_on_decrypt: false - # Delete previous megolm sessions from same device when receiving a new one. - delete_prev_on_new_session: false - # Delete megolm sessions received from a device when the device is deleted. - delete_on_device_delete: false - # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. - periodically_delete_expired: false - # Delete inbound megolm sessions that don't have the received_at field used for - # automatic ratcheting and expired session deletion. This is meant as a migration - # to delete old keys prior to the bridge update. - delete_outdated_inbound: false - # What level of device verification should be required from users? - # - # Valid levels: - # unverified - Send keys to all device in the room. - # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. - # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). - # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. - # Note that creating user signatures from the bridge bot is not currently possible. - # verified - Require manual per-device verification - # (currently only possible by modifying the `trust` column in the `crypto_device` database table). - verification_levels: - # Minimum level for which the bridge should send keys to when bridging messages from the remote network to Matrix. - receive: unverified - # Minimum level that the bridge should accept for incoming Matrix messages. - send: unverified - # Minimum level that the bridge should require for accepting key requests. - share: cross-signed-tofu - # Options for Megolm room key rotation. These options allow you to configure the m.room.encryption event content. - # See https://spec.matrix.org/v1.10/client-server-api/#mroomencryption for more information about that event. - rotation: - # Enable custom Megolm room key rotation settings. Note that these - # settings will only apply to rooms created after this option is set. - enable_custom: false - # The maximum number of milliseconds a session should be used - # before changing it. The Matrix spec recommends 604800000 (a week) - # as the default. - milliseconds: 604800000 - # The maximum number of messages that should be sent with a given a - # session before changing it. The Matrix spec recommends 100 as the - # default. - messages: 100 - # Disable rotating keys when a user's devices change? - # You should not enable this option unless you understand all the implications. - disable_device_change_key_rotation: false - -# Prefix for environment variables. All variables with this prefix must map to valid config fields. -# Nesting in variable names is represented with a dot (.). -# If there are no dots in the name, two underscores (__) are replaced with a dot. -# -# e.g. if the prefix is set to `BRIDGE_`, then `BRIDGE_APPSERVICE__AS_TOKEN` will set appservice.as_token. -# `BRIDGE_appservice.as_token` would work as well, but can't be set in a shell as easily. -# -# The variable names can also have a `_FILE` suffix to tell the bridge to read the value from the -# path set in the variable rather than using the value directly. -# -# If this is null, reading config fields from environment will be disabled. -env_config_prefix: null - -# Logging config. See https://github.com/tulir/zeroconfig for details. -logging: - min_level: debug - writers: - - type: stdout - format: pretty-colored - - type: file - format: json - filename: ./logs/bridge.log - max_size: 100 - max_backups: 10 - compress: false diff --git a/mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go b/mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go deleted file mode 100644 index eab3a2b9..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/legacymigrate.go +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mxmain - -import ( - "bytes" - "context" - "database/sql" - "errors" - "fmt" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/matrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (br *BridgeMain) LegacyMigrateWithAnotherUpgrader(rawRenameTablesQuery any, copyDataQuery string, newDBVersion int, otherTable dbutil.UpgradeTable, otherTableName string, otherNewVersion int) func(ctx context.Context) error { - return func(ctx context.Context) error { - // Unique constraints must have globally unique names on postgres, and renaming the table doesn't rename them, - // so just drop the ones that may conflict with the new schema. - if br.DB.Dialect == dbutil.Postgres { - _, err := br.DB.Exec(ctx, "ALTER TABLE message DROP CONSTRAINT IF EXISTS message_mxid_unique") - if err != nil { - return fmt.Errorf("failed to drop potentially conflicting constraint on message: %w", err) - } - _, err = br.DB.Exec(ctx, "ALTER TABLE reaction DROP CONSTRAINT IF EXISTS reaction_mxid_unique") - if err != nil { - return fmt.Errorf("failed to drop potentially conflicting constraint on reaction: %w", err) - } - } - err := dbutil.DangerousInternalUpgradeVersionTable(ctx, br.DB) - if err != nil { - return err - } - switch renameTablesQuery := rawRenameTablesQuery.(type) { - case string: - _, err = br.DB.Exec(ctx, renameTablesQuery) - case func(context.Context, *dbutil.Database) error: - err = renameTablesQuery(ctx, br.DB) - default: - return fmt.Errorf("invalid type for renameTablesQuery: %T", rawRenameTablesQuery) - } - if err != nil { - return err - } - upgradesTo, compat, err := br.DB.UpgradeTable[0].DangerouslyRun(ctx, br.DB) - if err != nil { - return err - } - if upgradesTo < newDBVersion || compat > newDBVersion { - return fmt.Errorf("unexpected new database version (%d/c:%d, expected %d)", upgradesTo, compat, newDBVersion) - } - if otherTable != nil { - _, err = br.DB.Exec(ctx, fmt.Sprintf("CREATE TABLE %s (version INTEGER, compat INTEGER)", otherTableName)) - if err != nil { - return err - } - otherUpgradesTo, otherCompat, err := otherTable[0].DangerouslyRun(ctx, br.DB) - if err != nil { - return err - } else if otherUpgradesTo < otherNewVersion || otherCompat > otherNewVersion { - return fmt.Errorf("unexpected new database version for %s (%d/c:%d, expected %d)", otherTableName, otherUpgradesTo, otherCompat, otherNewVersion) - } - _, err = br.DB.Exec(ctx, fmt.Sprintf("INSERT INTO %s (version, compat) VALUES ($1, $2)", otherTableName), otherUpgradesTo, otherCompat) - if err != nil { - return err - } - } - copyDataQuery, err = br.DB.Internals().FilterSQLUpgrade(bytes.Split([]byte(copyDataQuery), []byte("\n"))) - if err != nil { - return err - } - _, err = br.DB.Exec(ctx, copyDataQuery) - if err != nil { - return err - } - _, err = br.DB.Exec(ctx, "DELETE FROM database_owner") - if err != nil { - return err - } - _, err = br.DB.Exec(ctx, "INSERT INTO database_owner (key, owner) VALUES (0, $1)", br.DB.Owner) - if err != nil { - return err - } - _, err = br.DB.Exec(ctx, "DELETE FROM version") - if err != nil { - return err - } - _, err = br.DB.Exec(ctx, "INSERT INTO version (version, compat) VALUES ($1, $2)", upgradesTo, compat) - if err != nil { - return err - } - _, err = br.DB.Exec(ctx, "CREATE TABLE database_was_migrated(empty INTEGER)") - if err != nil { - return err - } - - return nil - } -} - -func (br *BridgeMain) LegacyMigrateSimple(renameTablesQuery, copyDataQuery string, newDBVersion int) func(ctx context.Context) error { - return br.LegacyMigrateWithAnotherUpgrader(renameTablesQuery, copyDataQuery, newDBVersion, nil, "", 0) -} - -func (br *BridgeMain) CheckLegacyDB( - expectedVersion int, - minBridgeVersion, - firstMegaVersion string, - migrator func(context.Context) error, - transaction bool, -) { - log := br.Log.With().Str("action", "migrate legacy db").Logger() - ctx := log.WithContext(context.Background()) - exists, err := br.DB.TableExists(ctx, "database_owner") - if err != nil { - log.Err(err).Msg("Failed to check if database_owner table exists") - return - } else if !exists { - return - } - var owner string - err = br.DB.QueryRow(ctx, "SELECT owner FROM database_owner LIMIT 1").Scan(&owner) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - log.Err(err).Msg("Failed to get database owner") - return - } else if owner != br.Name { - if owner != "megabridge/"+br.Name && owner != "" { - log.Warn().Str("db_owner", owner).Msg("Unexpected database owner, not migrating database") - } - return - } - var dbVersion int - err = br.DB.QueryRow(ctx, "SELECT version FROM version").Scan(&dbVersion) - if err != nil { - log.Fatal().Err(err).Msg("Failed to get database version") - return - } else if dbVersion < expectedVersion { - log.Fatal(). - Int("expected_version", expectedVersion). - Int("version", dbVersion). - Msgf("Unsupported database version. Please upgrade to %s %s or higher before upgrading to %s.", br.Name, minBridgeVersion, firstMegaVersion) // zerolog-allow-msgf - return - } else if dbVersion > expectedVersion { - log.Fatal(). - Int("expected_version", expectedVersion). - Int("version", dbVersion). - Msg("Unsupported database version (higher than expected)") - return - } - log.Info().Msg("Detected legacy database, migrating...") - if transaction { - err = br.DB.DoTxn(ctx, nil, migrator) - } else { - err = migrator(ctx) - } - if err != nil { - br.LogDBUpgradeErrorAndExit("main", err, "Failed to migrate legacy database") - } else { - log.Info().Msg("Successfully migrated legacy database") - } -} - -func (br *BridgeMain) postMigrateDMPortal(ctx context.Context, portal *bridgev2.Portal) error { - otherUserID := portal.OtherUserID - if otherUserID == "" { - zerolog.Ctx(ctx).Warn(). - Str("portal_id", string(portal.ID)). - Msg("DM portal has no other user ID") - return nil - } - ghost, err := br.Bridge.GetGhostByID(ctx, otherUserID) - if err != nil { - return fmt.Errorf("failed to get ghost for %s: %w", otherUserID, err) - } - mx := ghost.Intent.(*matrix.ASIntent).Matrix - err = br.Matrix.Bot.EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{ - BotOverride: mx.Client, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("portal_id", string(portal.ID)). - Stringer("room_id", portal.MXID). - Msg("Failed to ensure bot is joined to DM") - } - pls, err := mx.PowerLevels(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("portal_id", string(portal.ID)). - Stringer("room_id", portal.MXID). - Msg("Failed to get power levels in room") - } else { - userLevel := pls.GetUserLevel(mx.UserID) - pls.EnsureUserLevel(br.Matrix.Bot.UserID, userLevel) - if userLevel > 50 { - pls.SetUserLevel(mx.UserID, 50) - } - _, err = mx.SetPowerLevels(ctx, portal.MXID, pls) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("portal_id", string(portal.ID)). - Stringer("room_id", portal.MXID). - Msg("Failed to set power levels") - } - } - portal.UpdateInfoFromGhost(ctx, ghost) - return nil -} - -func (br *BridgeMain) PostMigrate(ctx context.Context) error { - log := br.Log.With().Str("action", "post-migrate").Logger() - wasMigrated, err := br.DB.TableExists(ctx, "database_was_migrated") - if err != nil { - return fmt.Errorf("failed to check if database_was_migrated table exists: %w", err) - } else if !wasMigrated { - return nil - } - log.Info().Msg("Doing post-migration updates to Matrix rooms") - - portals, err := br.Bridge.GetAllPortalsWithMXID(ctx) - if err != nil { - return fmt.Errorf("failed to get all portals: %w", err) - } - for _, portal := range portals { - log := log.With(). - Stringer("room_id", portal.MXID). - Object("portal_key", portal.PortalKey). - Str("room_type", string(portal.RoomType)). - Logger() - log.Debug().Msg("Migrating portal") - if br.PostMigratePortal != nil { - err = br.PostMigratePortal(ctx, portal) - if err != nil { - log.Err(err).Msg("Failed to run post-migrate portal hook") - continue - } - } else { - switch portal.RoomType { - case database.RoomTypeDM: - err = br.postMigrateDMPortal(ctx, portal) - if err != nil { - return fmt.Errorf("failed to update DM portal %s: %w", portal.MXID, err) - } - } - } - _, err = br.Matrix.Bot.SendStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.ElementFunctionalMembersContent{ - ServiceMembers: []id.UserID{br.Matrix.Bot.UserID}, - }) - if err != nil { - log.Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to set service members") - } - } - - _, err = br.DB.Exec(ctx, "DROP TABLE database_was_migrated") - if err != nil { - return fmt.Errorf("failed to drop database_was_migrated table: %w", err) - } - log.Info().Msg("Post-migration updates complete") - return nil -} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/main.go b/mautrix-patched/bridgev2/matrix/mxmain/main.go deleted file mode 100644 index f8303237..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/main.go +++ /dev/null @@ -1,454 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -// Package mxmain contains initialization code for a single-network Matrix bridge using the bridgev2 package. -package mxmain - -import ( - "context" - _ "embed" - "encoding/json" - "errors" - "fmt" - "os" - "os/signal" - "runtime" - "strings" - "syscall" - "time" - - "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog" - "go.mau.fi/util/configupgrade" - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exzerolog" - "go.mau.fi/util/progver" - "gopkg.in/yaml.v3" - flag "maunium.net/go/mauflag" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/bridgev2/commands" - "maunium.net/go/mautrix/bridgev2/matrix" -) - -var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String() -var writeExampleConfig = flag.MakeFull("e", "generate-example-config", "Save the example config to the config path and quit.", "false").Bool() -var dontSaveConfig = flag.MakeFull("n", "no-update", "Don't save updated config to disk.", "false").Bool() -var registrationPath = flag.MakeFull("r", "registration", "The path where to save the appservice registration.", "registration.yaml").String() -var generateRegistration = flag.MakeFull("g", "generate-registration", "Generate registration and quit.", "false").Bool() -var version = flag.MakeFull("v", "version", "View bridge version and quit.", "false").Bool() -var versionJSON = flag.Make().LongKey("version-json").Usage("Print a JSON object representing the bridge version and quit.").Default("false").Bool() -var ignoreUnsupportedDatabase = flag.Make().LongKey("ignore-unsupported-database").Usage("Run even if the database schema is too new").Default("false").Bool() -var ignoreForeignTables = flag.Make().LongKey("ignore-foreign-tables").Usage("Run even if the database contains tables from other programs (like Synapse)").Default("false").Bool() -var ignoreUnsupportedServer = flag.Make().LongKey("ignore-unsupported-server").Usage("Run even if the Matrix homeserver is outdated").Default("false").Bool() -var wantHelp, _ = flag.MakeHelpFlag() - -// BridgeMain contains the main function for a Matrix bridge. -type BridgeMain struct { - // Name is the name of the bridge project, e.g. mautrix-signal. - // Note that when making your own bridges that isn't under github.com/mautrix, - // you should invent your own name and not use the mautrix-* naming scheme. - Name string - // Description is a brief description of the bridge, usually of the form "A Matrix-OtherPlatform puppeting bridge." - Description string - // URL is the Git repository address for the bridge. - URL string - // Version is the latest release of the bridge. InitVersion will compare this to the provided - // git tag to see if the built version is the release or a dev build. - // You can either bump this right after a release or right before, as long as it matches on the release commit. - Version string - // SemCalVer defines whether this bridge uses a mix of semantic and calendar versioning, - // such that the Version field is YY.0M.patch, while git tags are major.YY0M.patch. - SemCalVer bool - - // PostInit is a function that will be called after the bridge has been initialized but before it is started. - PostInit func() - PostStart func() - - // PostMigratePortal is a function that will be called during a legacy - // migration for each portal. - PostMigratePortal func(context.Context, *bridgev2.Portal) error - - // Connector is the network connector for the bridge. - Connector bridgev2.NetworkConnector - - // All fields below are set automatically in Run or InitVersion should not be set manually. - - Log *zerolog.Logger - DB *dbutil.Database - Config *bridgeconfig.Config - Matrix *matrix.Connector - Bridge *bridgev2.Bridge - - ConfigPath string - RegistrationPath string - SaveConfig bool - - ver progver.ProgramVersion - - AdditionalShortFlags string - AdditionalLongFlags string - - manualStop chan int -} - -type VersionJSONOutput struct { - progver.ProgramVersion - - OS string - Arch string - - Mautrix struct { - Version string - Commit string - } -} - -// Run runs the bridge and waits for SIGTERM before stopping. -func (br *BridgeMain) Run() { - br.PreInit() - br.Init() - br.Start() - exitCode := br.WaitForInterrupt() - br.Stop() - os.Exit(exitCode) -} - -// PreInit parses CLI flags and loads the config file. This is called by [Run] and does not need to be called manually. -// -// This also handles all flags that cause the bridge to exit immediately (e.g. `--version` and `--generate-registration`). -func (br *BridgeMain) PreInit() { - br.manualStop = make(chan int, 1) - flag.SetHelpTitles( - fmt.Sprintf("%s - %s", br.Name, br.Description), - fmt.Sprintf("%s [-hgvn%s] [-c ] [-r ]%s", br.Name, br.AdditionalShortFlags, br.AdditionalLongFlags)) - err := flag.Parse() - br.ConfigPath = *configPath - br.RegistrationPath = *registrationPath - br.SaveConfig = !*dontSaveConfig - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) - flag.PrintHelp() - os.Exit(1) - } else if *wantHelp { - flag.PrintHelp() - os.Exit(0) - } else if *version { - fmt.Println(br.ver.VersionDescription) - os.Exit(0) - } else if *versionJSON { - output := VersionJSONOutput{ - ProgramVersion: br.ver, - - OS: runtime.GOOS, - Arch: runtime.GOARCH, - } - output.Mautrix.Commit = mautrix.Commit - output.Mautrix.Version = mautrix.Version - _ = json.NewEncoder(os.Stdout).Encode(output) - os.Exit(0) - } else if *writeExampleConfig { - if *configPath != "-" && *configPath != "/dev/stdout" && *configPath != "/dev/stderr" { - if _, err = os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) { - _, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example") - os.Exit(1) - } - } - networkExample, _, _ := br.Connector.GetConfig() - fullCfg := br.makeFullExampleConfig(networkExample) - if *configPath == "-" { - fmt.Print(fullCfg) - } else { - exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(fullCfg), 0600)) - fmt.Println("Wrote example config to", *configPath) - } - os.Exit(0) - } - br.LoadConfig() - if *generateRegistration { - br.GenerateRegistration() - os.Exit(0) - } - LoadGlobalConfigEnv() -} - -func (br *BridgeMain) GenerateRegistration() { - if !br.SaveConfig { - // We need to save the generated as_token and hs_token in the config - _, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration") - os.Exit(5) - } else if br.Config.Homeserver.Domain == "example.com" { - _, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set") - os.Exit(20) - } - reg := br.Config.GenerateRegistration() - err := reg.Save(br.RegistrationPath) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err) - os.Exit(21) - } - - updateTokens := func(helper configupgrade.Helper) { - helper.Set(configupgrade.Str, reg.AppToken, "appservice", "as_token") - helper.Set(configupgrade.Str, reg.ServerToken, "appservice", "hs_token") - } - upgrader, _ := br.getConfigUpgrader() - _, _, err = configupgrade.Do(br.ConfigPath, true, upgrader, configupgrade.SimpleUpgrader(updateTokens)) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err) - os.Exit(22) - } - fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.") - os.Exit(0) -} - -// Init sets up logging, database connection and creates the Matrix connector and central Bridge struct. -// This is called by [Run] and does not need to be called manually. -func (br *BridgeMain) Init() { - var err error - br.Log, err = br.Config.Logging.Compile() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) - os.Exit(12) - } - exzerolog.SetupDefaults(br.Log) - err = br.validateConfig() - if err != nil { - br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error") - br.Log.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info") - os.Exit(11) - } - - br.Log.Info(). - Str("name", br.Name). - Str("version", br.ver.FormattedVersion). - Time("built_at", br.ver.BuildTime). - Str("go_version", runtime.Version()). - Msg("Initializing bridge") - - br.initDB() - br.Matrix = matrix.NewConnector(br.Config) - br.Matrix.OnWebsocketReplaced = func() { - br.TriggerStop(0) - } - br.Matrix.IgnoreUnsupportedServer = *ignoreUnsupportedServer - br.Bridge = bridgev2.NewBridge("", br.DB, *br.Log, &br.Config.Bridge, br.Matrix, br.Connector, commands.NewProcessor) - br.Matrix.AS.DoublePuppetValue = br.Name - br.Bridge.Commands.(*commands.Processor).AddHandler(&commands.FullHandler{ - Func: func(ce *commands.Event) { - ce.Reply(br.ver.MarkdownDescription()) - }, - Name: "version", - Help: commands.HelpMeta{ - Section: commands.HelpSectionGeneral, - Description: "Get the bridge version.", - }, - }) - if br.PostInit != nil { - br.PostInit() - } -} - -func (br *BridgeMain) initDB() { - br.Log.Debug().Msg("Initializing database connection") - dbConfig := br.Config.Database - if dbConfig.Type == "sqlite3" { - br.Log.WithLevel(zerolog.FatalLevel).Msg("Invalid database type sqlite3. Use sqlite3-fk-wal instead.") - os.Exit(14) - } - if (dbConfig.Type == "sqlite3-fk-wal" || dbConfig.Type == "litestream") && dbConfig.MaxOpenConns != 1 && !strings.Contains(dbConfig.URI, "_txlock=immediate") { - var fixedExampleURI string - if !strings.HasPrefix(dbConfig.URI, "file:") { - fixedExampleURI = fmt.Sprintf("file:%s?_txlock=immediate", dbConfig.URI) - } else if !strings.ContainsRune(dbConfig.URI, '?') { - fixedExampleURI = fmt.Sprintf("%s?_txlock=immediate", dbConfig.URI) - } else { - fixedExampleURI = fmt.Sprintf("%s&_txlock=immediate", dbConfig.URI) - } - br.Log.Warn(). - Str("fixed_uri_example", fixedExampleURI). - Msg("Using SQLite without _txlock=immediate is not recommended") - } - var err error - br.DB, err = dbutil.NewFromConfig("megabridge/"+br.Name, dbConfig, dbutil.ZeroLogger(br.Log.With().Str("db_section", "main").Logger())) - if err != nil { - br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection") - if sqlError := (&sqlite3.Error{}); errors.As(err, sqlError) && sqlError.Code == sqlite3.ErrCorrupt { - os.Exit(18) - } - os.Exit(14) - } - br.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase - br.DB.IgnoreForeignTables = *ignoreForeignTables -} - -func (br *BridgeMain) validateConfig() error { - switch { - case br.Config.Homeserver.Address == "http://example.localhost:8008": - return errors.New("homeserver.address not configured") - case br.Config.Homeserver.Domain == "example.com": - return errors.New("homeserver.domain not configured") - case !bridgeconfig.AllowedHomeserverSoftware[br.Config.Homeserver.Software]: - return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)") - case br.Config.AppService.ASToken == "This value is generated when generating the registration": - return errors.New("appservice.as_token not configured. Did you forget to generate the registration? ") - case br.Config.AppService.HSToken == "This value is generated when generating the registration": - return errors.New("appservice.hs_token not configured. Did you forget to generate the registration? ") - case br.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable": - return errors.New("database.uri not configured") - case !br.Config.Bridge.Permissions.IsConfigured(): - return errors.New("bridge.permissions not configured") - case !strings.Contains(br.Config.AppService.FormatUsername("1234567890"), "1234567890"): - return errors.New("username template is missing user ID placeholder") - default: - cfgValidator, ok := br.Connector.(bridgev2.ConfigValidatingNetwork) - if ok { - err := cfgValidator.ValidateConfig() - if err != nil { - return err - } - } - return nil - } -} - -func (br *BridgeMain) getConfigUpgrader() (configupgrade.BaseUpgrader, any) { - networkExample, networkData, networkUpgrader := br.Connector.GetConfig() - baseConfig := br.makeFullExampleConfig(networkExample) - if networkUpgrader == nil { - networkUpgrader = configupgrade.NoopUpgrader - } - networkUpgraderProxied := &configupgrade.ProxyUpgrader{Target: networkUpgrader, Prefix: []string{"network"}} - upgrader := configupgrade.MergeUpgraders(baseConfig, networkUpgraderProxied, bridgeconfig.Upgrader) - return upgrader, networkData -} - -// LoadConfig upgrades and loads the config file. -// This is called by [Run] and does not need to be called manually. -func (br *BridgeMain) LoadConfig() { - upgrader, networkData := br.getConfigUpgrader() - configData, upgraded, err := configupgrade.Do(br.ConfigPath, br.SaveConfig, upgrader) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Error updating config:", err) - if !upgraded { - os.Exit(10) - } - } - - var cfg bridgeconfig.Config - err = yaml.Unmarshal(configData, &cfg) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err) - os.Exit(10) - } - if networkData != nil { - err = cfg.Network.Decode(networkData) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to parse network config:", err) - os.Exit(10) - } - } - cfg.Bridge.Backfill = cfg.Backfill - if cfg.EnvConfigPrefix != "" { - err = UpdateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix) - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err) - os.Exit(10) - } - } - br.Config = &cfg -} - -// Start starts the bridge after everything has been initialized. -// This is called by [Run] and does not need to be called manually. -func (br *BridgeMain) Start() { - ctx := br.Log.WithContext(context.Background()) - err := br.Bridge.StartConnectors(ctx) - var exitError matrix.ExitError - var dbUpgradeErr bridgev2.DBUpgradeError - if errors.As(err, &exitError) { - exitError.Exit() - } else if errors.As(err, &dbUpgradeErr) { - br.LogDBUpgradeErrorAndExit(dbUpgradeErr.Section, dbUpgradeErr.Err, "Failed to initialize database") - } else if errors.Is(err, bridgev2.ErrSplitPortalMigrationFailed) { - os.Exit(31) - } else if err != nil { - br.Log.Fatal().Err(err).Msg("Failed to start bridge") - } - err = br.PostMigrate(ctx) - if err != nil { - br.Log.Fatal().Err(err).Msg("Failed to run post-migration updates") - } - err = br.Bridge.StartLogins(ctx) - if err != nil { - br.Log.Fatal().Err(err).Msg("Failed to start existing user logins") - } - br.Bridge.PostStart(ctx) - if br.PostStart != nil { - br.PostStart() - } -} - -// WaitForInterrupt waits for a SIGINT or SIGTERM signal. -func (br *BridgeMain) WaitForInterrupt() int { - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - select { - case <-c: - br.Log.Info().Msg("Interrupt signal received from OS") - return 0 - case exitCode := <-br.manualStop: - br.Log.Info().Msg("Internal stop signal received") - return exitCode - } -} - -func (br *BridgeMain) TriggerStop(exitCode int) { - select { - case br.manualStop <- exitCode: - default: - } -} - -// Stop cleanly stops the bridge. This is called by [Run] and does not need to be called manually. -func (br *BridgeMain) Stop() { - br.Bridge.StopWithTimeout(5 * time.Second) -} - -// InitVersion formats the bridge version and build time nicely for things like -// the `version` bridge command on Matrix and the `--version` CLI flag. -// -// The values should generally be set by the build system. For example, assuming you have -// -// var ( -// Tag = "unknown" -// Commit = "unknown" -// BuildTime = "unknown" -// ) -// -// in your main package, then you'd use the following ldflags to fill them appropriately: -// -// go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`'" -// -// You may additionally want to fill the mautrix-go version using another ldflag: -// -// export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | head -n1 | awk '{ print $2 }') -// go build -ldflags "-X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -// -// (to use both at the same time, simply merge the ldflags into one, `-ldflags "-X '...' -X ..."`) -func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) { - br.ver = progver.ProgramVersion{ - Name: br.Name, - URL: br.URL, - BaseVersion: br.Version, - SemCalVer: br.SemCalVer, - }.Init(tag, commit, rawBuildTime) - mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.ver.FormattedVersion, mautrix.DefaultUserAgent) - br.Version = br.ver.FormattedVersion -} diff --git a/mautrix-patched/bridgev2/matrix/mxmain/main_test.go b/mautrix-patched/bridgev2/matrix/mxmain/main_test.go deleted file mode 100644 index 9a71344d..00000000 --- a/mautrix-patched/bridgev2/matrix/mxmain/main_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mxmain_test - -import ( - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/matrix/mxmain" -) - -// Information to find out exactly which commit the bridge was built from. -// These are filled at build time with the -X linker flag. -var ( - Tag = "unknown" - Commit = "unknown" - BuildTime = "unknown" -) - -func ExampleBridgeMain() { - // Set this yourself - var yourConnector bridgev2.NetworkConnector - - m := mxmain.BridgeMain{ - Name: "example-matrix-bridge", - URL: "https://github.com/octocat/matrix-bridge", - Description: "An example Matrix bridge.", - Version: "1.0.0", - - Connector: yourConnector, - } - m.PostInit = func() { - // If you want some code to run after all the setup is done, but before the bridge is started, - // you can set a function in PostInit. This is not required if you don't need to do anything special. - } - m.InitVersion(Tag, Commit, BuildTime) - m.Run() -} diff --git a/mautrix-patched/bridgev2/matrix/no-crypto.go b/mautrix-patched/bridgev2/matrix/no-crypto.go deleted file mode 100644 index fe942f83..00000000 --- a/mautrix-patched/bridgev2/matrix/no-crypto.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build !cgo || nocrypto - -package matrix - -import ( - "errors" -) - -func NewCryptoHelper(c *Connector) Crypto { - if c.Config.Encryption.Allow { - c.Log.Warn().Msg("Bridge built without end-to-bridge encryption, but encryption is enabled in config") - } else { - c.Log.Debug().Msg("Bridge built without end-to-bridge encryption") - } - return nil -} - -var NoSessionFound = errors.New("nil") -var UnknownMessageIndex = NoSessionFound -var DuplicateMessageIndex = NoSessionFound diff --git a/mautrix-patched/bridgev2/matrix/provisioning.go b/mautrix-patched/bridgev2/matrix/provisioning.go deleted file mode 100644 index 1553887c..00000000 --- a/mautrix-patched/bridgev2/matrix/provisioning.go +++ /dev/null @@ -1,704 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/pprof" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exhttp" - "go.mau.fi/util/exstrings" - "go.mau.fi/util/jsontime" - "go.mau.fi/util/ptr" - "go.mau.fi/util/requestlog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/provisionutil" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/federation" - "maunium.net/go/mautrix/id" -) - -type matrixAuthCacheEntry struct { - Expires time.Time - UserID id.UserID -} - -type ProvisioningAPI struct { - Router *http.ServeMux - - br *Connector - log zerolog.Logger - net bridgev2.NetworkConnector - - fedClient *federation.Client - - logins map[string]*ProvLogin - loginsLock sync.RWMutex - - matrixAuthCache map[string]matrixAuthCacheEntry - matrixAuthCacheLock sync.Mutex - - // Set for a given login once credentials have been exported, once in this state the finish - // API is available which will call logout on the client in question. - sessionTransfers map[networkid.UserLoginID]struct{} - sessionTransfersLock sync.Mutex - - // GetAuthFromRequest is a custom function for getting the auth token from - // the request if the Authorization header is not present. - GetAuthFromRequest func(r *http.Request) string - - // GetUserIDFromRequest is a custom function for getting the user ID to - // authenticate as instead of using the user ID provided in the query - // parameter. - GetUserIDFromRequest func(r *http.Request) id.UserID -} - -type provisioningContextKey int - -const ( - provisioningUserKey provisioningContextKey = iota - ProvisioningKeyRequest -) - -func (prov *ProvisioningAPI) GetUser(r *http.Request) *bridgev2.User { - return r.Context().Value(provisioningUserKey).(*bridgev2.User) -} - -func (prov *ProvisioningAPI) GetRouter() *http.ServeMux { - return prov.Router -} - -func (br *Connector) GetProvisioning() bridgev2.IProvisioningAPI { - return br.Provisioning -} - -func (prov *ProvisioningAPI) Init() { - prov.matrixAuthCache = make(map[string]matrixAuthCacheEntry) - prov.logins = make(map[string]*ProvLogin) - prov.sessionTransfers = make(map[networkid.UserLoginID]struct{}) - prov.net = prov.br.Bridge.Network - prov.log = prov.br.Log.With().Str("component", "provisioning").Logger() - prov.fedClient = federation.NewClient("", nil, nil, exhttp.SensibleClientSettings.WithGlobalTimeout(20*time.Second)) - prov.Router = http.NewServeMux() - prov.Router.HandleFunc("GET /v3/whoami", prov.GetWhoami) - prov.Router.HandleFunc("GET /v3/capabilities", prov.GetCapabilities) - prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows) - prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart) - prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep) - prov.Router.HandleFunc("POST /v3/login/cancel/{loginProcessID}", prov.PostLoginCancel) - prov.Router.HandleFunc("POST /v3/logout/{loginID}", prov.PostLogout) - prov.Router.HandleFunc("GET /v3/logins", prov.GetLogins) - prov.Router.HandleFunc("GET /v3/contacts", prov.GetContactList) - prov.Router.HandleFunc("POST /v3/search_users", prov.PostSearchUsers) - prov.Router.HandleFunc("GET /v3/resolve_identifier/{identifier}", prov.GetResolveIdentifier) - prov.Router.HandleFunc("POST /v3/create_dm/{identifier}", prov.PostCreateDM) - prov.Router.HandleFunc("POST /v3/create_group/{type}", prov.PostCreateGroup) - prov.Router.HandleFunc("POST /v3/backfill/{roomID}", prov.PostPaginate) - prov.Router.HandleFunc("GET /v3/image_pack/import", prov.ImportImagePack) - prov.Router.HandleFunc("POST /v3/image_pack/import", prov.ImportImagePack) - prov.Router.HandleFunc("GET /v3/image_pack/list", prov.ListImagePacks) - - if prov.br.Config.Provisioning.EnableSessionTransfers { - prov.log.Debug().Msg("Enabling session transfer API") - prov.Router.HandleFunc("POST /v3/session_transfer/init", prov.PostInitSessionTransfer) - prov.Router.HandleFunc("POST /v3/session_transfer/finish", prov.PostFinishSessionTransfer) - } - - if prov.br.Config.Provisioning.DebugEndpoints { - prov.log.Debug().Msg("Enabling debug API at /debug") - debugRouter := http.NewServeMux() - debugRouter.HandleFunc("GET /pprof/cmdline", pprof.Cmdline) - debugRouter.HandleFunc("GET /pprof/profile", pprof.Profile) - debugRouter.HandleFunc("GET /pprof/symbol", pprof.Symbol) - debugRouter.HandleFunc("GET /pprof/trace", pprof.Trace) - debugRouter.HandleFunc("/pprof/", pprof.Index) - prov.br.AS.Router.Handle("/debug/", exhttp.ApplyMiddleware( - debugRouter, - exhttp.StripPrefix("/debug"), - hlog.NewHandler(prov.br.Log.With().Str("component", "debug api").Logger()), - requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), - prov.DebugAuthMiddleware, - )) - } - - errorBodies := exhttp.ErrorBodies{ - NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), - MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), - } - prov.br.AS.Router.Handle("/_matrix/provision/", exhttp.ApplyMiddleware( - prov.Router, - exhttp.StripPrefix("/_matrix/provision"), - hlog.NewHandler(prov.log), - hlog.RequestIDHandler("request_id", "Request-Id"), - exhttp.CORSMiddleware, - requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), - exhttp.HandleErrors(errorBodies), - prov.AuthMiddleware, - )) -} - -func (prov *ProvisioningAPI) checkMatrixAuth(ctx context.Context, userID id.UserID, token string) error { - prov.matrixAuthCacheLock.Lock() - defer prov.matrixAuthCacheLock.Unlock() - if cached, ok := prov.matrixAuthCache[token]; ok && cached.Expires.After(time.Now()) && cached.UserID == userID { - return nil - } else if client, err := prov.br.DoublePuppet.newClient(ctx, userID, token); err != nil { - return err - } else if whoami, err := client.Whoami(ctx); err != nil { - return err - } else if whoami.UserID != userID { - return fmt.Errorf("mismatching user ID (%q != %q)", whoami.UserID, userID) - } else { - prov.matrixAuthCache[token] = matrixAuthCacheEntry{ - Expires: time.Now().Add(5 * time.Minute), - UserID: whoami.UserID, - } - return nil - } -} - -func (prov *ProvisioningAPI) checkFederatedMatrixAuth(ctx context.Context, userID id.UserID, token string) error { - homeserver := userID.Homeserver() - wrappedToken := fmt.Sprintf("%s:%s", homeserver, token) - // TODO smarter locking - prov.matrixAuthCacheLock.Lock() - defer prov.matrixAuthCacheLock.Unlock() - if cached, ok := prov.matrixAuthCache[wrappedToken]; ok && cached.Expires.After(time.Now()) && cached.UserID == userID { - return nil - } else if validationResult, err := prov.fedClient.GetOpenIDUserInfo(ctx, homeserver, token); err != nil { - return fmt.Errorf("failed to validate OpenID token: %w", err) - } else if validationResult.Sub != userID { - return fmt.Errorf("mismatching user ID (%q != %q)", validationResult, userID) - } else { - prov.matrixAuthCache[wrappedToken] = matrixAuthCacheEntry{ - Expires: time.Now().Add(1 * time.Hour), - UserID: userID, - } - return nil - } -} - -func disabledAuth(w http.ResponseWriter, r *http.Request) { - mautrix.MForbidden.WithMessage("Provisioning API is disabled").Write(w) -} - -func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler { - secret := prov.br.Config.Provisioning.SharedSecret - if len(secret) < 16 { - return http.HandlerFunc(disabledAuth) - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if auth == "" { - mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) - } else if !exstrings.ConstantTimeEqual(auth, secret) { - mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) - } else { - h.ServeHTTP(w, r) - } - }) -} - -func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { - secret := prov.br.Config.Provisioning.SharedSecret - if len(secret) < 16 { - return http.HandlerFunc(disabledAuth) - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if auth == "" && prov.GetAuthFromRequest != nil { - auth = prov.GetAuthFromRequest(r) - } - if auth == "" { - mautrix.MMissingToken.WithMessage("Missing auth token").Write(w) - return - } - userID := id.UserID(r.URL.Query().Get("user_id")) - if userID == "" && prov.GetUserIDFromRequest != nil { - userID = prov.GetUserIDFromRequest(r) - } - if !exstrings.ConstantTimeEqual(auth, secret) { - var err error - if !prov.br.Config.Provisioning.AllowMatrixAuth { - err = errors.New("matrix auth is disabled") - } else if strings.HasPrefix(auth, "openid:") { - err = prov.checkFederatedMatrixAuth(r.Context(), userID, strings.TrimPrefix(auth, "openid:")) - } else { - err = prov.checkMatrixAuth(r.Context(), userID, auth) - } - if err != nil { - zerolog.Ctx(r.Context()).Warn().Err(err). - Msg("Provisioning API request contained invalid auth") - mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w) - return - } - } - user, err := prov.br.Bridge.GetUserByMXID(r.Context(), userID) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get user") - mautrix.MUnknown.WithMessage("Failed to get user").Write(w) - return - } - // TODO handle user being nil? - // TODO per-endpoint permissions? - if !user.Permissions.Login { - mautrix.MForbidden.WithMessage("User does not have login permissions").Write(w) - return - } - - ctx := context.WithValue(r.Context(), ProvisioningKeyRequest, r) - ctx = context.WithValue(ctx, provisioningUserKey, user) - h.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -type RespWhoami struct { - Network bridgev2.BridgeName `json:"network"` - LoginFlows []bridgev2.LoginFlow `json:"login_flows"` - Homeserver string `json:"homeserver"` - BridgeBot id.UserID `json:"bridge_bot"` - CommandPrefix string `json:"command_prefix"` - - ManagementRoom id.RoomID `json:"management_room,omitempty"` - Logins []RespWhoamiLogin `json:"logins"` -} - -type RespWhoamiLogin struct { - // Deprecated - StateEvent status.BridgeStateEvent `json:"state_event"` - // Deprecated - StateTS jsontime.Unix `json:"state_ts"` - // Deprecated - StateReason string `json:"state_reason,omitempty"` - // Deprecated - StateInfo map[string]any `json:"state_info,omitempty"` - - State status.BridgeState `json:"state"` - ID networkid.UserLoginID `json:"id"` - Name string `json:"name"` - Profile status.RemoteProfile `json:"profile"` - SpaceRoom id.RoomID `json:"space_room,omitempty"` -} - -func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) { - user := prov.GetUser(r) - resp := &RespWhoami{ - Network: prov.br.Bridge.Network.GetName(), - LoginFlows: prov.br.Bridge.Network.GetLoginFlows(), - Homeserver: prov.br.AS.HomeserverDomain, - BridgeBot: prov.br.Bot.UserID, - CommandPrefix: prov.br.Config.Bridge.CommandPrefix, - ManagementRoom: user.ManagementRoom, - } - logins := user.GetUserLogins() - resp.Logins = make([]RespWhoamiLogin, len(logins)) - for i, login := range logins { - prevState := login.BridgeState.GetPrevUnsent() - // Clear redundant fields - prevState.UserID = "" - prevState.RemoteID = "" - prevState.RemoteName = "" - prevState.RemoteProfile = status.RemoteProfile{} - resp.Logins[i] = RespWhoamiLogin{ - StateEvent: prevState.StateEvent, - StateTS: prevState.Timestamp, - StateReason: prevState.Reason, - StateInfo: prevState.Info, - State: prevState, - - ID: login.ID, - Name: login.RemoteName, - Profile: login.RemoteProfile, - SpaceRoom: login.SpaceRoom, - } - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -type RespLoginFlows struct { - Flows []bridgev2.LoginFlow `json:"flows"` -} - -type RespSubmitLogin struct { - LoginID string `json:"login_id"` - *bridgev2.LoginStep -} - -func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Request) { - exhttp.WriteJSONResponse(w, http.StatusOK, &RespLoginFlows{ - Flows: prov.net.GetLoginFlows(), - }) -} - -func (prov *ProvisioningAPI) GetCapabilities(w http.ResponseWriter, r *http.Request) { - exhttp.WriteJSONResponse(w, http.StatusOK, &prov.net.GetCapabilities().Provisioning) -} - -func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request) { - user := prov.GetUser(r) - userLoginID := networkid.UserLoginID(r.PathValue("loginID")) - if userLoginID == "all" { - for { - login := user.GetDefaultLogin() - if login == nil { - break - } - login.Logout(r.Context()) - } - } else { - userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) - if userLogin == nil || userLogin.UserMXID != user.MXID { - mautrix.MNotFound.WithMessage("Login not found").Write(w) - return - } - userLogin.Logout(r.Context()) - } - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) -} - -type RespGetLogins struct { - LoginIDs []networkid.UserLoginID `json:"login_ids"` -} - -func (prov *ProvisioningAPI) GetLogins(w http.ResponseWriter, r *http.Request) { - user := prov.GetUser(r) - exhttp.WriteJSONResponse(w, http.StatusOK, &RespGetLogins{LoginIDs: user.GetUserLoginIDs()}) -} - -func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r *http.Request) (*bridgev2.UserLogin, bool) { - userLoginID := networkid.UserLoginID(r.URL.Query().Get("login_id")) - if userLoginID == "" { - return nil, false - } - userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID) - if userLogin == nil || userLogin.UserMXID != prov.GetUser(r).MXID { - hlog.FromRequest(r).Warn(). - Str("login_id", string(userLoginID)). - Msg("Tried to use non-existent login, returning 404") - mautrix.MNotFound.WithMessage("Login not found").Write(w) - return nil, true - } - return userLogin, false -} - -var ErrNotLoggedIn = mautrix.RespError{ - Err: "Not logged in", - ErrCode: "FI.MAU.NOT_LOGGED_IN", - StatusCode: http.StatusBadRequest, -} - -func (prov *ProvisioningAPI) GetLoginForRequest(w http.ResponseWriter, r *http.Request) *bridgev2.UserLogin { - userLogin, failed := prov.GetExplicitLoginForRequest(w, r) - if userLogin != nil || failed { - return userLogin - } - userLogin = prov.GetUser(r).GetDefaultLogin() - if userLogin == nil { - ErrNotLoggedIn.Write(w) - return nil - } - return userLogin -} - -type WritableError interface { - Write(w http.ResponseWriter) -} - -func RespondWithError(w http.ResponseWriter, err error, message string) { - var we WritableError - if errors.As(err, &we) { - we.Write(w) - } else { - mautrix.MUnknown.WithMessage(message).WithInternalError(err).Write(w) - } -} - -func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http.Request, createChat bool) { - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - resp, err := provisionutil.ResolveIdentifier(r.Context(), login, r.PathValue("identifier"), createChat) - if err != nil { - RespondWithError(w, err, "Internal error resolving identifier") - } else if resp == nil { - mautrix.MNotFound.WithMessage("Identifier not found").Write(w) - } else { - status := http.StatusOK - if resp.JustCreated { - status = http.StatusCreated - } - exhttp.WriteJSONResponse(w, status, resp) - } -} - -func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Request) { - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - resp, err := provisionutil.GetContactList(r.Context(), login) - if err != nil { - RespondWithError(w, err, "Internal error getting contact list") - return - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -type ReqSearchUsers struct { - Query string `json:"query"` -} - -func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Request) { - var req ReqSearchUsers - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) - return - } - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - resp, err := provisionutil.SearchUsers(r.Context(), login, req.Query) - if err != nil { - RespondWithError(w, err, "Internal error searching users") - return - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -func (prov *ProvisioningAPI) GetResolveIdentifier(w http.ResponseWriter, r *http.Request) { - prov.doResolveIdentifier(w, r, false) -} - -func (prov *ProvisioningAPI) PostCreateDM(w http.ResponseWriter, r *http.Request) { - prov.doResolveIdentifier(w, r, true) -} - -func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Request) { - var req bridgev2.GroupCreateParams - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) - return - } - req.Type = r.PathValue("type") - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - resp, err := provisionutil.CreateGroup(r.Context(), login, &req) - if err != nil { - RespondWithError(w, err, "Internal error creating group") - return - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -func (prov *ProvisioningAPI) PostPaginate(w http.ResponseWriter, r *http.Request) { - if !prov.br.Capabilities.BatchSending { - mautrix.MUnrecognized.WithMessage("Homeserver does not support batch sending historical messages").Write(w) - return - } else if !prov.br.Config.Backfill.Queue.Manual { - mautrix.MUnrecognized.WithMessage("Manual backfill is not enabled").Write(w) - return - } - log := zerolog.Ctx(r.Context()) - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - targetRoomID := id.RoomID(r.PathValue("roomID")) - portal, err := prov.br.Bridge.GetPortalByMXID(r.Context(), targetRoomID) - if err != nil { - log.Err(err).Msg("Failed to get portal for pagination") - RespondWithError(w, err, "Internal error getting portal") - } else if portal == nil { - log.Debug().Stringer("target_room_id", targetRoomID).Msg("Paginate requested for unknown portal room") - mautrix.MNotFound.WithMessage("Portal not found").Write(w) - } else if task, err := prov.br.Bridge.DB.BackfillTask.GetNextForPortal(r.Context(), portal.PortalKey, false); err != nil { - log.Err(err).Msg("Failed to get backfill task for portal") - RespondWithError(w, err, "Internal error getting backfill task") - } else if task == nil { - log.Debug().Msg("No backfill task found for portal") - w.WriteHeader(http.StatusNoContent) - } else { - log.Info(). - Object("portal_key", portal.PortalKey). - Any("current_backfill_task", task). - Msg("Sending manual backfill request to backfill queue") - doneChan := make(chan error, 1) - var done atomic.Bool - prov.br.Bridge.WakeupBackfillQueue(&bridgev2.ManualBackfill{ - Source: login, - Portal: portal, - DoneCallback: func(err error) { - if done.Swap(true) { - log.Warn().Err(err).Msg("Backfill done callback called multiple times, ignoring") - return - } - log.Debug().Msg("Backfill done callback called, sending result to channel") - doneChan <- err - close(doneChan) - }, - }) - select { - case err = <-doneChan: - if err != nil { - RespondWithError(w, err, "Internal error backfilling") - } else { - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) - } - case <-time.After(25 * time.Second): - log.Warn().Msg("Backfill did not complete within 25 seconds, returning timeout") - mautrix.MUnknown.WithMessage("Backfill did not complete within 25 seconds").Write(w) - case <-r.Context().Done(): - log.Warn().Msg("Request cancelled while waiting for backfill to complete") - } - } -} - -func (prov *ProvisioningAPI) ImportImagePack(w http.ResponseWriter, r *http.Request) { - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - var resp any - var err error - if r.Method == http.MethodPost { - resp, err = provisionutil.ImportImagePack(r.Context(), login, r.URL.Query().Get("pack_url")) - } else { - resp, err = provisionutil.PreviewImagePack(r.Context(), login, r.URL.Query().Get("pack_url")) - } - if err != nil { - RespondWithError(w, err, "Internal error importing image pack") - return - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -func (prov *ProvisioningAPI) ListImagePacks(w http.ResponseWriter, r *http.Request) { - login := prov.GetLoginForRequest(w, r) - if login == nil { - return - } - resp, err := provisionutil.ListImagePacks(r.Context(), login) - if err != nil { - RespondWithError(w, err, "Internal error listing image packs") - return - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -type ReqExportCredentials struct { - RemoteID networkid.UserLoginID `json:"remote_id"` -} - -type RespExportCredentials struct { - Credentials any `json:"credentials"` -} - -func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *http.Request) { - prov.sessionTransfersLock.Lock() - defer prov.sessionTransfersLock.Unlock() - - var req ReqExportCredentials - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) - return - } - - user := prov.GetUser(r) - logins := user.GetUserLogins() - var loginToExport *bridgev2.UserLogin - for _, login := range logins { - if login.ID == req.RemoteID { - loginToExport = login - break - } - } - if loginToExport == nil { - mautrix.MNotFound.WithMessage("No matching user login found").Write(w) - return - } - - client, ok := loginToExport.Client.(bridgev2.CredentialExportingNetworkAPI) - if !ok { - mautrix.MUnrecognized.WithMessage("This bridge does not support exporting credentials").Write(w) - return - } - - if _, ok := prov.sessionTransfers[loginToExport.ID]; ok { - // Warn, but allow, double exports. This might happen if a client crashes handling creds, - // and should be safe to call multiple times. - zerolog.Ctx(r.Context()).Warn().Msg("Exporting already exported credentials") - } - - // Disconnect now so we don't use the same network session in two places at once - client.Disconnect() - exhttp.WriteJSONResponse(w, http.StatusOK, &RespExportCredentials{ - Credentials: client.ExportCredentials(r.Context()), - }) -} - -func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r *http.Request) { - prov.sessionTransfersLock.Lock() - defer prov.sessionTransfersLock.Unlock() - - var req ReqExportCredentials - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) - return - } - - user := prov.GetUser(r) - logins := user.GetUserLogins() - var loginToExport *bridgev2.UserLogin - for _, login := range logins { - if login.ID == req.RemoteID { - loginToExport = login - break - } - } - if loginToExport == nil { - mautrix.MNotFound.WithMessage("No matching user login found").Write(w) - return - } else if _, ok := prov.sessionTransfers[loginToExport.ID]; !ok { - mautrix.MBadState.WithMessage("No matching credential export found").Write(w) - return - } - - zerolog.Ctx(r.Context()).Info(). - Str("remote_name", string(req.RemoteID)). - Msg("Logging out remote after finishing credential export") - - loginToExport.Client.LogoutRemote(r.Context()) - delete(prov.sessionTransfers, req.RemoteID) - - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) -} diff --git a/mautrix-patched/bridgev2/matrix/provisioninglogin.go b/mautrix-patched/bridgev2/matrix/provisioninglogin.go deleted file mode 100644 index 02e09789..00000000 --- a/mautrix-patched/bridgev2/matrix/provisioninglogin.go +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "sync" - "time" - - "github.com/rs/xid" - "github.com/rs/zerolog" - "go.mau.fi/util/exhttp" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/status" -) - -type ProvLogin struct { - ID string - Process bridgev2.LoginProcess - PrevStep *bridgev2.LoginStep - NextStep *bridgev2.LoginStep - Override *bridgev2.UserLogin - Lock sync.Mutex - - Ctx context.Context - CancelCtx context.CancelFunc -} - -var ErrNilStep = errors.New("bridge returned nil step with no error") -var ErrTooManyLogins = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.TOO_MANY_LOGINS", Err: "Maximum number of logins exceeded"} -var ErrLoginCancelled = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_CANCELLED", Err: "Login process was cancelled"} -var ErrLoginTimedOut = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_TIMED_OUT", Err: "Login process timed out"} -var ErrLoginAlreadyFinished = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_ALREADY_FINISHED", Err: "Login process was already finished"} - -func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) { - overrideLogin, failed := prov.GetExplicitLoginForRequest(w, r) - if failed { - return - } - user := prov.GetUser(r) - if overrideLogin == nil && user.HasTooManyLogins() { - ErrTooManyLogins.AppendMessage(" (%d)", user.Permissions.MaxLogins).Write(w) - return - } - login, err := prov.net.CreateLogin(r.Context(), user, r.PathValue("flowID")) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create login process") - RespondWithError(w, err, "Internal error creating login process") - return - } - var firstStep *bridgev2.LoginStep - overridable, ok := login.(bridgev2.LoginProcessWithOverride) - if ok && overrideLogin != nil { - firstStep, err = overridable.StartWithOverride(r.Context(), overrideLogin) - } else { - firstStep, err = login.Start(r.Context()) - } - if err == nil && firstStep == nil { - err = ErrNilStep - } - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to start login") - RespondWithError(w, err, "Internal error starting login") - return - } - loginID := xid.New().String() - ctx, cancel := context.WithTimeout(prov.br.Bridge.BackgroundCtx, 30*time.Minute) - ctx = user.Log.With(). - Str("login_id", loginID). - Logger().WithContext(ctx) - provLogin := &ProvLogin{ - ID: loginID, - Process: login, - NextStep: firstStep, - Override: overrideLogin, - Ctx: ctx, - CancelCtx: cancel, - } - go prov.handleLoginTimeout(provLogin) - prov.loginsLock.Lock() - prov.logins[loginID] = provLogin - prov.loginsLock.Unlock() - zerolog.Ctx(r.Context()).Info(). - Str("login_id", loginID). - Any("first_step", firstStep). - Msg("Created login process") - exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep}) -} - -func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) { - loginID := r.PathValue("loginProcessID") - prov.loginsLock.RLock() - login, ok := prov.logins[loginID] - prov.loginsLock.RUnlock() - if !ok { - zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") - mautrix.MNotFound.WithMessage("Login not found").Write(w) - return - } - stepID := r.PathValue("stepID") - stepType := bridgev2.LoginStepType(r.PathValue("stepType")) - var params map[string]string - var rawParams json.RawMessage - switch stepType { - case bridgev2.LoginStepTypeUserInput, bridgev2.LoginStepTypeCookies: - err := json.NewDecoder(r.Body).Decode(¶ms) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") - mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) - return - } - case bridgev2.LoginStepTypeWebAuthn: - var err error - rawParams, err = io.ReadAll(r.Body) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to read request body") - mautrix.MNotJSON.WithMessage("Failed to read request body").Write(w) - return - } else if !json.Valid(rawParams) { - mautrix.MNotJSON.WithMessage("Request body is not valid JSON").Write(w) - return - } - case bridgev2.LoginStepTypeDisplayAndWait: - // no params - case bridgev2.LoginStepTypeComplete: - ErrLoginAlreadyFinished.Write(w) - return - default: - mautrix.MUnrecognized.WithMessage("Invalid step type %q", r.PathValue("stepType")).Write(w) - return - } - resp, err := prov.doLoginStep(r.Context(), login, stepType, stepID, params, rawParams) - if err != nil { - zerolog.Ctx(r.Context()).Err(err).Msg("Failed to complete login step") - RespondWithError(w, err, "Internal error in login step") - } else if resp.Type == bridgev2.LoginStepTypeWebAuthn && prov.br.Config.Provisioning.FailOnWebAuthn { - prov.deleteLogin(login, true) - zerolog.Ctx(r.Context()).Warn().Msg("Got WebAuthn step, failing login") - bridgev2.RespError{ - ErrCode: "COM.BEEPER.WEBAUTHN_UNSUPPORTED", - Err: "Logging in with a passkey is not yet supported", - StatusCode: http.StatusBadRequest, - }.Write(w) - } else { - exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: resp}) - } -} - -func (prov *ProvisioningAPI) PostLoginCancel(w http.ResponseWriter, r *http.Request) { - loginID := r.PathValue("loginProcessID") - prov.loginsLock.RLock() - login, ok := prov.logins[loginID] - prov.loginsLock.RUnlock() - if !ok { - zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found") - mautrix.MNotFound.WithMessage("Login not found").Write(w) - return - } - prov.deleteLogin(login, true) - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) -} - -func (prov *ProvisioningAPI) doLoginStep( - ctx context.Context, - login *ProvLogin, - expectedType bridgev2.LoginStepType, - expectedID string, - params map[string]string, - rawParams json.RawMessage, -) (*bridgev2.LoginStep, error) { - log := zerolog.Ctx(ctx).With().Str("login_id", login.ID).Logger() - var returnPrevIfMatch bool - if !login.Lock.TryLock() { - log.Warn().Msg("Failed to acquire login lock immediately") - returnPrevIfMatch = true - login.Lock.Lock() - } - defer login.Lock.Unlock() - if login.Ctx.Err() != nil { - prov.deleteLogin(login, true) - if login.NextStep.Type == bridgev2.LoginStepTypeComplete { - return login.NextStep, nil - } else if errors.Is(login.Ctx.Err(), context.DeadlineExceeded) { - return nil, ErrLoginTimedOut - } - return nil, ErrLoginCancelled - } - - if returnPrevIfMatch && login.PrevStep != nil && login.PrevStep.StepID == expectedID { - log.Debug(). - Str("prev_step_id", login.PrevStep.StepID). - Any("next_step", login.NextStep). - Msg("Login step that failed to acquire lock requested previous ID, returning last response") - return login.NextStep, nil - } - if login.NextStep.StepID != expectedID { - log.Warn(). - Str("request_step_id", expectedID). - Str("expected_step_id", login.NextStep.StepID). - Msg("Step ID does not match") - return nil, mautrix.MBadState.WithMessage("Step ID does not match") - } - if login.NextStep.Type != expectedType { - log.Warn(). - Str("request_step_type", string(expectedType)). - Str("expected_step_type", string(login.NextStep.Type)). - Msg("Step type does not match") - return nil, mautrix.MBadState.WithMessage("Step type does not match") - } - log.Debug(). - Str("step_id", login.NextStep.StepID). - Str("step_type", string(login.NextStep.Type)). - Msg("Submitting login step") - var nextStep *bridgev2.LoginStep - var err error - switch login.NextStep.Type { - case bridgev2.LoginStepTypeUserInput: - nextStep, err = login.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(login.Ctx, params) - case bridgev2.LoginStepTypeCookies: - nextStep, err = login.Process.(bridgev2.LoginProcessCookies).SubmitCookies(login.Ctx, params) - case bridgev2.LoginStepTypeDisplayAndWait: - nextStep, err = login.Process.(bridgev2.LoginProcessDisplayAndWait).Wait(login.Ctx) - case bridgev2.LoginStepTypeWebAuthn: - nextStep, err = login.Process.(bridgev2.LoginProcessWebAuthn).SubmitWebAuthnResponse(login.Ctx, rawParams) - default: - panic("Impossible state") - } - if err != nil { - prov.deleteLogin(login, true) - return nil, err - } else if nextStep == nil { - prov.deleteLogin(login, true) - return nil, ErrNilStep - } - login.PrevStep = login.NextStep - login.NextStep = nextStep - if nextStep.Type == bridgev2.LoginStepTypeComplete { - prov.handleCompleteStep(login, nextStep) - } else { - log.Debug().Any("next_step", nextStep).Msg("Returning next login step") - } - return nextStep, nil -} - -func (prov *ProvisioningAPI) handleCompleteStep(login *ProvLogin, step *bridgev2.LoginStep) { - ctx := login.Ctx - zerolog.Ctx(ctx).Info(). - Str("step_id", step.StepID). - Str("user_login_id", string(step.CompleteParams.UserLoginID)). - Msg("Login completed successfully") - defer login.CancelCtx() - prov.deleteLogin(login, false) - if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID { - return - } - zerolog.Ctx(ctx).Info(). - Str("old_login_id", string(login.Override.ID)). - Str("new_login_id", string(step.CompleteParams.UserLoginID)). - Msg("Login resulted in different remote ID than what was being overridden. Deleting previous login") - login.Override.Delete(ctx, status.BridgeState{ - StateEvent: status.StateLoggedOut, - Reason: "LOGIN_OVERRIDDEN", - }, bridgev2.DeleteOpts{LogoutRemote: true}) -} - -func (prov *ProvisioningAPI) handleLoginTimeout(login *ProvLogin) { - <-login.Ctx.Done() - if errors.Is(login.Ctx.Err(), context.DeadlineExceeded) { - zerolog.Ctx(login.Ctx).Warn().Msg("Login process timed out, deleting") - login.Process.Cancel() - prov.loginsLock.Lock() - delete(prov.logins, login.ID) - prov.loginsLock.Unlock() - } -} - -func (prov *ProvisioningAPI) deleteLogin(login *ProvLogin, cancel bool) { - if cancel { - login.Process.Cancel() - login.CancelCtx() - } - prov.loginsLock.Lock() - delete(prov.logins, login.ID) - prov.loginsLock.Unlock() -} diff --git a/mautrix-patched/bridgev2/matrix/publicmedia.go b/mautrix-patched/bridgev2/matrix/publicmedia.go deleted file mode 100644 index 82ea8c2b..00000000 --- a/mautrix-patched/bridgev2/matrix/publicmedia.go +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "slices" - "strings" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var _ bridgev2.MatrixConnectorWithPublicMedia = (*Connector)(nil) - -func (br *Connector) initPublicMedia() error { - if !br.Config.PublicMedia.Enabled { - return nil - } else if br.GetPublicAddress() == "" { - return fmt.Errorf("public media is enabled in config, but no public address is set") - } else if br.Config.PublicMedia.HashLength > 32 { - return fmt.Errorf("public media hash length is too long") - } else if br.Config.PublicMedia.HashLength < 0 { - return fmt.Errorf("public media hash length is negative") - } - br.pubMediaSigKey = []byte(br.Config.PublicMedia.SigningKey) - br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}", br.serveDatabasePublicMedia) - br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}/{filename}", br.serveDatabasePublicMedia) - br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia) - br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}/{filename}", br.servePublicMedia) - return nil -} - -func (br *Connector) hashContentURI(uri id.ContentURI, expiry []byte) []byte { - hasher := hmac.New(sha256.New, br.pubMediaSigKey) - hasher.Write([]byte(uri.String())) - hasher.Write(expiry) - return hasher.Sum(expiry)[:br.Config.PublicMedia.HashLength+len(expiry)] -} - -func (br *Connector) hashDBPublicMedia(pm *database.PublicMedia) []byte { - hasher := hmac.New(sha256.New, br.pubMediaSigKey) - hasher.Write([]byte(pm.MXC.String())) - hasher.Write([]byte(pm.MimeType)) - if pm.Keys != nil { - hasher.Write([]byte(pm.Keys.Version)) - hasher.Write([]byte(pm.Keys.Key.Algorithm)) - hasher.Write([]byte(pm.Keys.Key.Key)) - hasher.Write([]byte(pm.Keys.InitVector)) - hasher.Write([]byte(pm.Keys.Hashes.SHA256)) - } - return hasher.Sum(nil)[:br.Config.PublicMedia.HashLength] -} - -func (br *Connector) makePublicMediaChecksum(uri id.ContentURI) []byte { - var expiresAt []byte - if br.Config.PublicMedia.Expiry > 0 { - expiresAtInt := time.Now().Add(time.Duration(br.Config.PublicMedia.Expiry) * time.Second).Unix() - expiresAt = binary.BigEndian.AppendUint64(nil, uint64(expiresAtInt)) - } - return br.hashContentURI(uri, expiresAt) -} - -func (br *Connector) verifyPublicMediaChecksum(uri id.ContentURI, checksum []byte) (valid, expired bool) { - var expiryBytes []byte - if br.Config.PublicMedia.Expiry > 0 { - if len(checksum) < 8 { - return - } - expiryBytes = checksum[:8] - expiresAtInt := binary.BigEndian.Uint64(expiryBytes) - expired = time.Now().Unix() > int64(expiresAtInt) - } - valid = hmac.Equal(checksum, br.hashContentURI(uri, expiryBytes)) - return -} - -var proxyHeadersToCopy = []string{ - "Content-Type", "Content-Disposition", "Content-Length", "Content-Security-Policy", - "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", - "Cache-Control", "Cross-Origin-Resource-Policy", -} - -func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) { - contentURI := id.ContentURI{ - Homeserver: r.PathValue("server"), - FileID: r.PathValue("mediaID"), - } - if !contentURI.IsValid() { - http.Error(w, "invalid content URI", http.StatusBadRequest) - return - } - checksum, err := base64.RawURLEncoding.DecodeString(r.PathValue("checksum")) - if err != nil || !hmac.Equal(checksum, br.makePublicMediaChecksum(contentURI)) { - http.Error(w, "invalid base64 in checksum", http.StatusBadRequest) - return - } else if valid, expired := br.verifyPublicMediaChecksum(contentURI, checksum); !valid { - http.Error(w, "invalid checksum", http.StatusNotFound) - return - } else if expired { - http.Error(w, "checksum expired", http.StatusGone) - return - } - br.doProxyMedia(w, r, contentURI, nil, "") -} - -func (br *Connector) serveDatabasePublicMedia(w http.ResponseWriter, r *http.Request) { - if !br.Config.PublicMedia.UseDatabase { - http.Error(w, "public media short links are disabled", http.StatusNotFound) - return - } - log := zerolog.Ctx(r.Context()) - media, err := br.Bridge.DB.PublicMedia.Get(r.Context(), r.PathValue("customID")) - if err != nil { - log.Err(err).Msg("Failed to get public media from database") - http.Error(w, "failed to get media metadata", http.StatusInternalServerError) - return - } else if media == nil { - http.Error(w, "media ID not found", http.StatusNotFound) - return - } else if !media.Expiry.IsZero() && media.Expiry.Before(time.Now()) { - // This is not gone as it can still be refreshed in the DB - http.Error(w, "media expired", http.StatusNotFound) - return - } else if media.Keys != nil && media.Keys.PrepareForDecryption() != nil { - http.Error(w, "media keys are malformed", http.StatusInternalServerError) - return - } - br.doProxyMedia(w, r, media.MXC, media.Keys, media.MimeType) -} - -var safeMimes = []string{ - "text/css", "text/plain", "text/csv", - "application/json", "application/ld+json", - "image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif", - "video/mp4", "video/webm", "video/ogg", "video/quicktime", - "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", - "audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac", -} - -func (br *Connector) doProxyMedia(w http.ResponseWriter, r *http.Request, contentURI id.ContentURI, encInfo *attachment.EncryptedFile, mimeType string) { - resp, err := br.Bot.Download(r.Context(), contentURI) - if err != nil { - zerolog.Ctx(r.Context()).Warn().Stringer("uri", contentURI).Err(err).Msg("Failed to download media to proxy") - http.Error(w, "failed to download media", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - for _, hdr := range proxyHeadersToCopy { - w.Header()[hdr] = resp.Header[hdr] - } - stream := resp.Body - if encInfo != nil { - if mimeType == "" { - mimeType = "application/octet-stream" - } - contentDisposition := "attachment" - if slices.Contains(safeMimes, mimeType) { - contentDisposition = "inline" - } - dispositionArgs := map[string]string{} - if filename := r.PathValue("filename"); filename != "" { - dispositionArgs["filename"] = filename - } - w.Header().Set("Content-Type", mimeType) - w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, dispositionArgs)) - // Note: this won't check the Close result like it should, but it's probably not a big deal here - stream = encInfo.DecryptStream(stream) - } else if filename := r.PathValue("filename"); filename != "" { - contentDisposition, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) - if contentDisposition == "" { - contentDisposition = "attachment" - } - w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, map[string]string{ - "filename": filename, - })) - } - w.WriteHeader(http.StatusOK) - _, _ = io.Copy(w, stream) -} - -func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) string { - return br.getPublicMediaAddressWithFileName(contentURI, "") -} - -func (br *Connector) getPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string { - if br.pubMediaSigKey == nil { - return "" - } - parsed, err := contentURI.Parse() - if err != nil || !parsed.IsValid() { - return "" - } - fileName = url.PathEscape(strings.ReplaceAll(fileName, "/", "_")) - if fileName == ".." { - fileName = "" - } - parts := []string{ - br.GetPublicAddress(), - strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), - parsed.Homeserver, - parsed.FileID, - base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)), - fileName, - } - if fileName == "" { - parts = parts[:len(parts)-1] - } - return strings.Join(parts, "/") -} - -func (br *Connector) GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) { - if br.pubMediaSigKey == nil { - return "", bridgev2.ErrPublicMediaDisabled - } - if !br.Config.PublicMedia.UseDatabase { - if evt.File != nil { - return "", fmt.Errorf("can't generate address for encrypted file: %w", bridgev2.ErrPublicMediaDatabaseDisabled) - } - return br.getPublicMediaAddressWithFileName(evt.URL, evt.GetFileName()), nil - } - mxc := evt.URL - var keys *attachment.EncryptedFile - if evt.File != nil { - mxc = evt.File.URL - keys = &evt.File.EncryptedFile - } - parsedMXC, err := mxc.Parse() - if err != nil { - return "", fmt.Errorf("%w: failed to parse MXC: %w", bridgev2.ErrPublicMediaGenerateFailed, err) - } - pm := &database.PublicMedia{ - MXC: parsedMXC, - Keys: keys, - MimeType: evt.GetInfo().MimeType, - } - if br.Config.PublicMedia.Expiry > 0 { - pm.Expiry = time.Now().Add(time.Duration(br.Config.PublicMedia.Expiry) * time.Second) - } - pm.PublicID = base64.RawURLEncoding.EncodeToString(br.hashDBPublicMedia(pm)) - err = br.Bridge.DB.PublicMedia.Put(ctx, pm) - if err != nil { - return "", fmt.Errorf("%w: failed to store public media in database: %w", bridgev2.ErrPublicMediaGenerateFailed, err) - } - fileName := url.PathEscape(strings.ReplaceAll(evt.GetFileName(), "/", "_")) - if fileName == ".." { - fileName = "" - } - parts := []string{ - br.GetPublicAddress(), - strings.Trim(br.Config.PublicMedia.PathPrefix, "/"), - pm.PublicID, - fileName, - } - if fileName == "" { - parts = parts[:len(parts)-1] - } - return strings.Join(parts, "/"), nil -} diff --git a/mautrix-patched/bridgev2/matrix/websocket.go b/mautrix-patched/bridgev2/matrix/websocket.go deleted file mode 100644 index b498cacd..00000000 --- a/mautrix-patched/bridgev2/matrix/websocket.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package matrix - -import ( - "context" - "errors" - "fmt" - "os" - "sync" - "time" - - "maunium.net/go/mautrix/appservice" -) - -const defaultReconnectBackoff = 2 * time.Second -const maxReconnectBackoff = 2 * time.Minute -const reconnectBackoffReset = 5 * time.Minute - -func (br *Connector) startWebsocket(wg *sync.WaitGroup) { - log := br.Log.With().Str("action", "appservice websocket").Logger() - var wgOnce sync.Once - onConnect := func() { - if br.hasSentAnyStates { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - for _, state := range br.Bridge.GetCurrentBridgeStates() { - err := br.SendBridgeStatus(ctx, &state) - if err != nil { - log.Err(err).Msg("Failed to resend latest bridge state after websocket reconnect") - } else { - log.Debug().Any("bridge_state", state).Msg("Resent bridge state after websocket reconnect") - } - } - }() - } - wgOnce.Do(wg.Done) - select { - case br.wsStarted <- struct{}{}: - default: - } - } - reconnectBackoff := defaultReconnectBackoff - lastDisconnect := time.Now().UnixNano() - br.wsStopped = make(chan struct{}) - defer func() { - log.Debug().Msg("Appservice websocket loop finished") - close(br.wsStopped) - }() - addr := br.Config.Homeserver.WSProxy - if addr == "" { - addr = br.Config.Homeserver.Address - } - for { - err := br.AS.StartWebsocket(br.Bridge.BackgroundCtx, addr, onConnect) - if errors.Is(err, appservice.ErrWebsocketManualStop) { - return - } else if closeCommand := (&appservice.CloseCommand{}); errors.As(err, &closeCommand) && closeCommand.Status == appservice.MeowConnectionReplaced { - log.Warn().Msg("Appservice websocket closed by another instance of the bridge, shutting down...") - if br.OnWebsocketReplaced != nil { - br.OnWebsocketReplaced() - } else { - os.Exit(1) - } - return - } else if err != nil { - log.Err(err).Msg("Error in appservice websocket") - } - if br.stopping { - return - } - now := time.Now().UnixNano() - if lastDisconnect+reconnectBackoffReset.Nanoseconds() < now { - reconnectBackoff = defaultReconnectBackoff - } else { - reconnectBackoff *= 2 - if reconnectBackoff > maxReconnectBackoff { - reconnectBackoff = maxReconnectBackoff - } - } - lastDisconnect = now - log.Info(). - Int("backoff_seconds", int(reconnectBackoff.Seconds())). - Msg("Websocket disconnected, reconnecting...") - select { - case <-br.wsShortCircuitReconnectBackoff: - log.Debug().Msg("Reconnect backoff was short-circuited") - case <-time.After(reconnectBackoff): - } - if br.stopping { - return - } - } -} - -type wsPingData struct { - Timestamp int64 `json:"timestamp"` -} - -func (br *Connector) PingServer() (start, serverTs, end time.Time) { - if !br.Websocket { - panic(fmt.Errorf("PingServer called without websocket enabled")) - } - if !br.AS.HasWebsocket() { - br.Log.Debug().Msg("Received server ping request, but no websocket connected. Trying to short-circuit backoff sleep") - select { - case br.wsShortCircuitReconnectBackoff <- struct{}{}: - default: - br.Log.Warn().Msg("Failed to ping websocket: not connected and no backoff?") - return - } - select { - case <-br.wsStarted: - case <-time.After(15 * time.Second): - if !br.AS.HasWebsocket() { - br.Log.Warn().Msg("Failed to ping websocket: didn't connect after 15 seconds of waiting") - return - } - } - } - start = time.Now() - var resp wsPingData - br.Log.Debug().Msg("Pinging appservice websocket") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - err := br.AS.RequestWebsocket(ctx, &appservice.WebsocketRequest{ - Command: "ping", - Data: &wsPingData{Timestamp: start.UnixMilli()}, - }, &resp) - end = time.Now() - if err != nil { - br.Log.Warn().Err(err).Dur("duration", end.Sub(start)).Msg("Websocket ping returned error") - br.AS.StopWebsocket(fmt.Errorf("websocket ping returned error in %s: %w", end.Sub(start), err)) - } else { - serverTs = time.Unix(0, resp.Timestamp*int64(time.Millisecond)) - br.Log.Debug(). - Dur("duration", end.Sub(start)). - Dur("req_duration", serverTs.Sub(start)). - Dur("resp_duration", end.Sub(serverTs)). - Msg("Websocket ping returned success") - } - return -} - -func (br *Connector) websocketServerPinger() { - interval := time.Duration(br.Config.Homeserver.WSPingInterval) * time.Second - clock := time.NewTicker(interval) - defer func() { - br.Log.Info().Msg("Stopping websocket pinger") - clock.Stop() - }() - br.Log.Info().Dur("interval_duration", interval).Msg("Starting websocket pinger") - for { - select { - case <-clock.C: - br.PingServer() - case <-br.wsStopPinger: - return - } - if br.stopping { - return - } - } -} diff --git a/mautrix-patched/bridgev2/matrixinterface.go b/mautrix-patched/bridgev2/matrixinterface.go deleted file mode 100644 index 8d94053f..00000000 --- a/mautrix-patched/bridgev2/matrixinterface.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "time" - - "go.mau.fi/util/exhttp" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type MatrixCapabilities struct { - AutoJoinInvites bool - BatchSending bool - ArbitraryMemberChange bool - ExtraProfileMeta bool - ReplaceEntireProfile bool -} - -type BeeperStreamPublisher interface { - NewDescriptor(ctx context.Context, roomID id.RoomID, streamType string) (*event.BeeperStreamInfo, error) - Register(ctx context.Context, roomID id.RoomID, eventID id.EventID, descriptor *event.BeeperStreamInfo) error - Publish(ctx context.Context, roomID id.RoomID, eventID id.EventID, delta map[string]any) error - Unregister(roomID id.RoomID, eventID id.EventID) -} - -type MatrixConnector interface { - Init(*Bridge) - Start(ctx context.Context) error - PreStop() - Stop() - - GetCapabilities() *MatrixCapabilities - - ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) - GhostIntent(userID networkid.UserID) MatrixAPI - NewUserIntent(ctx context.Context, userID id.UserID, accessToken string) (MatrixAPI, string, error) - BotIntent() MatrixAPI - - SendBridgeStatus(ctx context.Context, state *status.BridgeState) error - SendMessageStatus(ctx context.Context, status *MessageStatus, evt *MessageStatusEventInfo) - - GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) - ParseContentURI(ctx context.Context, contentURI id.ContentURIString) (networkid.MediaID, error) - - GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) - GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) - GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) - - BatchSend(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBeeperBatchSend, extras []*MatrixSendExtra) (*mautrix.RespBeeperBatchSend, error) - GenerateDeterministicRoomID(portalKey networkid.PortalKey) id.RoomID - GenerateDeterministicEventID(roomID id.RoomID, portalKey networkid.PortalKey, messageID networkid.MessageID, partID networkid.PartID) id.EventID - GenerateReactionEventID(roomID id.RoomID, targetMessage *database.Message, sender networkid.UserID, emojiID networkid.EmojiID) id.EventID - - ServerName() string -} - -type MatrixConnectorWithArbitraryRoomState interface { - MatrixConnector - GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) -} - -type MatrixConnectorWithServer interface { - MatrixConnector - GetPublicAddress() string - GetRouter() *http.ServeMux -} - -type IProvisioningAPI interface { - GetRouter() *http.ServeMux - GetUser(r *http.Request) *User -} - -type MatrixConnectorWithProvisioning interface { - MatrixConnector - GetProvisioning() IProvisioningAPI -} - -type MatrixConnectorWithPublicMedia interface { - MatrixConnector - GetPublicMediaAddress(contentURI id.ContentURIString) string - GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) -} - -type MatrixConnectorWithNameDisambiguation interface { - MatrixConnector - IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error) -} - -type MatrixConnectorWithBridgeIdentifier interface { - MatrixConnector - GetUniqueBridgeID() string -} - -type MatrixConnectorWithURLPreviews interface { - MatrixConnector - GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error) -} - -type MatrixConnectorWithPostRoomBridgeHandling interface { - MatrixConnector - HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error -} - -type MatrixConnectorWithAnalytics interface { - MatrixConnector - TrackAnalytics(userID id.UserID, event string, properties map[string]any) -} - -type MatrixConnectorWithBeeperStreams interface { - MatrixConnector - GetBeeperStreamPublisher() BeeperStreamPublisher -} - -type DirectNotificationData struct { - Portal *Portal - Sender *Ghost - MessageID networkid.MessageID - Message string - - FormattedNotification string - FormattedTitle string -} - -type MatrixConnectorWithNotifications interface { - MatrixConnector - DisplayNotification(ctx context.Context, data *DirectNotificationData) -} - -type MatrixConnectorWithHTTPSettings interface { - MatrixConnector - GetHTTPClientSettings() exhttp.ClientSettings -} - -type MatrixSendExtra struct { - Timestamp time.Time - MessageMeta *database.Message - ReactionMeta *database.Reaction - StreamOrder int64 - PartIndex int -} - -// FileStreamResult is the result of a FileStreamCallback. -type FileStreamResult struct { - // ReplacementFile is the path to a new file that replaces the original file provided to the callback. - // Providing a replacement file is only allowed if the requireFile flag was set for the UploadMediaStream call. - ReplacementFile string - // FileName is the name of the file to be specified when uploading to the server. - // This should be the same as the file name that will be included in the Matrix event (body or filename field). - // If the file gets encrypted, this field will be ignored. - FileName string - // MimeType is the type of field to be specified when uploading to the server. - // This should be the same as the mime type that will be included in the Matrix event (info -> mimetype field). - // If the file gets encrypted, this field will be replaced with application/octet-stream. - MimeType string -} - -// FileStreamCallback is a callback function for file uploads that roundtrip via disk. -// -// The parameter is either a file or an in-memory buffer depending on the size of the file and whether the requireFile flag was set. -// -// The return value must be non-nil unless there's an error, and should always include FileName and MimeType. -type FileStreamCallback func(file io.Writer) (*FileStreamResult, error) - -type CallbackError struct { - Type string - Wrapped error -} - -func (ce CallbackError) Error() string { - return fmt.Sprintf("%s callback failed: %s", ce.Type, ce.Wrapped.Error()) -} - -func (ce CallbackError) Unwrap() error { - return ce.Wrapped -} - -type EnsureJoinedParams struct { - Via []string -} - -type MatrixAPI interface { - GetMXID() id.UserID - IsDoublePuppet() bool - - SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *MatrixSendExtra) (*mautrix.RespSendEvent, error) - SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (*mautrix.RespSendEvent, error) - MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, ts time.Time) error - MarkUnread(ctx context.Context, roomID id.RoomID, unread bool) error - MarkTyping(ctx context.Context, roomID id.RoomID, typingType TypingType, timeout time.Duration) error - DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) - DownloadMediaToFile(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo, writable bool, callback func(*os.File) error) error - UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) - UploadMediaStream(ctx context.Context, roomID id.RoomID, size int64, requireFile bool, cb FileStreamCallback) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) - - SetDisplayName(ctx context.Context, name string) error - SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error - SetExtraProfileMeta(ctx context.Context, data any) error - SetProfile(ctx context.Context, data any) error - - CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) - DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error - EnsureJoined(ctx context.Context, roomID id.RoomID, params ...EnsureJoinedParams) error - EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error - - TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error - MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error - - GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) -} - -type StreamOrderReadingMatrixAPI interface { - MatrixAPI - MarkStreamOrderRead(ctx context.Context, roomID id.RoomID, streamOrder int64, ts time.Time) error -} - -type MarkAsDMMatrixAPI interface { - MatrixAPI - MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error -} - -// MatrixAPIWithArbitraryRoomState is an extension of MatrixAPI that allows fetching arbitrary state events from a room. -// This should only be used with double puppets when the bridge wants to ensure that the caller has access to the room. -// For any other use case, use MatrixConnectorWithArbitraryRoomState instead, which uses the bridge bot. -type MatrixAPIWithArbitraryRoomState interface { - MatrixAPI - GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) -} diff --git a/mautrix-patched/bridgev2/matrixinvite.go b/mautrix-patched/bridgev2/matrixinvite.go deleted file mode 100644 index 19fb6950..00000000 --- a/mautrix-patched/bridgev2/matrixinvite.go +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" -) - -func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { - log := zerolog.Ctx(ctx) - // These invites should already be rejected in QueueMatrixEvent - if !sender.Permissions.Commands { - log.Warn().Msg("Received bot invite from user without permission to send commands") - return EventHandlingResultIgnored - } - err := br.Bot.EnsureJoined(ctx, evt.RoomID) - if err != nil { - log.Err(err).Msg("Failed to accept invite to room") - return EventHandlingResultFailed.WithError(err) - } - log.Debug().Msg("Accepted invite to room as bot") - members, err := br.Matrix.GetMembers(ctx, evt.RoomID) - if err != nil { - log.Err(err).Msg("Failed to get members of room after accepting invite") - } - if len(members) == 2 { - var message string - if sender.ManagementRoom == "" { - message = fmt.Sprintf("Hello, I'm a %s bridge bot.\n\nUse `help` for help or `login` to log in.\n\nThis room has been marked as your management room.", br.Network.GetName().DisplayName) - sender.ManagementRoom = evt.RoomID - err = br.DB.User.Update(ctx, sender.User) - if err != nil { - log.Err(err).Msg("Failed to update user's management room in database") - } - } else { - message = fmt.Sprintf("Hello, I'm a %s bridge bot.\n\nUse `%s help` for help.", br.Network.GetName().DisplayName, br.Config.CommandPrefix) - } - _, err = br.Bot.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{ - Parsed: format.RenderMarkdown(message, true, false), - }, nil) - if err != nil { - log.Err(err).Msg("Failed to send welcome message to room") - } - } - return EventHandlingResultSuccess -} - -func sendNotice(ctx context.Context, evt *event.Event, intent MatrixAPI, message string, args ...any) { - if len(args) > 0 { - message = fmt.Sprintf(message, args...) - } - content := format.RenderMarkdown(message, true, false) - content.MsgType = event.MsgNotice - resp, err := intent.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("room_id", evt.RoomID). - Stringer("inviter_id", evt.Sender). - Stringer("invitee_id", intent.GetMXID()). - Str("notice_text", message). - Msg("Failed to send notice") - } else { - zerolog.Ctx(ctx).Debug(). - Stringer("notice_event_id", resp.EventID). - Stringer("room_id", evt.RoomID). - Stringer("inviter_id", evt.Sender). - Stringer("invitee_id", intent.GetMXID()). - Str("notice_text", message). - Msg("Sent notice") - } -} - -func sendErrorAndLeave(ctx context.Context, evt *event.Event, intent MatrixAPI, message string, args ...any) { - sendNotice(ctx, evt, intent, message, args...) - rejectInvite(ctx, evt, intent, "") -} - -func (portal *Portal) CleanupOrphanedDM(ctx context.Context, userMXID id.UserID) { - if portal.MXID == "" { - return - } - log := zerolog.Ctx(ctx) - existingPortalMembers, err := portal.Bridge.Matrix.GetMembers(ctx, portal.MXID) - if err != nil { - log.Err(err). - Stringer("old_portal_mxid", portal.MXID). - Msg("Failed to check existing portal members, deleting room") - } else if targetUserMember, ok := existingPortalMembers[userMXID]; !ok { - log.Debug(). - Stringer("old_portal_mxid", portal.MXID). - Msg("Inviter has no member event in old portal, deleting room") - } else if targetUserMember.Membership.IsInviteOrJoin() { - return - } else { - log.Debug(). - Stringer("old_portal_mxid", portal.MXID). - Str("membership", string(targetUserMember.Membership)). - Msg("Inviter is not in old portal, deleting room") - } - - if err = portal.RemoveMXID(ctx); err != nil { - log.Err(err).Msg("Failed to delete old portal mxid") - } else if err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil { - log.Err(err).Msg("Failed to clean up old portal room") - } -} - -func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult { - ghostID, _ := br.Matrix.ParseGhostMXID(id.UserID(evt.GetStateKey())) - validator, ok := br.Network.(IdentifierValidatingNetwork) - if ghostID == "" || (ok && !validator.ValidateUserID(ghostID)) { - rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "Malformed user ID") - return EventHandlingResultIgnored - } - log := zerolog.Ctx(ctx).With(). - Str("invitee_network_id", string(ghostID)). - Stringer("room_id", evt.RoomID). - Logger() - // TODO sort in preference order - logins := sender.GetUserLogins() - if len(logins) == 0 { - rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "You're not logged in") - return EventHandlingResultIgnored - } - _, ok = logins[0].Client.(IdentifierResolvingNetworkAPI) - if !ok { - rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "This bridge does not support starting chats") - return EventHandlingResultIgnored - } - invitedGhost, err := br.GetGhostByID(ctx, ghostID) - if err != nil { - log.Err(err).Msg("Failed to get invited ghost") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get invited ghost: %w", err)) - } - err = invitedGhost.Intent.EnsureJoined(ctx, evt.RoomID) - if err != nil { - log.Err(err).Msg("Failed to accept invite to room") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to accept invite: %w", err)) - } - var resp *CreateChatResponse - var sourceLogin *UserLogin - // TODO this should somehow lock incoming event processing to avoid race conditions where a new portal room is created - // between ResolveIdentifier returning and the portal MXID being updated. - for _, login := range logins { - api, ok := login.Client.(IdentifierResolvingNetworkAPI) - if !ok { - continue - } - var resolveResp *ResolveIdentifierResponse - ghostAPI, ok := login.Client.(GhostDMCreatingNetworkAPI) - if ok { - resp, err = ghostAPI.CreateChatWithGhost(ctx, invitedGhost) - } else { - resolveResp, err = api.ResolveIdentifier(ctx, string(ghostID), true) - if resolveResp != nil { - resp = resolveResp.Chat - } - } - if errors.Is(err, ErrResolveIdentifierTryNext) { - log.Debug().Err(err).Str("login_id", string(login.ID)).Msg("Failed to resolve identifier, trying next login") - continue - } else if err != nil { - log.Err(err).Msg("Failed to resolve identifier") - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to create chat") - return EventHandlingResultFailed.WithError(err) - } else { - sourceLogin = login - break - } - } - if resp == nil { - log.Warn().Msg("No login could resolve the identifier") - sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create chat via any login") - return EventHandlingResultFailed.WithError(fmt.Errorf("no login could resolve the invited identifier")) - } - portal := resp.Portal - if portal == nil { - portal, err = br.GetPortalByKey(ctx, resp.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to get portal by key") - sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create portal entry") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get portal by key: %w", err)) - } - } - portal.CleanupOrphanedDM(ctx, sender.MXID) - err = invitedGhost.Intent.EnsureInvited(ctx, evt.RoomID, br.Bot.GetMXID()) - if err != nil { - log.Err(err).Msg("Failed to ensure bot is invited to room") - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to invite bridge bot") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to ensure bot is invited: %w", err)) - } - err = br.Bot.EnsureJoined(ctx, evt.RoomID) - if err != nil { - log.Err(err).Msg("Failed to ensure bot is joined to room") - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to join with bridge bot") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to ensure bot is joined: %w", err)) - } - - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - portalMXID := portal.MXID - if portalMXID != "" { - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portalMXID, portalMXID.URI(br.Matrix.ServerName()).MatrixToURL()) - rejectInvite(ctx, evt, br.Bot, "") - return EventHandlingResultSuccess - } - err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) - if err != nil { - log.Err(err).Msg("Failed to give permissions to bridge bot") - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to give permissions to bridge bot") - rejectInvite(ctx, evt, br.Bot, "") - return EventHandlingResultSuccess - } - overrideIntent := invitedGhost.Intent - if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { - log.Debug(). - Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). - Msg("Created DM was redirected to another user ID") - _, err = invitedGhost.Intent.SendState(ctx, evt.RoomID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: "Direct chat redirected to another internal user ID", - }, - }, time.Time{}) - if err != nil { - log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") - } - if resp.DMRedirectedTo == SpecialValueDMRedirectedToBot { - overrideIntent = br.Bot - } else if otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo); err != nil { - log.Err(err).Msg("Failed to get ghost of real portal other user ID") - } else { - invitedGhost = otherUserGhost - overrideIntent = otherUserGhost.Intent - } - } - err = portal.UpdateMatrixRoomID(ctx, evt.RoomID, UpdateMatrixRoomIDParams{ - // We locked it before checking the mxid - RoomCreateAlreadyLocked: true, - - FailIfMXIDSet: true, - ChatInfo: resp.PortalInfo, - ChatInfoSource: sourceLogin, - }) - if err != nil { - log.Err(err).Msg("Failed to update Matrix room ID for new DM portal") - sendNotice(ctx, evt, overrideIntent, "Failed to finish configuring portal. The chat may or may not work") - return EventHandlingResultSuccess - } - message := "Private chat portal created" - mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling) - if ok { - err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID) - if err != nil { - log.Err(err).Msg("Error in connector newly bridged room handler") - message += fmt.Sprintf("\n\nWarning: %s", err.Error()) - } - } - sendNotice(ctx, evt, overrideIntent, message) - return EventHandlingResultSuccess -} - -func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWithPower MatrixAPI) error { - powers, err := br.Matrix.GetPowerLevels(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to get power levels: %w", err) - } - userLevel := powers.GetUserLevel(userWithPower.GetMXID()) - if powers.EnsureUserLevelAs(userWithPower.GetMXID(), br.Bot.GetMXID(), userLevel) { - if userLevel > powers.UsersDefault { - powers.SetUserLevel(userWithPower.GetMXID(), userLevel-1) - } - _, err = userWithPower.SendState(ctx, roomID, event.StatePowerLevels, "", &event.Content{ - Parsed: powers, - }, time.Time{}) - if err != nil { - return fmt.Errorf("failed to give power to bot: %w", err) - } - } - return nil -} diff --git a/mautrix-patched/bridgev2/messagestatus.go b/mautrix-patched/bridgev2/messagestatus.go deleted file mode 100644 index 462b668e..00000000 --- a/mautrix-patched/bridgev2/messagestatus.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "errors" - "fmt" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type MessageStatusEventInfo struct { - RoomID id.RoomID - TransactionID string - SourceEventID id.EventID - NewEventID id.EventID - EventType event.Type - MessageType event.MessageType - Sender id.UserID - ThreadRoot id.EventID - StreamOrder int64 - - IsSourceEventDoublePuppeted bool -} - -func StatusEventInfoFromEvent(evt *event.Event) *MessageStatusEventInfo { - var threadRoot id.EventID - if relatable, ok := evt.Content.Parsed.(event.Relatable); ok { - threadRoot = relatable.OptionalGetRelatesTo().GetThreadParent() - } - - _, isDoublePuppeted := evt.Content.Raw[appservice.DoublePuppetKey] - - return &MessageStatusEventInfo{ - RoomID: evt.RoomID, - TransactionID: evt.Unsigned.TransactionID, - SourceEventID: evt.ID, - EventType: evt.Type, - MessageType: evt.Content.AsMessage().MsgType, - Sender: evt.Sender, - ThreadRoot: threadRoot, - - IsSourceEventDoublePuppeted: isDoublePuppeted, - } -} - -// MessageStatus represents the status of a message. It also implements the error interface to allow network connectors -// to return errors which get translated into user-friendly error messages and/or status events. -type MessageStatus struct { - Step status.MessageCheckpointStep - RetryNum int - - Status event.MessageStatus - ErrorReason event.MessageStatusReason - DeliveredTo []id.UserID - InternalError error // Internal error to be tracked in message checkpoints - Message string // Human-readable message shown to users - - ErrorAsMessage bool - IsCertain bool - SendNotice bool - DisableMSS bool -} - -func WrapErrorInStatus(err error) MessageStatus { - var alreadyWrapped MessageStatus - var ok bool - if alreadyWrapped, ok = err.(MessageStatus); ok { - return alreadyWrapped - } else if errors.As(err, &alreadyWrapped) { - alreadyWrapped.InternalError = err - return alreadyWrapped - } - return MessageStatus{ - Status: event.MessageStatusRetriable, - ErrorReason: event.MessageStatusGenericError, - InternalError: err, - } -} - -func (ms MessageStatus) WithSendNotice(send bool) MessageStatus { - ms.SendNotice = send - return ms -} - -func (ms MessageStatus) WithIsCertain(certain bool) MessageStatus { - ms.IsCertain = certain - return ms -} - -func (ms MessageStatus) WithMessage(msg string) MessageStatus { - ms.Message = msg - ms.ErrorAsMessage = false - return ms -} - -func (ms MessageStatus) WithStep(step status.MessageCheckpointStep) MessageStatus { - ms.Step = step - return ms -} - -func (ms MessageStatus) WithStatus(status event.MessageStatus) MessageStatus { - ms.Status = status - return ms -} - -func (ms MessageStatus) WithErrorReason(reason event.MessageStatusReason) MessageStatus { - ms.ErrorReason = reason - return ms -} - -func (ms MessageStatus) WithErrorAsMessage() MessageStatus { - ms.ErrorAsMessage = true - return ms -} - -func (ms MessageStatus) Error() string { - return ms.InternalError.Error() -} - -func (ms MessageStatus) Unwrap() error { - return ms.InternalError -} - -func (ms MessageStatus) Is(other error) bool { - return errors.Is(other, ms.InternalError) -} - -func (ms *MessageStatus) checkpointStatus() status.MessageCheckpointStatus { - switch ms.Status { - case event.MessageStatusSuccess: - if len(ms.DeliveredTo) > 0 { - return status.MsgStatusDelivered - } - return status.MsgStatusSuccess - case event.MessageStatusPending: - return status.MsgStatusWillRetry - case event.MessageStatusRetriable, event.MessageStatusFail: - switch ms.ErrorReason { - case event.MessageStatusTooOld: - return status.MsgStatusTimeout - case event.MessageStatusUnsupported: - return status.MsgStatusUnsupported - default: - return status.MsgStatusPermFailure - } - default: - return "UNKNOWN" - } -} - -func (ms *MessageStatus) ToCheckpoint(evt *MessageStatusEventInfo) *status.MessageCheckpoint { - step := status.MsgStepRemote - if ms.Step != "" { - step = ms.Step - } - checkpoint := &status.MessageCheckpoint{ - RoomID: evt.RoomID, - EventID: evt.SourceEventID, - Step: step, - Timestamp: jsontime.UnixMilliNow(), - Status: ms.checkpointStatus(), - RetryNum: ms.RetryNum, - ReportedBy: status.MsgReportedByBridge, - EventType: evt.EventType, - MessageType: evt.MessageType, - } - if ms.InternalError != nil { - checkpoint.Info = ms.InternalError.Error() - } else if ms.Message != "" { - checkpoint.Info = ms.Message - } - return checkpoint -} - -func (ms *MessageStatus) ToMSSEvent(evt *MessageStatusEventInfo) *event.BeeperMessageStatusEventContent { - content := &event.BeeperMessageStatusEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelReference, - EventID: evt.SourceEventID, - }, - TargetTxnID: evt.TransactionID, - Status: ms.Status, - Reason: ms.ErrorReason, - Message: ms.Message, - } - if ms.InternalError != nil { - content.InternalError = ms.InternalError.Error() - if ms.ErrorAsMessage { - content.Message = content.InternalError - } - } - if ms.DeliveredTo != nil { - content.DeliveredToUsers = &ms.DeliveredTo - } - return content -} - -func (ms *MessageStatus) ToNoticeEvent(evt *MessageStatusEventInfo) *event.MessageEventContent { - certainty := "may not have been" - if ms.IsCertain { - certainty = "was not" - } - evtType := "message" - switch evt.EventType { - case event.EventReaction: - evtType = "reaction" - case event.EventRedaction: - evtType = "redaction" - } - msg := ms.Message - if ms.ErrorAsMessage || msg == "" { - msg = ms.InternalError.Error() - } - messagePrefix := fmt.Sprintf("Your %s %s bridged", evtType, certainty) - if ms.Step == status.MsgStepCommand { - messagePrefix = "Handling your command panicked" - } - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("\u26a0\ufe0f %s: %s", messagePrefix, msg), - RelatesTo: &event.RelatesTo{}, - Mentions: &event.Mentions{}, - } - if evt.ThreadRoot != "" { - content.RelatesTo.SetThread(evt.ThreadRoot, evt.SourceEventID) - } else { - content.RelatesTo.SetReplyTo(evt.SourceEventID) - } - if evt.Sender != "" { - content.Mentions.UserIDs = []id.UserID{evt.Sender} - } - return content -} diff --git a/mautrix-patched/bridgev2/networkid/bridgeid.go b/mautrix-patched/bridgev2/networkid/bridgeid.go deleted file mode 100644 index e3a6df70..00000000 --- a/mautrix-patched/bridgev2/networkid/bridgeid.go +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -// Package networkid contains string types used to represent different kinds of identifiers on remote networks. -// -// Except for [BridgeID], all types in this package are only generated by network connectors. -// Network connectors may generate and parse these types any way they want, all other components -// will treat them as opaque identifiers and will not parse them nor assume anything about them. -// However, identifiers are stored in the bridge database, so backwards-compatibility must be -// considered when changing the format. -// -// All IDs are scoped to a bridge, i.e. they don't need to be unique across different bridges. -// However, most IDs need to be globally unique within the bridge, i.e. the same ID must refer -// to the same entity even from another user's point of view. If the remote network does not -// directly provide such globally unique identifiers, the network connector should prefix them -// with a user ID or other identifier to make them unique. -package networkid - -import ( - "fmt" - - "github.com/rs/zerolog" -) - -// BridgeID is an opaque identifier for a bridge -type BridgeID string - -// PortalID is the ID of a room on the remote network. A portal ID alone should identify group chats -// uniquely, and also DMs when scoped to a user login ID (see [PortalKey]). -type PortalID string - -// PortalKey is the unique key of a room on the remote network. It combines a portal ID and a receiver ID. -// -// The Receiver field is generally only used for DMs, and should be empty for group chats. -// The purpose is to segregate DMs by receiver, so that the same DM has separate rooms even -// if both sides are logged into the bridge. Also, for networks that use user IDs as DM chat IDs, -// the receiver is necessary to have separate rooms for separate users who have a DM with the same -// remote user. -// -// It is also permitted to use a non-empty receiver for group chats if there is a good reason to -// segregate them. For example, Telegram's non-supergroups have user-scoped message IDs instead -// of chat-scoped IDs, which is easier to manage with segregated rooms. -// -// As a special case, Receiver MUST be set if the Bridge.Config.SplitPortals flag is set to true. -// The flag is intended for puppeting-only bridges which want multiple logins to create separate portals for each user. -type PortalKey struct { - ID PortalID `json:"portal_id"` - Receiver UserLoginID `json:"portal_receiver,omitempty"` -} - -func (pk PortalKey) IsEmpty() bool { - return pk.ID == "" && pk.Receiver == "" -} - -func (pk PortalKey) String() string { - if pk.Receiver == "" { - return string(pk.ID) - } - return fmt.Sprintf("%s/%s", pk.ID, pk.Receiver) -} - -func (pk PortalKey) MarshalZerologObject(evt *zerolog.Event) { - evt.Str("portal_id", string(pk.ID)) - if pk.Receiver != "" { - evt.Str("portal_receiver", string(pk.Receiver)) - } -} - -// UserID is the ID of a user on the remote network. -// -// User IDs must be globally unique within the bridge for identifying a specific remote user. -type UserID string - -// UserLoginID is the ID of the user being controlled on the remote network. -// -// It may be the same shape as [UserID]. However, being the same shape is not required, and the -// central bridge module and Matrix connectors will never assume it is. Instead, the bridge will -// use methods like [maunium.net/go/mautrix/bridgev2.NetworkAPI.IsThisUser] to check if a user ID -// is associated with a given UserLogin. -// The network connector is of course allowed to assume a UserLoginID is equivalent to a UserID, -// because it is the one defining both types. -type UserLoginID string - -// MessageID is the ID of a message on the remote network. -// -// Message IDs must be unique across rooms and consistent across users (i.e. globally unique within the bridge). -type MessageID string - -// TransactionID is a client-generated identifier for a message send operation on the remote network. -// -// Transaction IDs must be unique across users in a room, but don't need to be unique across different rooms. -type TransactionID string - -// RawTransactionID is a client-generated identifier for a message send operation on the remote network. -// -// Unlike TransactionID, RawTransactionID's are only used for sending and don't have any uniqueness requirements. -type RawTransactionID string - -// PartID is the ID of a message part on the remote network (e.g. index of image in album). -// -// Part IDs are only unique within a message, not globally. -// To refer to a specific message part globally, use the MessagePartID tuple struct. -type PartID string - -// MessagePartID refers to a specific part of a message by combining a message ID and a part ID. -type MessagePartID struct { - MessageID MessageID - PartID PartID -} - -// MessageOptionalPartID refers to a specific part of a message by combining a message ID and an optional part ID. -// If the part ID is not set, this should refer to the first part ID sorted alphabetically. -type MessageOptionalPartID struct { - MessageID MessageID - PartID *PartID -} - -// PaginationCursor is a cursor used for paginating message history. -type PaginationCursor string - -// AvatarID is the ID of a user or room avatar on the remote network. -// -// It may be a real URL, an opaque identifier, or anything in between. It should be an identifier that -// can be acquired from the remote network without downloading the entire avatar. -// -// In general, it is preferred to use a stable identifier which only changes when the avatar changes. -// However, the bridge will also hash the avatar data to check for changes before sending an avatar -// update to Matrix, so the avatar ID being slightly unstable won't be the end of the world. -type AvatarID string - -// EmojiID is the ID of a reaction emoji on the remote network. -// -// On networks that only allow one reaction per message, an empty string should be used -// to apply the unique constraints in the database appropriately. -// On networks that allow multiple emojis, this is the unicode emoji or a network-specific shortcode. -type EmojiID string - -// MediaID represents a media identifier that can be downloaded from the remote network at any point in the future. -// -// This is used to implement on-demand media downloads. The network connector can ask the Matrix connector -// to generate a content URI from a media ID. Then, when the Matrix connector wants to download the media, -// it will parse the content URI and ask the network connector for the data using the media ID. -type MediaID []byte diff --git a/mautrix-patched/bridgev2/networkinterface.go b/mautrix-patched/bridgev2/networkinterface.go deleted file mode 100644 index 22559ee3..00000000 --- a/mautrix-patched/bridgev2/networkinterface.go +++ /dev/null @@ -1,1501 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/configupgrade" - "go.mau.fi/util/ptr" - "go.mau.fi/util/random" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/mediaproxy" -) - -type ConvertedMessagePart struct { - ID networkid.PartID - Type event.Type - Content *event.MessageEventContent - Extra map[string]any - DBMetadata any - DontBridge bool -} - -func (cmp *ConvertedMessagePart) ToEditPart(part *database.Message) *ConvertedEditPart { - if cmp == nil { - return nil - } - if cmp.DBMetadata != nil { - merger, ok := part.Metadata.(database.MetaMerger) - if ok { - merger.CopyFrom(cmp.DBMetadata) - } else { - part.Metadata = cmp.DBMetadata - } - } - return &ConvertedEditPart{ - Part: part, - Type: cmp.Type, - Content: cmp.Content, - Extra: cmp.Extra, - DontBridge: cmp.DontBridge, - } -} - -// EventSender represents a specific user in a chat. -type EventSender struct { - // If IsFromMe is true, the UserLogin who the event was received through is used as the sender. - // Double puppeting will be used if available. - IsFromMe bool - // SenderLogin is the ID of the UserLogin who sent the event. This may be different from the - // login the event was received through. It is used to ensure double puppeting can still be - // used even if the event is received through another login. - SenderLogin networkid.UserLoginID - // Sender is the remote user ID of the user who sent the event. - // For new events, this will not be used for double puppeting. - // - // However, in the member list, [ChatMemberList.CheckAllLogins] can be specified to go through every login - // and call [NetworkAPI.IsThisUser] to check if this ID belongs to that login. This method is not recommended, - // it is better to fill the IsFromMe and SenderLogin fields appropriately. - Sender networkid.UserID - - // ForceDMUser can be set if the event should be sent as the DM user even if the Sender is different. - // This only applies in DM rooms where [database.Portal.OtherUserID] is set and is ignored if IsFromMe is true. - // A warning will be logged if the sender is overridden due to this flag. - ForceDMUser bool - - // Force using the original message sender to send the edit - ForceEditOrigSender bool -} - -func (es EventSender) MarshalZerologObject(evt *zerolog.Event) { - evt.Str("user_id", string(es.Sender)) - if string(es.SenderLogin) != string(es.Sender) { - evt.Str("sender_login", string(es.SenderLogin)) - } - if es.IsFromMe { - evt.Bool("is_from_me", true) - } - if es.ForceDMUser { - evt.Bool("force_dm_user", true) - } -} - -type ConvertedMessage struct { - ReplyTo *networkid.MessageOptionalPartID - // Optional additional info about the reply. This is only used when backfilling messages - // on Beeper, where replies may target messages that haven't been bridged yet. - // Standard Matrix servers can't backwards backfill, so these are never used. - ReplyToRoom networkid.PortalKey - ReplyToUser networkid.UserID - ReplyToLogin networkid.UserLoginID - - ThreadRoot *networkid.MessageID - Parts []*ConvertedMessagePart - Disappear database.DisappearingSetting -} - -func MergeCaption(textPart, mediaPart *ConvertedMessagePart) *ConvertedMessagePart { - if textPart == nil { - return mediaPart - } else if mediaPart == nil { - return textPart - } - mediaPart = ptr.Clone(mediaPart) - if mediaPart.Content.MsgType == event.MsgNotice || (mediaPart.Content.Body != "" && mediaPart.Content.FileName != "" && mediaPart.Content.Body != mediaPart.Content.FileName) { - textPart = ptr.Clone(textPart) - textPart.Content.EnsureHasHTML() - mediaPart.Content.EnsureHasHTML() - mediaPart.Content.Body += "\n\n" + textPart.Content.Body - mediaPart.Content.FormattedBody += "

      " + textPart.Content.FormattedBody - mediaPart.Content.Mentions = mediaPart.Content.Mentions.Merge(textPart.Content.Mentions) - mediaPart.Content.BeeperLinkPreviews = append(mediaPart.Content.BeeperLinkPreviews, textPart.Content.BeeperLinkPreviews...) - } else { - mediaPart.Content.FileName = mediaPart.Content.Body - mediaPart.Content.Body = textPart.Content.Body - mediaPart.Content.Format = textPart.Content.Format - mediaPart.Content.FormattedBody = textPart.Content.FormattedBody - mediaPart.Content.Mentions = textPart.Content.Mentions - mediaPart.Content.BeeperLinkPreviews = textPart.Content.BeeperLinkPreviews - } - if metaMerger, ok := mediaPart.DBMetadata.(database.MetaMerger); ok { - metaMerger.CopyFrom(textPart.DBMetadata) - } - mediaPart.ID = textPart.ID - return mediaPart -} - -func (cm *ConvertedMessage) MergeCaption() bool { - if len(cm.Parts) != 2 { - return false - } - textPart, mediaPart := cm.Parts[1], cm.Parts[0] - if textPart.Content.MsgType != event.MsgText { - textPart, mediaPart = mediaPart, textPart - } - if (!mediaPart.Content.MsgType.IsMedia() && mediaPart.Content.MsgType != event.MsgNotice) || textPart.Content.MsgType != event.MsgText { - return false - } - merged := MergeCaption(textPart, mediaPart) - if merged != nil { - cm.Parts = []*ConvertedMessagePart{merged} - return true - } - return false -} - -type ConvertedEditPart struct { - Part *database.Message - - Type event.Type - // The Content and Extra fields will be put inside `m.new_content` automatically. - // SetEdit must NOT be called by the network connector. - Content *event.MessageEventContent - Extra map[string]any - // TopLevelExtra can be used to specify custom fields at the top level of the content rather than inside `m.new_content`. - TopLevelExtra map[string]any - // NewMentions can be used to specify new mentions that should ping the users again. - // Mentions inside the edited content will not ping. - NewMentions *event.Mentions - - DontBridge bool -} - -type ConvertedEdit struct { - ModifiedParts []*ConvertedEditPart - DeletedParts []*database.Message - // Warning: added parts will be sent at the end of the room. - // If other messages have been sent after the message being edited, - // these new parts will not be next to the existing parts. - AddedParts *ConvertedMessage -} - -// BridgeName contains information about the network that a connector bridges to. -type BridgeName struct { - // The displayname of the network, e.g. `Discord` - DisplayName string `json:"displayname"` - // The URL to the website of the network, e.g. `https://discord.com` - NetworkURL string `json:"network_url"` - // The icon of the network as a mxc:// URI - NetworkIcon id.ContentURIString `json:"network_icon"` - // An identifier uniquely identifying the network, e.g. `discord` - NetworkID string `json:"network_id"` - // An identifier uniquely identifying the bridge software. - // The Go import path is a good choice here (e.g. github.com/octocat/discordbridge) - BeeperBridgeType string `json:"beeper_bridge_type"` - // The default appservice port to use in the example config, defaults to 8080 if unset - // Official mautrix bridges will use ports defined in https://mau.fi/ports - DefaultPort uint16 `json:"default_port,omitempty"` - // The default command prefix to use in the example config, defaults to NetworkID if unset. Must include the ! prefix. - DefaultCommandPrefix string `json:"default_command_prefix,omitempty"` -} - -func (bn BridgeName) AsBridgeInfoSection() event.BridgeInfoSection { - return event.BridgeInfoSection{ - ID: bn.BeeperBridgeType, - DisplayName: bn.DisplayName, - AvatarURL: bn.NetworkIcon, - ExternalURL: bn.NetworkURL, - } -} - -// NetworkConnector is the main interface that a network connector must implement. -type NetworkConnector interface { - // Init is called when the bridge is initialized. The connector should store the bridge instance for later use. - // This should not do any network calls or other blocking operations. - Init(*Bridge) - // Start is called when the bridge is starting. - // The connector should do any non-user-specific startup actions necessary. - // User logins will be loaded separately, so the connector should not load them here. - Start(context.Context) error - - // GetName returns the name of the bridge and some additional metadata, - // which is used to fill `m.bridge` events among other things. - // - // The first call happens *before* the config is loaded, because the data here is also used to - // fill parts of the example config (like the default username template and bot localpart). - // The output can still be adjusted based on config variables, but the function must have - // default values when called without a config. - GetName() BridgeName - // GetDBMetaTypes returns struct types that are used to store connector-specific metadata in various tables. - // All fields are optional. If a field isn't provided, then the corresponding table will have no custom metadata. - // This will be called before Init, it should have a hardcoded response. - GetDBMetaTypes() database.MetaTypes - // GetCapabilities returns the general capabilities of the network connector. - // Note that most capabilities are scoped to rooms and are returned by [NetworkAPI.GetCapabilities] instead. - GetCapabilities() *NetworkGeneralCapabilities - // GetConfig returns all the parts of the network connector's config file. Specifically: - // - example: a string containing an example config file - // - data: an interface to unmarshal the actual config into - // - upgrader: a config upgrader to ensure all fields are present and to do any migrations from old configs - GetConfig() (example string, data any, upgrader configupgrade.Upgrader) - - // LoadUserLogin is called when a UserLogin is loaded from the database in order to fill the [UserLogin.Client] field. - // - // This is called within the bridge's global cache lock, so it must not do any slow operations, - // such as connecting to the network. Instead, connecting should happen when [NetworkAPI.Connect] is called later. - LoadUserLogin(ctx context.Context, login *UserLogin) error - - // GetLoginFlows returns a list of login flows that the network supports. - GetLoginFlows() []LoginFlow - // CreateLogin is called when a user wants to log in to the network. - // - // This should generally not do any work, it should just return a LoginProcess that remembers - // the user and will execute the requested flow. The actual work should start when [LoginProcess.Start] is called. - CreateLogin(ctx context.Context, user *User, flowID string) (LoginProcess, error) - - // GetBridgeInfoVersion returns version numbers for bridge info and room capabilities respectively. - // When the versions change, the bridge will automatically resend bridge info to all rooms. - GetBridgeInfoVersion() (info, capabilities int) -} - -type StoppableNetwork interface { - NetworkConnector - // Stop is called when the bridge is stopping, after all network clients have been disconnected. - Stop() -} - -// DirectMediableNetwork is an optional interface that network connectors can implement to support direct media access. -// -// If the Matrix connector has direct media enabled, SetUseDirectMedia will be called -// before the Start method of the network connector. Download will then be called -// whenever someone wants to download a direct media `mxc://` URI which was generated -// by calling GenerateContentURI on the Matrix connector. -type DirectMediableNetwork interface { - NetworkConnector - SetUseDirectMedia() - Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) -} - -// IdentifierValidatingNetwork is an optional interface that network connectors can implement to validate the shape of user IDs. -// -// This should not perform any checks to see if the user ID actually exists on the network, just that the user ID looks valid. -type IdentifierValidatingNetwork interface { - NetworkConnector - ValidateUserID(id networkid.UserID) bool -} - -type TransactionIDGeneratingNetwork interface { - NetworkConnector - GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID -} - -type PortalBridgeInfoFillingNetwork interface { - NetworkConnector - FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent) -} - -// ConfigValidatingNetwork is an optional interface that network connectors can implement to validate config fields -// before the bridge is started. -// -// When the ValidateConfig method is called, the config data will already be unmarshaled into the -// object returned by [NetworkConnector.GetConfig]. -// -// This mechanism is usually used to refuse bridge startup if a mandatory field has an invalid value. -type ConfigValidatingNetwork interface { - NetworkConnector - ValidateConfig() error -} - -// MaxFileSizeingNetwork is an optional interface that network connectors can implement -// to find out the maximum file size that can be uploaded to Matrix. -// -// The SetMaxFileSize will be called asynchronously soon after startup. -// Before the function is called, the connector may assume a default limit of 50 MiB. -type MaxFileSizeingNetwork interface { - NetworkConnector - SetMaxFileSize(maxSize int64) -} - -type NetworkResettingNetwork interface { - NetworkConnector - // ResetHTTPTransport should recreate the HTTP client used by the bridge. - // It should refetch settings from the Matrix connector using GetHTTPClientSettings if applicable. - ResetHTTPTransport() - // ResetNetworkConnections should forcefully disconnect and restart any persistent network connections. - // ResetHTTPTransport will usually be called before this, so resetting the transport is not necessary here. - ResetNetworkConnections() -} - -type RemoteEchoHandler func(RemoteMessage, *database.Message) (bool, error) - -type MatrixMessageResponse struct { - DB *database.Message - StreamOrder int64 - // If Pending is set, the bridge will not save the provided message to the database. - // This should only be used if AddPendingToSave has been called. - Pending bool - // If RemovePending is set, the bridge will remove the provided transaction ID from pending messages - // after saving the provided message to the database. This should be used with AddPendingToIgnore. - RemovePending networkid.TransactionID - // An optional function that is called after the message is saved to the database. - // Will not be called if the message is not saved for some reason. - PostSave func(context.Context, *database.Message) -} - -type OutgoingTimeoutConfig struct { - CheckInterval time.Duration - NoEchoTimeout time.Duration - NoEchoMessage string - NoAckTimeout time.Duration - NoAckMessage string -} - -type NetworkGeneralCapabilities struct { - // Does the network connector support disappearing messages? - // This flag enables the message disappearing loop in the bridge. - DisappearingMessages bool - // Should the bridge re-request user info on incoming messages even if the ghost already has info? - // By default, info is only requested for ghosts with no name, and other updating is left to events. - AggressiveUpdateInfo bool - // Should the bridge call HandleMatrixReadReceipt with fake data when receiving a new message? - // This should be enabled if the network requires each message to be marked as read independently, - // and doesn't automatically do it when sending a message. - ImplicitReadReceipts bool - // If the bridge uses the pending message mechanism ([MatrixMessage.AddPendingToSave]) - // to handle asynchronous message responses, this field can be set to enable - // automatic timeout errors in case the asynchronous response never arrives. - OutgoingMessageTimeouts *OutgoingTimeoutConfig - // Capabilities related to the provisioning API. - Provisioning ProvisioningCapabilities -} - -// NetworkAPI is an interface representing a remote network client for a single user login. -// -// Implementations of this interface are stored in [UserLogin.Client]. -// The [NetworkConnector.LoadUserLogin] method is responsible for filling the Client field with a NetworkAPI. -type NetworkAPI interface { - // Connect is called to actually connect to the remote network. - // If there's no persistent connection, this may just check access token validity, or even do nothing at all. - // This method isn't allowed to return errors, because any connection errors should be sent - // using the bridge state mechanism (UserLogin.BridgeState.Send) - Connect(ctx context.Context) - // Disconnect should disconnect from the remote network. - // A clean disconnection is preferred, but it should not take too long. - Disconnect() - // IsLoggedIn should return whether the access tokens in this NetworkAPI are valid. - // This should not do any IO operations, it should only return cached data which is updated elsewhere. - IsLoggedIn() bool - // LogoutRemote should invalidate the access tokens in this NetworkAPI if possible - // and disconnect from the remote network. - LogoutRemote(ctx context.Context) - - // IsThisUser should return whether the given remote network user ID is the same as this login. - // This is used when the bridge wants to convert a user login ID to a user ID. - IsThisUser(ctx context.Context, userID networkid.UserID) bool - // GetChatInfo returns info for a given chat. Any fields that are nil will be ignored and not processed at all, - // while empty strings will change the relevant value in the room to be an empty string. - // For example, a nil name will mean the room name is not changed, while an empty string name will remove the name. - GetChatInfo(ctx context.Context, portal *Portal) (*ChatInfo, error) - // GetUserInfo returns info for a given user. Like chat info, fields can be nil to skip them. - GetUserInfo(ctx context.Context, ghost *Ghost) (*UserInfo, error) - // GetCapabilities returns the bridging capabilities in a given room. - // This can simply return a static list if the remote network has no per-chat capability differences, - // but all calls will include the portal, because some networks do have per-chat differences. - GetCapabilities(ctx context.Context, portal *Portal) *event.RoomFeatures - - // HandleMatrixMessage is called when a message is sent from Matrix in an existing portal room. - // This function should convert the message as appropriate, send it over to the remote network, - // and return the info so the central bridge can store it in the database. - // - // This is only called for normal non-edit messages. For other types of events, see the optional extra interfaces (`XHandlingNetworkAPI`). - HandleMatrixMessage(ctx context.Context, msg *MatrixMessage) (message *MatrixMessageResponse, err error) -} - -// NetworkAPIWithUserID is an optional interface for fetching the remote user ID of the logged-in user. -// Networks where such mapping is not possible should not implement this interface. -// The GetUserID method may also return an empty string to indicate the user ID is not available. -type NetworkAPIWithUserID interface { - NetworkAPI - // GetUserID returns the user ID of this client on the remote network. - GetUserID() networkid.UserID -} - -type ConnectBackgroundParams struct { - // RawData is the raw data in the push that triggered the background connection. - RawData json.RawMessage - // ExtraData is the data returned by [PushParsingNetwork.ParsePushNotification]. - // It's only present for native pushes. Relayed pushes will only have the raw data. - ExtraData any -} - -// BackgroundSyncingNetworkAPI is an optional interface that network connectors can implement to support background resyncs. -type BackgroundSyncingNetworkAPI interface { - NetworkAPI - // ConnectBackground is called in place of Connect for background resyncs. - // The client should connect to the remote network, handle pending messages, and then disconnect. - // This call should block until the entire sync is complete and the client is disconnected. - ConnectBackground(ctx context.Context, params *ConnectBackgroundParams) error -} - -// CredentialExportingNetworkAPI is an optional interface that networks connectors can implement to support export of -// the credentials associated with that login. Credential type is bridge specific. -type CredentialExportingNetworkAPI interface { - NetworkAPI - ExportCredentials(ctx context.Context) any -} - -// FetchMessagesParams contains the parameters for a message history pagination request. -type FetchMessagesParams struct { - // The portal to fetch messages in. Always present. - Portal *Portal - // When fetching messages inside a thread, the ID of the thread. - ThreadRoot networkid.MessageID - // Whether to fetch new messages instead of old ones. - Forward bool - // The oldest known message in the thread or the portal. If Forward is true, this is the newest known message instead. - // If the portal doesn't have any bridged messages, this will be nil. - AnchorMessage *database.Message - // The cursor returned by the previous call to FetchMessages with the same portal and thread root. - // This will not be present in Forward calls. - Cursor networkid.PaginationCursor - // The preferred number of messages to return. The returned batch can be bigger or smaller - // without any side effects, but the network connector should aim for this number. - Count int - // Whether the network connector should allow slow fetches of messages. - // If false and the network connector hits a case where a slow fetch is necessary, - // the response should set MoreRequiresSlowFetch in the response instead of fetching messages. - AllowSlowFetch bool - - // When a forward backfill is triggered by a [RemoteChatResyncBackfillBundle], this will contain - // the bundled data returned by the event. It can be used as an optimization to avoid fetching - // messages that were already provided by the remote network, while still supporting fetching - // more messages if the limit is higher. - BundledData any - - // When the messages are being fetched for a queued backfill, this is the task object. - Task *database.BackfillTask -} - -// BackfillReaction is an individual reaction to a message in a history pagination request. -// -// The target message is always the BackfillMessage that contains this item. -// Optionally, the reaction can target a specific part by specifying TargetPart. -// If not specified, the first part (sorted lexicographically) is targeted. -type BackfillReaction struct { - // Optional part of the message that the reaction targets. - // If nil, the reaction targets the first part of the message. - TargetPart *networkid.PartID - // Optional timestamp for the reaction. - // If unset, the reaction will have a fake timestamp that is slightly after the message timestamp. - Timestamp time.Time - - Sender EventSender - EmojiID networkid.EmojiID - Emoji string - ExtraContent map[string]any - DBMetadata any -} - -// BackfillMessage is an individual message in a history pagination request. -type BackfillMessage struct { - *ConvertedMessage - Sender EventSender - ID networkid.MessageID - TxnID networkid.TransactionID - Timestamp time.Time - StreamOrder int64 - Reactions []*BackfillReaction - - ShouldBackfillThread bool - LastThreadMessage networkid.MessageID -} - -var ( - _ RemoteMessageWithTransactionID = (*BackfillMessage)(nil) - _ RemoteEventWithTimestamp = (*BackfillMessage)(nil) -) - -func (b *BackfillMessage) GetType() RemoteEventType { - return RemoteEventMessage -} - -func (b *BackfillMessage) GetPortalKey() networkid.PortalKey { - panic("GetPortalKey called for BackfillMessage") -} - -func (b *BackfillMessage) AddLogContext(c zerolog.Context) zerolog.Context { - return c -} - -func (b *BackfillMessage) GetSender() EventSender { - return b.Sender -} - -func (b *BackfillMessage) GetID() networkid.MessageID { - return b.ID -} - -func (b *BackfillMessage) GetTransactionID() networkid.TransactionID { - return b.TxnID -} - -func (b *BackfillMessage) GetTimestamp() time.Time { - return b.Timestamp -} - -func (b *BackfillMessage) ConvertMessage(ctx context.Context, portal *Portal, intent MatrixAPI) (*ConvertedMessage, error) { - return b.ConvertedMessage, nil -} - -// FetchMessagesResponse contains the response for a message history pagination request. -type FetchMessagesResponse struct { - // The messages to backfill. Messages should always be sorted in chronological order (oldest to newest). - Messages []*BackfillMessage - // The next cursor to use for fetching more messages. - Cursor networkid.PaginationCursor - // Whether there are more messages that can be backfilled. - // This field is required. If it is false, FetchMessages will not be called again. - HasMore bool - // Whether the batch contains new messages rather than old ones. - // Cursor, HasMore and the progress fields will be ignored when this is present. - Forward bool - // When sending forward backfill (or the first batch in a room), this field can be set - // to mark the messages as read immediately after backfilling. - MarkRead bool - - // If further fetches require requests that take longer than normal message fetches, this can be set to true. - // The bridge will then skip this portal in the backfill queue and only paginate further if the user requests it. - // This should not be set if AllowSlowFetch is true in the fetch params. - MoreRequiresSlowFetch bool - // If a backfill event was requested in the background and will later be sent using RemoteBackfill, - // this should be set to true. The queue will suspend the task for at least 24 hours until the event. - Pending bool - - // Should the bridge check each message against the database to ensure it's not a duplicate before bridging? - // By default, the bridge will only drop messages that are older than the last bridged message for forward backfills, - // or newer than the first for backward. - AggressiveDeduplication bool - - // When HasMore is true, one of the following fields can be set to report backfill progress: - - // Approximate backfill progress as a number between 0 and 1. - ApproxProgress float64 - // Approximate number of messages remaining that can be backfilled. - ApproxRemainingCount int - // Approximate total number of messages in the chat. - ApproxTotalCount int - - // An optional function that is called after the backfill batch has been sent. - CompleteCallback func() -} - -// BackfillingNetworkAPI is an optional interface that network connectors can implement to support backfilling message history. -type BackfillingNetworkAPI interface { - NetworkAPI - // FetchMessages returns a batch of messages to backfill in a portal room. - // For details on the input and output, see the documentation of [FetchMessagesParams] and [FetchMessagesResponse]. - FetchMessages(ctx context.Context, fetchParams FetchMessagesParams) (*FetchMessagesResponse, error) -} - -// BackfillingNetworkAPIWithLimits is an optional interface that network connectors can implement to customize -// the limit for backwards backfilling tasks. It is recommended to implement this by reading the MaxBatchesOverride -// config field with network-specific keys for different room types. -type BackfillingNetworkAPIWithLimits interface { - BackfillingNetworkAPI - // GetBackfillMaxBatchCount is called before a backfill task is executed to determine the maximum number of batches - // that should be backfilled. Return values less than 0 are treated as unlimited. - GetBackfillMaxBatchCount(ctx context.Context, portal *Portal, task *database.BackfillTask) int -} - -// EditHandlingNetworkAPI is an optional interface that network connectors can implement to handle message edits. -type EditHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixEdit is called when a previously bridged message is edited in a portal room. - // The central bridge module will save the [*database.Message] after this function returns, - // so the network connector is allowed to mutate the provided object. - HandleMatrixEdit(ctx context.Context, msg *MatrixEdit) error -} - -type PollHandlingNetworkAPI interface { - NetworkAPI - HandleMatrixPollStart(ctx context.Context, msg *MatrixPollStart) (*MatrixMessageResponse, error) - HandleMatrixPollVote(ctx context.Context, msg *MatrixPollVote) (*MatrixMessageResponse, error) -} - -// ReactionHandlingNetworkAPI is an optional interface that network connectors can implement to handle message reactions. -type ReactionHandlingNetworkAPI interface { - NetworkAPI - // PreHandleMatrixReaction is called as the first step of handling a reaction. It returns the emoji ID, - // sender user ID and max reaction count to allow the central bridge module to de-duplicate the reaction - // if appropriate. - PreHandleMatrixReaction(ctx context.Context, msg *MatrixReaction) (MatrixReactionPreResponse, error) - // HandleMatrixReaction is called after confirming that the reaction is not a duplicate. - // This is the method that should actually send the reaction to the remote network. - // The returned [database.Reaction] object may be empty: the central bridge module already has - // all the required fields and will fill them automatically if they're empty. However, network - // connectors are allowed to set fields themselves if any extra fields are necessary. - HandleMatrixReaction(ctx context.Context, msg *MatrixReaction) (reaction *database.Reaction, err error) - // HandleMatrixReactionRemove is called when a redaction event is received pointing at a previously - // bridged reaction. The network connector should remove the reaction from the remote network. - HandleMatrixReactionRemove(ctx context.Context, msg *MatrixReactionRemove) error -} - -// RedactionHandlingNetworkAPI is an optional interface that network connectors can implement to handle message deletions. -type RedactionHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixMessageRemove is called when a previously bridged message is deleted in a portal room. - HandleMatrixMessageRemove(ctx context.Context, msg *MatrixMessageRemove) error -} - -// ReadReceiptHandlingNetworkAPI is an optional interface that network connectors can implement to handle read receipts. -type ReadReceiptHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixReadReceipt is called when a read receipt is sent in a portal room. - // This will be called even if the target message is not a bridged message. - // Network connectors must gracefully handle [MatrixReadReceipt.ExactMessage] being nil. - // The exact handling is up to the network connector. - HandleMatrixReadReceipt(ctx context.Context, msg *MatrixReadReceipt) error -} - -// ChatViewingNetworkAPI is an optional interface that network connectors can implement to handle viewing chat status. -type ChatViewingNetworkAPI interface { - NetworkAPI - // HandleMatrixViewingChat is called when the user opens a portal room. - // This will never be called by the standard appservice connector, - // as Matrix doesn't have any standard way of signaling chat open status. - // Clients are expected to call this every 5 seconds. There is no signal for closing a chat. - HandleMatrixViewingChat(ctx context.Context, msg *MatrixViewingChat) error -} - -// TypingHandlingNetworkAPI is an optional interface that network connectors can implement to handle typing events. -type TypingHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixTyping is called when a user starts typing in a portal room. - // In the future, the central bridge module will likely get a loop to automatically repeat - // calls to this function until the user stops typing. - HandleMatrixTyping(ctx context.Context, msg *MatrixTyping) error -} - -type MarkedUnreadHandlingNetworkAPI interface { - NetworkAPI - HandleMarkedUnread(ctx context.Context, msg *MatrixMarkedUnread) error -} - -type MuteHandlingNetworkAPI interface { - NetworkAPI - HandleMute(ctx context.Context, msg *MatrixMute) error -} - -type TagHandlingNetworkAPI interface { - NetworkAPI - HandleRoomTag(ctx context.Context, msg *MatrixRoomTag) error -} - -// RoomNameHandlingNetworkAPI is an optional interface that network connectors can implement to handle room name changes. -type RoomNameHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixRoomName is called when the name of a portal room is changed. - // This method should update the Name and NameSet fields of the Portal with - // the new name and return true if the change was successful. - // If the change is not successful, then the fields should not be updated. - HandleMatrixRoomName(ctx context.Context, msg *MatrixRoomName) (bool, error) -} - -// RoomAvatarHandlingNetworkAPI is an optional interface that network connectors can implement to handle room avatar changes. -type RoomAvatarHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixRoomAvatar is called when the avatar of a portal room is changed. - // This method should update the AvatarID, AvatarHash and AvatarMXC fields - // with the new avatar details and return true if the change was successful. - // If the change is not successful, then the fields should not be updated. - HandleMatrixRoomAvatar(ctx context.Context, msg *MatrixRoomAvatar) (bool, error) -} - -// RoomTopicHandlingNetworkAPI is an optional interface that network connectors can implement to handle room topic changes. -type RoomTopicHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixRoomTopic is called when the topic of a portal room is changed. - // This method should update the Topic and TopicSet fields of the Portal with - // the new topic and return true if the change was successful. - // If the change is not successful, then the fields should not be updated. - HandleMatrixRoomTopic(ctx context.Context, msg *MatrixRoomTopic) (bool, error) -} - -type DisappearTimerChangingNetworkAPI interface { - NetworkAPI - // HandleMatrixDisappearingTimer is called when the disappearing timer of a portal room is changed. - // This method should update the Disappear field of the Portal with the new timer and return true - // if the change was successful. If the change is not successful, then the field should not be updated. - HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error) -} - -// DeleteChatHandlingNetworkAPI is an optional interface that network connectors -// can implement to delete a chat from the remote network. -type DeleteChatHandlingNetworkAPI interface { - NetworkAPI - // HandleMatrixDeleteChat is called when the user explicitly deletes a chat. - HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error -} - -// MessageRequestAcceptingNetworkAPI is an optional interface that network connectors -// can implement to accept message requests from the remote network. -type MessageRequestAcceptingNetworkAPI interface { - NetworkAPI - // HandleMatrixAcceptMessageRequest is called when the user accepts a message request. - HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error -} - -type ResolveIdentifierResponse struct { - // Ghost is the ghost of the user that the identifier resolves to. - // This field should be set whenever possible. However, it is not required, - // and the central bridge module will not try to create a ghost if it is not set. - Ghost *Ghost - - // UserID is the user ID of the user that the identifier resolves to. - UserID networkid.UserID - // UserInfo contains the info of the user that the identifier resolves to. - // If both this and the Ghost field are set, the central bridge module will - // automatically update the ghost's info with the data here. - UserInfo *UserInfo - - // Chat contains info about the direct chat with the resolved user. - // This field is required when createChat is true in the ResolveIdentifier call, - // and optional otherwise. - Chat *CreateChatResponse -} - -var SpecialValueDMRedirectedToBot = networkid.UserID("__fi.mau.bridgev2.dm_redirected_to_bot::" + random.String(10)) - -type CreateChatResponse struct { - PortalKey networkid.PortalKey - // Portal and PortalInfo are not required, the caller will fetch them automatically based on PortalKey if necessary. - Portal *Portal - PortalInfo *ChatInfo - // If a start DM request (CreateChatWithGhost or ResolveIdentifier) returns the DM to a different user, - // this field should have the user ID of said different user. - DMRedirectedTo networkid.UserID - - FailedParticipants map[networkid.UserID]*CreateChatFailedParticipant -} - -type CreateChatFailedParticipant struct { - Reason string `json:"reason"` - InviteEventType string `json:"invite_event_type,omitempty"` - InviteContent *event.Content `json:"invite_content,omitempty"` - - UserMXID id.UserID `json:"user_mxid,omitempty"` - DMRoomMXID id.RoomID `json:"dm_room_mxid,omitempty"` -} - -// IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats. -type IdentifierResolvingNetworkAPI interface { - NetworkAPI - // ResolveIdentifier is called when the user wants to start a new chat. - // This can happen via the `resolve-identifier` or `start-chat` bridge bot commands, - // or the corresponding provisioning API endpoints. - ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*ResolveIdentifierResponse, error) -} - -// GhostDMCreatingNetworkAPI is an optional extension to IdentifierResolvingNetworkAPI for starting chats with pre-validated user IDs. -type GhostDMCreatingNetworkAPI interface { - IdentifierResolvingNetworkAPI - // CreateChatWithGhost may be called instead of [IdentifierResolvingNetworkAPI.ResolveIdentifier] - // when starting a chat with an internal user identifier that has been pre-validated using - // [IdentifierValidatingNetwork.ValidateUserID]. If this is not implemented, ResolveIdentifier - // will be used instead (by stringifying the ghost ID). - CreateChatWithGhost(ctx context.Context, ghost *Ghost) (*CreateChatResponse, error) -} - -// ContactListingNetworkAPI is an optional interface that network connectors can implement to provide the user's contact list. -type ContactListingNetworkAPI interface { - NetworkAPI - GetContactList(ctx context.Context) ([]*ResolveIdentifierResponse, error) -} - -type UserSearchingNetworkAPI interface { - IdentifierResolvingNetworkAPI - SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) -} - -type GroupCreatingNetworkAPI interface { - IdentifierResolvingNetworkAPI - CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error) -} - -type PersonalFilteringCustomizingNetworkAPI interface { - NetworkAPI - CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom) -} - -type ProvisioningCapabilities struct { - ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"` - GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"` - ImagePackImport bool `json:"image_pack_import,omitempty"` -} - -type ResolveIdentifierCapabilities struct { - // Can DMs be created after resolving an identifier? - CreateDM bool `json:"create_dm"` - // Can users be looked up by phone number? - LookupPhone bool `json:"lookup_phone"` - // Can users be looked up by email address? - LookupEmail bool `json:"lookup_email"` - // Can users be looked up by network-specific username? - LookupUsername bool `json:"lookup_username"` - // Can any phone number be contacted without having to validate it via lookup first? - AnyPhone bool `json:"any_phone"` - // Can a contact list be retrieved from the bridge? - ContactList bool `json:"contact_list"` - // Can users be searched by name on the remote network? - Search bool `json:"search"` -} - -type GroupTypeCapabilities struct { - TypeDescription string `json:"type_description"` - - Name GroupFieldCapability `json:"name"` - Username GroupFieldCapability `json:"username"` - Avatar GroupFieldCapability `json:"avatar"` - Topic GroupFieldCapability `json:"topic"` - Disappear GroupFieldCapability `json:"disappear"` - Participants GroupFieldCapability `json:"participants"` - Parent GroupFieldCapability `json:"parent"` -} - -type GroupFieldCapability struct { - // Is setting this field allowed at all in the create request? - // Even if false, the network connector should attempt to set the metadata after group creation, - // as the allowed flag can't be enforced properly when creating a group for an existing Matrix room. - Allowed bool `json:"allowed"` - // Is setting this field mandatory for the creation to succeed? - Required bool `json:"required,omitempty"` - // The minimum/maximum length of the field, if applicable. - // For members, length means the number of members excluding the creator. - MinLength int `json:"min_length,omitempty"` - MaxLength int `json:"max_length,omitempty"` - - // Only for the disappear field: allowed disappearing settings - DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"` - - // This can be used to tell provisionutil not to call ValidateUserID on each participant. - // It only meant to allow hacks where ResolveIdentifier returns a fake ID that isn't actually valid for MXIDs. - SkipIdentifierValidation bool `json:"-"` -} - -type GroupCreateParams struct { - Type string `json:"type,omitempty"` - - Username string `json:"username,omitempty"` - // Clients may also provide MXIDs here, but provisionutil will normalize them, so bridges only need to handle network IDs - Participants []networkid.UserID `json:"participants,omitempty"` - Parent *networkid.PortalKey `json:"parent,omitempty"` - - Name *event.RoomNameEventContent `json:"name,omitempty"` - Avatar *event.RoomAvatarEventContent `json:"avatar,omitempty"` - Topic *event.TopicEventContent `json:"topic,omitempty"` - Disappear *event.BeeperDisappearingTimer `json:"disappear,omitempty"` - - // An existing room ID to bridge to. If unset, a new room will be created. - RoomID id.RoomID `json:"room_id,omitempty"` -} - -type MembershipChangeType struct { - From event.Membership - To event.Membership - IsSelf bool -} - -var ( - AcceptInvite = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipJoin, IsSelf: true} - RevokeInvite = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipLeave} - RejectInvite = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipLeave, IsSelf: true} - BanInvited = MembershipChangeType{From: event.MembershipInvite, To: event.MembershipBan} - ProfileChange = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipJoin, IsSelf: true} - Leave = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipLeave, IsSelf: true} - Kick = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipLeave} - BanJoined = MembershipChangeType{From: event.MembershipJoin, To: event.MembershipBan} - Invite = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipInvite} - Join = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipJoin, IsSelf: true} - BanLeft = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipBan} - Knock = MembershipChangeType{From: event.MembershipLeave, To: event.MembershipKnock, IsSelf: true} - AcceptKnock = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipInvite} - RejectKnock = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipLeave} - RetractKnock = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipLeave, IsSelf: true} - BanKnocked = MembershipChangeType{From: event.MembershipKnock, To: event.MembershipBan} - Unban = MembershipChangeType{From: event.MembershipBan, To: event.MembershipLeave} -) - -type GhostOrUserLogin interface { - isGhostOrUserLogin() -} - -func (*Ghost) isGhostOrUserLogin() {} -func (*UserLogin) isGhostOrUserLogin() {} - -type MatrixMembershipChange struct { - MatrixRoomMeta[*event.MemberEventContent] - Target GhostOrUserLogin - Type MembershipChangeType -} - -type MatrixMembershipResult struct { - RedirectTo networkid.UserID -} - -type MembershipHandlingNetworkAPI interface { - NetworkAPI - HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (*MatrixMembershipResult, error) -} - -type SinglePowerLevelChange struct { - OrigLevel int - NewLevel int - NewIsSet bool -} - -type UserPowerLevelChange struct { - Target GhostOrUserLogin - SinglePowerLevelChange -} - -type MatrixPowerLevelChange struct { - MatrixRoomMeta[*event.PowerLevelsEventContent] - Users map[id.UserID]*UserPowerLevelChange - Events map[string]*SinglePowerLevelChange - UsersDefault *SinglePowerLevelChange - EventsDefault *SinglePowerLevelChange - StateDefault *SinglePowerLevelChange - Invite *SinglePowerLevelChange - Kick *SinglePowerLevelChange - Ban *SinglePowerLevelChange - Redact *SinglePowerLevelChange -} - -type PowerLevelHandlingNetworkAPI interface { - NetworkAPI - HandleMatrixPowerLevels(ctx context.Context, msg *MatrixPowerLevelChange) (bool, error) -} - -type PushType int - -func (pt PushType) String() string { - return pt.GoString() -} - -func PushTypeFromString(str string) PushType { - switch strings.TrimPrefix(strings.ToLower(str), "pushtype") { - case "web": - return PushTypeWeb - case "apns": - return PushTypeAPNs - case "fcm": - return PushTypeFCM - default: - return PushTypeUnknown - } -} - -func (pt PushType) GoString() string { - switch pt { - case PushTypeUnknown: - return "PushTypeUnknown" - case PushTypeWeb: - return "PushTypeWeb" - case PushTypeAPNs: - return "PushTypeAPNs" - case PushTypeFCM: - return "PushTypeFCM" - default: - return fmt.Sprintf("PushType(%d)", int(pt)) - } -} - -const ( - PushTypeUnknown PushType = iota - PushTypeWeb - PushTypeAPNs - PushTypeFCM -) - -type WebPushConfig struct { - VapidKey string `json:"vapid_key"` -} - -type FCMPushConfig struct { - SenderID string `json:"sender_id"` -} - -type APNsPushConfig struct { - BundleID string `json:"bundle_id"` -} - -type PushConfig struct { - Web *WebPushConfig `json:"web,omitempty"` - FCM *FCMPushConfig `json:"fcm,omitempty"` - APNs *APNsPushConfig `json:"apns,omitempty"` - // If Native is true, it means the network supports registering for pushes - // that are delivered directly to the app without the use of a push relay. - Native bool `json:"native,omitempty"` -} - -// PushableNetworkAPI is an optional interface that network connectors can implement -// to support waking up the wrapper app using push notifications. -type PushableNetworkAPI interface { - NetworkAPI - - // RegisterPushNotifications is called when the wrapper app wants to register a push token with the remote network. - RegisterPushNotifications(ctx context.Context, pushType PushType, token string) error - // GetPushConfigs is used to find which types of push notifications the remote network can provide. - GetPushConfigs() *PushConfig -} - -// PushParsingNetwork is an optional interface that network connectors can implement -// to support parsing native push notifications from networks. -type PushParsingNetwork interface { - NetworkConnector - - // ParsePushNotification is called when a native push is received. - // It must return the corresponding user login ID to wake up, plus optionally data to pass to the wakeup call. - ParsePushNotification(ctx context.Context, data json.RawMessage) (networkid.UserLoginID, any, error) -} - -type ImportedImagePack struct { - // The main content of the converted image pack - Content *event.ImagePackEventContent - // Extra top-level content - Extra map[string]any - // A unique identifier (within the network) for the image pack, used as the state key. - // If unset, falls back to Content.Metadata.BridgedPack.URL - Shortcode string -} - -type StickerImportingNetworkAPI interface { - NetworkAPI - - DownloadImagePack(ctx context.Context, url string) (*ImportedImagePack, error) - ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) -} - -type RemoteEventType int - -func (ret RemoteEventType) String() string { - switch ret { - case RemoteEventUnknown: - return "RemoteEventUnknown" - case RemoteEventMessage: - return "RemoteEventMessage" - case RemoteEventMessageUpsert: - return "RemoteEventMessageUpsert" - case RemoteEventEdit: - return "RemoteEventEdit" - case RemoteEventReaction: - return "RemoteEventReaction" - case RemoteEventReactionRemove: - return "RemoteEventReactionRemove" - case RemoteEventReactionSync: - return "RemoteEventReactionSync" - case RemoteEventMessageRemove: - return "RemoteEventMessageRemove" - case RemoteEventReadReceipt: - return "RemoteEventReadReceipt" - case RemoteEventDeliveryReceipt: - return "RemoteEventDeliveryReceipt" - case RemoteEventMarkUnread: - return "RemoteEventMarkUnread" - case RemoteEventTyping: - return "RemoteEventTyping" - case RemoteEventChatInfoChange: - return "RemoteEventChatInfoChange" - case RemoteEventChatResync: - return "RemoteEventChatResync" - case RemoteEventChatDelete: - return "RemoteEventChatDelete" - case RemoteEventBackfill: - return "RemoteEventBackfill" - default: - return fmt.Sprintf("RemoteEventType(%d)", int(ret)) - } -} - -const ( - RemoteEventUnknown RemoteEventType = iota - RemoteEventMessage - RemoteEventMessageUpsert - RemoteEventEdit - RemoteEventReaction - RemoteEventReactionRemove - RemoteEventReactionSync - RemoteEventMessageRemove - RemoteEventReadReceipt - RemoteEventDeliveryReceipt - RemoteEventMarkUnread - RemoteEventTyping - RemoteEventChatInfoChange - RemoteEventChatResync - RemoteEventChatDelete - RemoteEventBackfill -) - -// RemoteEvent represents a single event from the remote network, such as a message or a reaction. -// -// When a [NetworkAPI] receives an event from the remote network, it should convert it into a [RemoteEvent] -// and pass it to the bridge for processing using [Bridge.QueueRemoteEvent]. -type RemoteEvent interface { - GetType() RemoteEventType - GetPortalKey() networkid.PortalKey - AddLogContext(c zerolog.Context) zerolog.Context - GetSender() EventSender -} - -type RemoteEventWithContextMutation interface { - RemoteEvent - MutateContext(ctx context.Context) context.Context -} - -type RemoteEventWithUncertainPortalReceiver interface { - RemoteEvent - PortalReceiverIsUncertain() bool -} - -type RemoteEventWithUncertainPortalReceiverFetcher interface { - RemoteEventWithUncertainPortalReceiver - FetchCertainPortalKey(context.Context) networkid.PortalKey -} - -type RemotePreHandler interface { - RemoteEvent - PreHandle(ctx context.Context, portal *Portal) -} - -type RemotePostHandler interface { - RemoteEvent - PostHandle(ctx context.Context, portal *Portal) -} - -type RemoteChatInfoChange interface { - RemoteEvent - GetChatInfoChange(ctx context.Context) (*ChatInfoChange, error) -} - -type RemoteChatResync interface { - RemoteEvent -} - -type RemoteChatResyncWithInfo interface { - RemoteChatResync - GetChatInfo(ctx context.Context, portal *Portal) (*ChatInfo, error) -} - -type RemoteChatResyncBackfill interface { - RemoteChatResync - CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) -} - -type RemoteChatResyncBackfillBundle interface { - RemoteChatResyncBackfill - GetBundledBackfillData() any -} - -type RemoteBackfill interface { - RemoteEvent - GetBackfillData(ctx context.Context, portal *Portal) (*FetchMessagesResponse, error) -} - -type RemoteDeleteOnlyForMe interface { - RemoteEvent - DeleteOnlyForMe() bool -} - -type RemoteChatDelete interface { - RemoteDeleteOnlyForMe -} - -type RemoteChatDeleteWithChildren interface { - RemoteChatDelete - DeleteChildren() bool -} - -type RemoteEventThatMayCreatePortal interface { - RemoteEvent - ShouldCreatePortal() bool -} - -type RemoteEventWithTargetMessage interface { - RemoteEvent - GetTargetMessage() networkid.MessageID -} - -type RemoteEventWithBundledParts interface { - RemoteEventWithTargetMessage - GetTargetDBMessage() []*database.Message -} - -type RemoteEventWithTargetPart interface { - RemoteEventWithTargetMessage - GetTargetMessagePart() networkid.PartID -} - -type RemoteEventWithTimestamp interface { - RemoteEvent - GetTimestamp() time.Time -} - -type RemoteEventWithStreamOrder interface { - RemoteEvent - GetStreamOrder() int64 -} - -type RemoteMessage interface { - RemoteEvent - GetID() networkid.MessageID - ConvertMessage(ctx context.Context, portal *Portal, intent MatrixAPI) (*ConvertedMessage, error) -} - -type UpsertResult struct { - SubEvents []RemoteEvent - SaveParts bool - ContinueMessageHandling bool -} - -type RemoteMessageUpsert interface { - RemoteMessage - HandleExisting(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message) (UpsertResult, error) -} - -type RemoteMessageWithTransactionID interface { - RemoteMessage - GetTransactionID() networkid.TransactionID -} - -type RemoteEdit interface { - RemoteEventWithTargetMessage - ConvertEdit(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message) (*ConvertedEdit, error) -} - -type RemoteReaction interface { - RemoteEventWithTargetMessage - GetReactionEmoji() (string, networkid.EmojiID) -} - -type ReactionSyncUser struct { - Reactions []*BackfillReaction - // Whether the list contains all reactions the user has sent - HasAllReactions bool - // If the list doesn't contain all reactions from the user, - // then this field can be set to remove old reactions if there are more than a certain number. - MaxCount int -} - -type ReactionSyncData struct { - Users map[networkid.UserID]*ReactionSyncUser - // Whether the map contains all users who have reacted to the message - HasAllUsers bool -} - -func (rsd *ReactionSyncData) ToBackfill() []*BackfillReaction { - var reactions []*BackfillReaction - for _, user := range rsd.Users { - reactions = append(reactions, user.Reactions...) - } - return reactions -} - -type RemoteReactionSync interface { - RemoteEventWithTargetMessage - GetReactions() *ReactionSyncData -} - -type RemoteReactionWithExtraContent interface { - RemoteReaction - GetReactionExtraContent() map[string]any -} - -type RemoteReactionWithMeta interface { - RemoteReaction - GetReactionDBMetadata() any -} - -type RemoteReactionRemove interface { - RemoteEventWithTargetMessage - GetRemovedEmojiID() networkid.EmojiID -} - -type RemoteMessageRemove interface { - RemoteEventWithTargetMessage -} - -type RemoteMessageRemoveWithoutPlaceholder interface { - RemoteMessageRemove - DontRenderPlaceholder() bool -} - -// Deprecated: Renamed to RemoteReadReceipt. -type RemoteReceipt = RemoteReadReceipt - -type RemoteReadReceipt interface { - RemoteEvent - GetLastReceiptTarget() networkid.MessageID - GetReceiptTargets() []networkid.MessageID - GetReadUpTo() time.Time -} - -type RemoteReadReceiptWithStreamOrder interface { - RemoteReadReceipt - GetReadUpToStreamOrder() int64 -} - -type RemoteDeliveryReceipt interface { - RemoteEvent - GetReceiptTargets() []networkid.MessageID -} - -type RemoteMarkUnread interface { - RemoteEvent - GetUnread() bool -} - -type RemoteTyping interface { - RemoteEvent - GetTimeout() time.Duration -} - -type TypingType int - -const ( - TypingTypeText TypingType = iota - TypingTypeUploadingMedia - TypingTypeRecordingMedia -) - -type RemoteTypingWithType interface { - RemoteTyping - GetTypingType() TypingType -} - -type OrigSender struct { - User *User - UserID id.UserID - - RequiresDisambiguation bool - DisambiguatedName string - FormattedName string - PerMessageProfile event.BeeperPerMessageProfile - - event.MemberEventContent -} - -type MatrixEventBase[ContentType any] struct { - // The raw event being bridged. - Event *event.Event - // The parsed content struct of the event. Custom fields can be found in Event.Content.Raw. - Content ContentType - // The room where the event happened. - Portal *Portal - - // The original sender user ID. Only present in case the event is being relayed (and Sender is not the same user). - OrigSender *OrigSender - - InputTransactionID networkid.RawTransactionID -} - -type MatrixMessage struct { - MatrixEventBase[*event.MessageEventContent] - ThreadRoot *database.Message - ReplyTo *database.Message - - pendingSaves []*outgoingMessage -} - -type MatrixEdit struct { - MatrixEventBase[*event.MessageEventContent] - EditTarget *database.Message -} - -type MatrixPollStart struct { - MatrixMessage - Content *event.PollStartEventContent -} - -type MatrixPollVote struct { - MatrixMessage - VoteTo *database.Message - Content *event.PollResponseEventContent -} - -type MatrixReaction struct { - MatrixEventBase[*event.ReactionEventContent] - TargetMessage *database.Message - PreHandleResp *MatrixReactionPreResponse - - // When EmojiID is blank and there's already an existing reaction, this is the old reaction that is being overridden. - ReactionToOverride *database.Reaction - // When MaxReactions is >0 in the pre-response, this is the list of previous reactions that should be preserved. - ExistingReactionsToKeep []*database.Reaction -} - -type MatrixReactionPreResponse struct { - SenderID networkid.UserID - EmojiID networkid.EmojiID - Emoji string - MaxReactions int -} - -type MatrixReactionRemove struct { - MatrixEventBase[*event.RedactionEventContent] - TargetReaction *database.Reaction -} - -type MatrixMessageRemove struct { - MatrixEventBase[*event.RedactionEventContent] - TargetMessage *database.Message -} - -type MatrixRoomMeta[ContentType any] struct { - MatrixEventBase[ContentType] - PrevContent ContentType - IsStateRequest bool -} - -type MatrixRoomName = MatrixRoomMeta[*event.RoomNameEventContent] -type MatrixRoomAvatar = MatrixRoomMeta[*event.RoomAvatarEventContent] -type MatrixRoomTopic = MatrixRoomMeta[*event.TopicEventContent] -type MatrixDisappearingTimer = MatrixRoomMeta[*event.BeeperDisappearingTimer] - -type MatrixReadReceipt struct { - Portal *Portal - // The event ID that the receipt is targeting - EventID id.EventID - // The exact message that was read. This may be nil if the event ID isn't a message. - ExactMessage *database.Message - // The timestamp that the user has read up to. This is either the timestamp of the message - // (if one is present) or the timestamp of the receipt. - ReadUpTo time.Time - // The ReadUpTo timestamp of the previous message - LastRead time.Time - // The receipt metadata. - Receipt event.ReadReceipt - // Whether the receipt is implicit, i.e. triggered by an incoming timeline event rather than an explicit receipt. - Implicit bool -} - -type MatrixTyping struct { - Portal *Portal - IsTyping bool - Type TypingType -} - -type MatrixViewingChat struct { - // The portal that the user is viewing. This will be nil when the user switches to a chat from a different bridge. - Portal *Portal -} - -type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent] -type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent] -type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] -type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] -type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] diff --git a/mautrix-patched/bridgev2/portal.go b/mautrix-patched/bridgev2/portal.go deleted file mode 100644 index 50300556..00000000 --- a/mautrix-patched/bridgev2/portal.go +++ /dev/null @@ -1,5557 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "cmp" - "context" - "errors" - "fmt" - "runtime/debug" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - "unsafe" - - "github.com/rs/zerolog" - "go.mau.fi/util/exfmt" - "go.mau.fi/util/exmaps" - "go.mau.fi/util/exslices" - "go.mau.fi/util/exsync" - "go.mau.fi/util/ptr" - "go.mau.fi/util/variationselector" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type portalMatrixEvent struct { - evt *event.Event - sender *User -} - -type portalRemoteEvent struct { - evt RemoteEvent - source *UserLogin - evtType RemoteEventType -} - -type portalCreateEvent struct { - ctx context.Context - source *UserLogin - info *ChatInfo - cb func(error) -} - -func (pme *portalMatrixEvent) isPortalEvent() {} -func (pre *portalRemoteEvent) isPortalEvent() {} -func (pre *portalCreateEvent) isPortalEvent() {} - -type portalEvent interface { - isPortalEvent() -} - -type outgoingMessage struct { - db *database.Message - evt *event.Event - ignore bool - handle func(RemoteMessage, *database.Message) (bool, error) - ackedAt time.Time - timeouted bool -} - -type Portal struct { - *database.Portal - Bridge *Bridge - Log zerolog.Logger - Parent *Portal - Relay *UserLogin - - currentlyTyping []id.UserID - currentlyTypingLogins map[id.UserID]*UserLogin - currentlyTypingLock sync.Mutex - currentlyTypingGhosts *exsync.Set[id.UserID] - - outgoingMessages map[networkid.TransactionID]*outgoingMessage - outgoingMessagesLock sync.Mutex - - lastCapUpdate time.Time - - roomCreateLock sync.Mutex - cancelRoomCreate atomic.Pointer[context.CancelFunc] - backgroundCtx context.Context - cancelBackground context.CancelFunc - deleted <-chan struct{} - RoomCreated *exsync.Event - - functionalMembersLock sync.Mutex - functionalMembersCache *event.ElementFunctionalMembersContent - - events chan portalEvent - - eventsLock sync.Mutex - eventIdx int - - backfillLock sync.Mutex - forwardBackfillLock sync.Mutex - nextBackfillDoneCallback func(error) -} - -var PortalEventBuffer = 64 -var PanicOnStuckEvent = false -var EventHandlingTimeoutTicks = 10 - -func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, queryErr error, key *networkid.PortalKey) (*Portal, error) { - if queryErr != nil { - return nil, fmt.Errorf("failed to query db: %w", queryErr) - } - if dbPortal == nil { - if key == nil { - return nil, nil - } - dbPortal = &database.Portal{ - BridgeID: br.ID, - PortalKey: *key, - } - err := br.DB.Portal.Insert(ctx, dbPortal) - if err != nil { - return nil, fmt.Errorf("failed to insert new portal: %w", err) - } - } - portal := &Portal{ - Portal: dbPortal, - Bridge: br, - - currentlyTypingLogins: make(map[id.UserID]*UserLogin), - currentlyTypingGhosts: exsync.NewSet[id.UserID](), - outgoingMessages: make(map[networkid.TransactionID]*outgoingMessage), - - RoomCreated: exsync.NewEvent(), - } - portal.backgroundCtx, portal.cancelBackground = context.WithCancel(br.BackgroundCtx) - portal.deleted = portal.backgroundCtx.Done() - if portal.MXID != "" { - portal.RoomCreated.Set() - } - // Putting the portal in the cache before it's fully initialized is mildly dangerous, - // but loading the relay user login may depend on it. - br.portalsByKey[portal.PortalKey] = portal - if portal.MXID != "" { - br.portalsByMXID[portal.MXID] = portal - } - var err error - if portal.ParentKey.ID != "" { - portal.Parent, err = br.UnlockedGetPortalByKey(ctx, portal.ParentKey, false) - if err != nil { - delete(br.portalsByKey, portal.PortalKey) - if portal.MXID != "" { - delete(br.portalsByMXID, portal.MXID) - } - return nil, fmt.Errorf("failed to load parent portal (%s): %w", portal.ParentKey, err) - } - } - if portal.RelayLoginID != "" { - portal.Relay, err = br.unlockedGetExistingUserLoginByID(ctx, portal.RelayLoginID) - if err != nil { - delete(br.portalsByKey, portal.PortalKey) - if portal.MXID != "" { - delete(br.portalsByMXID, portal.MXID) - } - return nil, fmt.Errorf("failed to load relay login (%s): %w", portal.RelayLoginID, err) - } - } - portal.updateLogger() - if PortalEventBuffer != 0 { - portal.events = make(chan portalEvent, PortalEventBuffer) - go portal.eventLoop() - } - return portal, nil -} - -func (portal *Portal) updateLogger() { - logWith := portal.Bridge.Log.With(). - Str("portal_id", string(portal.ID)). - Str("portal_receiver", string(portal.Receiver)). - Str("portal_pointer", strconv.FormatUint(uint64(uintptr(unsafe.Pointer(portal))), 16)) - if portal.MXID != "" { - logWith = logWith.Stringer("portal_mxid", portal.MXID) - } - portal.Log = logWith.Logger() -} - -func (br *Bridge) loadManyPortals(ctx context.Context, portals []*database.Portal) ([]*Portal, error) { - output := make([]*Portal, 0, len(portals)) - for _, dbPortal := range portals { - if cached, ok := br.portalsByKey[dbPortal.PortalKey]; ok { - output = append(output, cached) - } else { - loaded, err := br.loadPortal(ctx, dbPortal, nil, nil) - if err != nil { - return nil, err - } else if loaded != nil { - output = append(output, loaded) - } - } - } - return output, nil -} - -func (br *Bridge) loadPortalWithCacheCheck(ctx context.Context, dbPortal *database.Portal) (*Portal, error) { - if dbPortal == nil { - return nil, nil - } else if cached, ok := br.portalsByKey[dbPortal.PortalKey]; ok { - return cached, nil - } else { - return br.loadPortal(ctx, dbPortal, nil, nil) - } -} - -func (br *Bridge) UnlockedGetPortalByKey(ctx context.Context, key networkid.PortalKey, onlyIfExists bool) (*Portal, error) { - if br.Config.SplitPortals && key.Receiver == "" { - return nil, fmt.Errorf("receiver must always be set when split portals is enabled") - } - cached, ok := br.portalsByKey[key] - if ok { - return cached, nil - } - keyPtr := &key - if onlyIfExists { - keyPtr = nil - } - db, err := br.DB.Portal.GetByKey(ctx, key) - return br.loadPortal(ctx, db, err, keyPtr) -} - -func (br *Bridge) FindPortalReceiver(ctx context.Context, id networkid.PortalID, maybeReceiver networkid.UserLoginID) (networkid.PortalKey, error) { - key := br.FindCachedPortalReceiver(id, maybeReceiver) - if !key.IsEmpty() { - return key, nil - } - key, err := br.DB.Portal.FindReceiver(ctx, id, maybeReceiver) - if err != nil { - return networkid.PortalKey{}, err - } - return key, nil -} - -func (br *Bridge) FindCachedPortalReceiver(id networkid.PortalID, maybeReceiver networkid.UserLoginID) networkid.PortalKey { - if br.Config.SplitPortals { - return networkid.PortalKey{ID: id, Receiver: maybeReceiver} - } - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - portal, ok := br.portalsByKey[networkid.PortalKey{ - ID: id, - Receiver: maybeReceiver, - }] - if ok { - return portal.PortalKey - } - portal, ok = br.portalsByKey[networkid.PortalKey{ID: id}] - if ok { - return portal.PortalKey - } - return networkid.PortalKey{} -} - -func (br *Bridge) GetPortalByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - cached, ok := br.portalsByMXID[mxid] - if ok { - return cached, nil - } - db, err := br.DB.Portal.GetByMXID(ctx, mxid) - return br.loadPortal(ctx, db, err, nil) -} - -func (br *Bridge) GetAllPortalsWithMXID(ctx context.Context) ([]*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - rows, err := br.DB.Portal.GetAllWithMXID(ctx) - if err != nil { - return nil, err - } - return br.loadManyPortals(ctx, rows) -} - -func (br *Bridge) GetAllPortals(ctx context.Context) ([]*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - rows, err := br.DB.Portal.GetAll(ctx) - if err != nil { - return nil, err - } - return br.loadManyPortals(ctx, rows) -} - -func (br *Bridge) GetDMPortalsWith(ctx context.Context, otherUserID networkid.UserID) ([]*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - rows, err := br.DB.Portal.GetAllDMsWith(ctx, otherUserID) - if err != nil { - return nil, err - } - return br.loadManyPortals(ctx, rows) -} - -func (br *Bridge) GetChildPortals(ctx context.Context, parent networkid.PortalKey) ([]*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - rows, err := br.DB.Portal.GetChildren(ctx, parent) - if err != nil { - return nil, err - } - return br.loadManyPortals(ctx, rows) -} - -func (br *Bridge) GetDMPortal(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - dbPortal, err := br.DB.Portal.GetDM(ctx, receiver, otherUserID) - if err != nil { - return nil, err - } - return br.loadPortalWithCacheCheck(ctx, dbPortal) -} - -func (br *Bridge) GetPortalByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.UnlockedGetPortalByKey(ctx, key, false) -} - -func (br *Bridge) GetExistingPortalByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - if key.Receiver == "" || br.Config.SplitPortals { - return br.UnlockedGetPortalByKey(ctx, key, true) - } - cached, ok := br.portalsByKey[key] - if ok { - return cached, nil - } - cached, ok = br.portalsByKey[networkid.PortalKey{ID: key.ID}] - if ok { - return cached, nil - } - db, err := br.DB.Portal.GetByIDWithUncertainReceiver(ctx, key) - return br.loadPortal(ctx, db, err, nil) -} - -func (portal *Portal) queueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { - if portal.backgroundCtx.Err() != nil { - if portal.Bridge.BackgroundCtx.Err() != nil { - return EventHandlingResultFailed.WithError(fmt.Errorf("bridge shutting down: %w", portal.Bridge.BackgroundCtx.Err())) - } - return EventHandlingResultIgnored - } - if PortalEventBuffer == 0 { - portal.eventsLock.Lock() - defer portal.eventsLock.Unlock() - portal.eventIdx++ - return portal.handleSingleEventWithDelayLogging(portal.eventIdx, evt) - } else { - if portal.events == nil { - panic(fmt.Errorf("queueEvent into uninitialized portal %s", portal.PortalKey)) - } - select { - case portal.events <- evt: - return EventHandlingResultQueued - case <-portal.deleted: - return EventHandlingResultIgnored - default: - zerolog.Ctx(ctx).Error(). - Str("portal_id", string(portal.ID)). - Msg("Portal event channel is full, queue will block") - for { - select { - case portal.events <- evt: - return EventHandlingResultQueued - case <-portal.deleted: - return EventHandlingResultIgnored - case <-time.After(5 * time.Second): - zerolog.Ctx(ctx).Error(). - Str("portal_id", string(portal.ID)). - Msg("Portal event channel is still full") - } - } - } - } -} - -func (portal *Portal) eventLoop() { - if cfg := portal.Bridge.Network.GetCapabilities().OutgoingMessageTimeouts; cfg != nil { - ctx, cancel := context.WithCancel(portal.Log.WithContext(portal.backgroundCtx)) - go portal.pendingMessageTimeoutLoop(ctx, cfg) - defer cancel() - } - for i := 0; ; i++ { - select { - case rawEvt := <-portal.events: - if rawEvt == nil { - return - } - if portal.Bridge.Config.AsyncEvents { - go portal.handleSingleEventWithDelayLogging(i, rawEvt) - } else { - portal.handleSingleEventWithDelayLogging(i, rawEvt) - } - case <-portal.deleted: - return - } - } -} - -func (portal *Portal) handleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) { - ctx := portal.getEventCtxWithLog(rawEvt, idx) - log := zerolog.Ctx(ctx) - doneCh := make(chan struct{}) - var backgrounded atomic.Bool - start := time.Now() - var handleDuration time.Duration - // Note: this will assume success if the handler times out - outerRes = EventHandlingResult{Queued: true, Success: true, Error: ErrHandlerBackgrounded} - go portal.handleSingleEvent(ctx, rawEvt, func(res EventHandlingResult) { - // If the portal was deleted, ignore errors. - // The bridge background ctx check distinguishes bridge stops from portal deletions. - if res.Error != nil && portal.backgroundCtx.Err() != nil && portal.Bridge.BackgroundCtx.Err() == nil { - res = EventHandlingResultIgnored - } - outerRes = res - handleDuration = time.Since(start) - close(doneCh) - if backgrounded.Load() { - log.Debug(). - Time("started_at", start). - Stringer("duration", handleDuration). - Msg("Event that took too long finally finished handling") - } - }) - tick := time.NewTicker(30 * time.Second) - _, isCreate := rawEvt.(*portalCreateEvent) - defer tick.Stop() - for i := 0; i < EventHandlingTimeoutTicks; i++ { - select { - case <-doneCh: - if i > 0 { - log.Debug(). - Time("started_at", start). - Stringer("duration", handleDuration). - Msg("Event that took long finished handling") - } - return - case <-tick.C: - log.Warn(). - Time("started_at", start). - Msg("Event handling is taking long") - if isCreate { - // Never background portal creation events - i = 1 - } - } - } - backgrounded.Store(true) - if PanicOnStuckEvent { - panic(fmt.Errorf("event handling started at %v is still not finished", start)) - } - log.Warn(). - Time("started_at", start). - Msg("Event handling is taking too long, continuing in background") - return -} - -type contextKey int - -const ( - contextKeyRemoteEvent contextKey = iota - contextKeyMatrixEvent -) - -func GetMatrixEventFromContext(ctx context.Context) (evt *event.Event) { - evt, _ = ctx.Value(contextKeyMatrixEvent).(*event.Event) - return -} - -func GetRemoteEventFromContext(ctx context.Context) (evt RemoteEvent) { - evt, _ = ctx.Value(contextKeyRemoteEvent).(RemoteEvent) - return -} - -func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context { - var logWith zerolog.Context - switch evt := rawEvt.(type) { - case *portalMatrixEvent: - logWith = portal.Log.With().Int("event_loop_index", idx). - Str("action", "handle matrix event"). - Stringer("event_id", evt.evt.ID). - Str("event_type", evt.evt.Type.Type) - if evt.evt.Type.Class != event.EphemeralEventType { - logWith = logWith. - Stringer("event_id", evt.evt.ID). - Stringer("sender", evt.sender.MXID) - } - ctx := portal.backgroundCtx - ctx = context.WithValue(ctx, contextKeyMatrixEvent, evt.evt) - ctx = logWith.Logger().WithContext(ctx) - return ctx - case *portalRemoteEvent: - evt.evtType = evt.evt.GetType() - logWith = portal.Log.With().Int("event_loop_index", idx). - Str("action", "handle remote event"). - Str("source_id", string(evt.source.ID)). - Stringer("bridge_evt_type", evt.evtType) - logWith = evt.evt.AddLogContext(logWith) - if remoteSender := evt.evt.GetSender(); remoteSender.Sender != "" || remoteSender.IsFromMe { - logWith = logWith.Object("remote_sender", remoteSender) - } - if remoteMsg, ok := evt.evt.(RemoteMessage); ok { - if remoteMsgID := remoteMsg.GetID(); remoteMsgID != "" { - logWith = logWith.Str("remote_message_id", string(remoteMsgID)) - } - } - if remoteMsg, ok := evt.evt.(RemoteEventWithTargetMessage); ok { - if targetMsgID := remoteMsg.GetTargetMessage(); targetMsgID != "" { - logWith = logWith.Str("remote_target_message_id", string(targetMsgID)) - } - } - if remoteMsg, ok := evt.evt.(RemoteEventWithStreamOrder); ok { - if remoteStreamOrder := remoteMsg.GetStreamOrder(); remoteStreamOrder != 0 { - logWith = logWith.Int64("remote_stream_order", remoteStreamOrder) - } - } - if remoteMsg, ok := evt.evt.(RemoteEventWithTimestamp); ok { - if remoteTimestamp := remoteMsg.GetTimestamp(); !remoteTimestamp.IsZero() { - logWith = logWith.Time("remote_timestamp", remoteTimestamp) - } - } - ctx := portal.backgroundCtx - ctx = context.WithValue(ctx, contextKeyRemoteEvent, evt.evt) - ctx = logWith.Logger().WithContext(ctx) - if ctxMut, ok := evt.evt.(RemoteEventWithContextMutation); ok { - ctx = ctxMut.MutateContext(ctx) - } - return ctx - case *portalCreateEvent: - return evt.ctx - default: - panic(fmt.Errorf("invalid type %T in getEventCtxWithLog", evt)) - } -} - -func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(res EventHandlingResult)) { - log := zerolog.Ctx(ctx) - var res EventHandlingResult - defer func() { - doneCallback(res) - if err := recover(); err != nil { - logEvt := log.Error() - var errorString string - if realErr, ok := err.(error); ok { - logEvt = logEvt.Err(realErr) - errorString = realErr.Error() - } else { - logEvt = logEvt.Any(zerolog.ErrorFieldName, err) - errorString = fmt.Sprintf("%v", err) - } - stack := debug.Stack() - logEvt. - Bytes("stack", stack). - Msg("Event handling panicked") - switch evt := rawEvt.(type) { - case *portalMatrixEvent: - if evt.evt.ID != "" { - go portal.sendErrorStatus(ctx, evt.evt, ErrPanicInEventHandler) - } - case *portalCreateEvent: - evt.cb(fmt.Errorf("portal creation panicked")) - } - portal.Bridge.TrackAnalytics("", "Bridge Event Handler Panic", map[string]any{ - "error": errorString, - "stack": string(stack), - "handler_type": fmt.Sprintf("%T", rawEvt), - }) - } - }() - switch evt := rawEvt.(type) { - case *portalMatrixEvent: - isStateRequest := evt.evt.Type == event.BeeperSendState - if isStateRequest { - if err := portal.unwrapBeeperSendState(ctx, evt.evt); err != nil { - portal.sendErrorStatus(ctx, evt.evt, err) - return - } - } - res = portal.handleMatrixEvent(ctx, evt.sender, evt.evt, isStateRequest) - if portal.backgroundCtx.Err() != nil { - return - } - if res.SendMSS { - if res.Error != nil { - portal.sendErrorStatus(ctx, evt.evt, res.Error) - } else { - portal.sendSuccessStatus(ctx, evt.evt, 0, "") - } - } - if !isStateRequest && res.Error != nil && evt.evt.StateKey != nil { - portal.revertRoomMeta(ctx, evt.evt) - } - if isStateRequest && res.Success && !res.SkipStateEcho { - portal.sendRoomMeta( - ctx, - evt.sender.DoublePuppet(ctx), - time.UnixMilli(evt.evt.Timestamp), - evt.evt.Type, - evt.evt.GetStateKey(), - evt.evt.Content.Parsed, - false, - evt.evt.Content.Raw, - ) - } - case *portalRemoteEvent: - res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) - case *portalCreateEvent: - err := portal.createMatrixRoomInLoop(evt.ctx, evt.source, evt.info, nil) - res.Success = err == nil - evt.cb(err) - default: - panic(fmt.Errorf("illegal type %T in eventLoop", evt)) - } -} - -func (portal *Portal) unwrapBeeperSendState(ctx context.Context, evt *event.Event) error { - if !portal.Bridge.Config.EnableSendStateRequests { - return fmt.Errorf("send state requests are not enabled") - } - content, ok := evt.Content.Parsed.(*event.BeeperSendStateEventContent) - if !ok { - return fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed) - } - evt.Content = content.Content - evt.StateKey = &content.StateKey - evt.Type = event.Type{Type: content.Type, Class: event.StateEventType} - _ = evt.Content.ParseRaw(evt.Type) - mx, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) - if !ok { - return fmt.Errorf("matrix connector doesn't support fetching state") - } - prevEvt, err := mx.GetStateEvent(ctx, portal.MXID, evt.Type, evt.GetStateKey()) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - return fmt.Errorf("failed to get prev event: %w", err) - } else if prevEvt != nil { - evt.Unsigned.PrevContent = &prevEvt.Content - evt.Unsigned.PrevSender = prevEvt.Sender - } - return nil -} - -func (portal *Portal) FindPreferredLogin(ctx context.Context, user *User, allowRelay bool) (*UserLogin, *database.UserPortal, error) { - if portal.Receiver != "" { - login, err := portal.Bridge.GetExistingUserLoginByID(ctx, portal.Receiver) - if err != nil { - return nil, nil, err - } - if login == nil { - return nil, nil, fmt.Errorf("%w (receiver login is nil)", ErrNotLoggedIn) - } else if !login.Client.IsLoggedIn() { - return nil, nil, fmt.Errorf("%w (receiver login is not logged in)", ErrNotLoggedIn) - } else if login.UserMXID != user.MXID { - if allowRelay && portal.Relay != nil { - return nil, nil, nil - } - return nil, nil, fmt.Errorf("%w (relay not set and receiver login is owned by %s, not %s)", ErrNotLoggedIn, login.UserMXID, user.MXID) - } - up, err := portal.Bridge.DB.UserPortal.Get(ctx, login.UserLogin, portal.PortalKey) - return login, up, err - } - logins, err := portal.Bridge.DB.UserPortal.GetAllForUserInPortal(ctx, user.MXID, portal.PortalKey) - if err != nil { - return nil, nil, err - } - portal.Bridge.cacheLock.Lock() - defer portal.Bridge.cacheLock.Unlock() - for _, up := range logins { - login, ok := user.logins[up.LoginID] - if ok && login.Client != nil && login.Client.IsLoggedIn() { - return login, up, nil - } - } - if !allowRelay { - if len(logins) > 0 { - return nil, nil, fmt.Errorf("%w (no logins found that are in portal)", ErrNotLoggedIn) - } else if portal.Relay != nil { - return nil, nil, fmt.Errorf("%w (relay not allowed for this event)", ErrNotLoggedIn) - } - return nil, nil, ErrNotLoggedIn - } - // Portal has relay, use it - if portal.Relay != nil { - return nil, nil, nil - } - var firstLogin *UserLogin - for _, login := range user.logins { - firstLogin = login - break - } - if firstLogin != nil && firstLogin.Client.IsLoggedIn() { - zerolog.Ctx(ctx).Warn(). - Str("chosen_login_id", string(firstLogin.ID)). - Msg("No usable user portal rows found, returning random login") - return firstLogin, nil, nil - } else { - return nil, nil, fmt.Errorf("%w (no usable logins found)", ErrNotLoggedIn) - } -} - -func (portal *Portal) sendSuccessStatus(ctx context.Context, evt *event.Event, streamOrder int64, newEventID id.EventID) { - info := StatusEventInfoFromEvent(evt) - info.StreamOrder = streamOrder - if newEventID != evt.ID { - info.NewEventID = newEventID - } - portal.Bridge.Matrix.SendMessageStatus(ctx, &MessageStatus{Status: event.MessageStatusSuccess}, info) -} - -func (portal *Portal) sendErrorStatus(ctx context.Context, evt *event.Event, err error) { - status := WrapErrorInStatus(err) - if status.Status == "" { - status.Status = event.MessageStatusRetriable - } - if status.ErrorReason == "" { - status.ErrorReason = event.MessageStatusGenericError - } - if status.InternalError == nil { - status.InternalError = err - } - portal.Bridge.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) -} - -func (portal *Portal) checkConfusableName(ctx context.Context, userID id.UserID, name string) bool { - conn, ok := portal.Bridge.Matrix.(MatrixConnectorWithNameDisambiguation) - if !ok { - return false - } - confusableWith, err := conn.IsConfusableName(ctx, portal.MXID, userID, name) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to check if name is confusable") - return true - } - for _, confusable := range confusableWith { - // Don't disambiguate names that only conflict with ghosts of this bridge - if !portal.Bridge.IsGhostMXID(confusable) { - return true - } - } - return false -} - -var fakePerMessageProfileEventType = event.Type{Class: event.StateEventType, Type: "m.per_message_profile"} - -func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult { - log := zerolog.Ctx(ctx) - if evt.Mautrix.EventSource&event.SourceEphemeral != 0 { - switch evt.Type { - case event.EphemeralEventReceipt: - return portal.handleMatrixReceipts(ctx, evt) - case event.EphemeralEventTyping: - return portal.handleMatrixTyping(ctx, evt) - default: - return EventHandlingResultIgnored - } - } - if evt.Type == event.StateTombstone { - // Tombstones aren't bridged so they don't need a login - return portal.handleMatrixTombstone(ctx, evt) - } - login, userPortal, err := portal.FindPreferredLogin(ctx, sender, true) - if err != nil { - log.Err(err).Msg("Failed to get user login to handle Matrix event") - if errors.Is(err, ErrNotLoggedIn) { - shouldSendNotice := evt.Type == event.EventMessage && evt.Content.AsMessage().MsgType != event.MsgNotice - return EventHandlingResultFailed.WithMSSError(WrapErrorInStatus(err). - WithMessage(fmt.Sprintf("You're %s", err)). - WithIsCertain(true). - WithSendNotice(shouldSendNotice)) - } else { - return EventHandlingResultFailed.WithMSSError( - WrapErrorInStatus(err).WithMessage("Failed to get login to handle event").WithIsCertain(true).WithSendNotice(true), - ) - } - } - var origSender *OrigSender - if login == nil { - if isStateRequest { - return EventHandlingResultFailed.WithMSSError(ErrCantRelayStateRequest) - } - login = portal.Relay - origSender = &OrigSender{ - User: sender, - UserID: sender.MXID, - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Bool("is_relay", true) - }) - - memberInfo, err := portal.Bridge.Matrix.GetMemberInfo(ctx, portal.MXID, sender.MXID) - if err != nil { - log.Warn().Err(err).Msg("Failed to get member info for user being relayed") - } else if memberInfo != nil { - origSender.MemberEventContent = *memberInfo - if memberInfo.Displayname == "" { - origSender.DisambiguatedName = sender.MXID.String() - } else if origSender.RequiresDisambiguation = portal.checkConfusableName(ctx, sender.MXID, memberInfo.Displayname); origSender.RequiresDisambiguation { - origSender.DisambiguatedName = fmt.Sprintf("%s (%s)", memberInfo.Displayname, sender.MXID) - } else { - origSender.DisambiguatedName = memberInfo.Displayname - } - } else { - origSender.DisambiguatedName = sender.MXID.String() - } - msg := evt.Content.AsMessage() - if msg != nil && msg.BeeperPerMessageProfile != nil && msg.BeeperPerMessageProfile.Displayname != "" { - pmp := msg.BeeperPerMessageProfile - origSender.PerMessageProfile = *pmp - roomPLs, err := portal.Bridge.Matrix.GetPowerLevels(ctx, portal.MXID) - if err != nil { - log.Warn().Err(err).Msg("Failed to get power levels to check relay profile") - } - if roomPLs != nil && - roomPLs.GetUserLevel(sender.MXID) >= roomPLs.GetEventLevel(fakePerMessageProfileEventType) && - !portal.checkConfusableName(ctx, sender.MXID, pmp.Displayname) { - origSender.DisambiguatedName = pmp.Displayname - origSender.RequiresDisambiguation = false - } else { - origSender.DisambiguatedName = fmt.Sprintf("%s via %s", pmp.Displayname, origSender.DisambiguatedName) - } - } - - origSender.FormattedName = portal.Bridge.Config.Relay.FormatName(origSender) - } - // Copy logger because many of the handlers will use UpdateContext - ctx = log.With().Str("login_id", string(login.ID)).Logger().WithContext(ctx) - - if origSender == nil && portal.Bridge.Network.GetCapabilities().ImplicitReadReceipts && !evt.Type.IsAccountData() { - rrLog := log.With().Str("subaction", "implicit read receipt").Logger() - rrCtx := rrLog.WithContext(ctx) - rrLog.Debug().Msg("Sending implicit read receipt for event") - evtTS := time.UnixMilli(evt.Timestamp) - portal.callReadReceiptHandler(rrCtx, login, nil, &MatrixReadReceipt{ - Portal: portal, - EventID: evt.ID, - Implicit: true, - ReadUpTo: evtTS, - Receipt: event.ReadReceipt{Timestamp: evtTS}, - }, userPortal) - } - - switch evt.Type { - case event.EventMessage, event.EventSticker, event.EventUnstablePollStart, event.EventUnstablePollResponse: - return portal.handleMatrixMessage(ctx, login, origSender, evt) - case event.EventReaction: - return portal.handleMatrixReaction(ctx, login, origSender, evt) - case event.EventRedaction: - return portal.handleMatrixRedaction(ctx, login, origSender, evt) - case event.StateRoomName: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomNameHandlingNetworkAPI.HandleMatrixRoomName) - case event.StateTopic: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomTopicHandlingNetworkAPI.HandleMatrixRoomTopic) - case event.StateRoomAvatar: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, RoomAvatarHandlingNetworkAPI.HandleMatrixRoomAvatar) - case event.StateBeeperDisappearingTimer: - return handleMatrixRoomMeta(portal, ctx, login, origSender, evt, isStateRequest, DisappearTimerChangingNetworkAPI.HandleMatrixDisappearingTimer) - case event.StateEncryption: - // TODO? - return EventHandlingResultIgnored - case event.AccountDataMarkedUnread: - return handleMatrixAccountData(portal, ctx, login, evt, MarkedUnreadHandlingNetworkAPI.HandleMarkedUnread) - case event.AccountDataRoomTags: - return handleMatrixAccountData(portal, ctx, login, evt, TagHandlingNetworkAPI.HandleRoomTag) - case event.AccountDataBeeperMute: - return handleMatrixAccountData(portal, ctx, login, evt, MuteHandlingNetworkAPI.HandleMute) - case event.StateMember: - return portal.handleMatrixMembership(ctx, login, origSender, evt, isStateRequest) - case event.StatePowerLevels: - return portal.handleMatrixPowerLevels(ctx, login, origSender, evt, isStateRequest) - case event.BeeperDeleteChat: - return portal.handleMatrixDeleteChat(ctx, login, origSender, evt) - case event.BeeperAcceptMessageRequest: - return portal.handleMatrixAcceptMessageRequest(ctx, login, origSender, evt) - default: - return EventHandlingResultIgnored - } -} - -func (portal *Portal) handleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { - content, ok := evt.Content.Parsed.(*event.ReceiptEventContent) - if !ok { - return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - for evtID, receipts := range *content { - readReceipts, ok := receipts[event.ReceiptTypeRead] - if !ok { - continue - } - for userID, receipt := range readReceipts { - sender, err := portal.Bridge.GetUserByMXID(ctx, userID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle read receipt") - return EventHandlingResultFailed.WithError(err) - } - portal.handleMatrixReadReceipt(ctx, sender, evtID, receipt) - } - } - // TODO actual status - return EventHandlingResultSuccess -} - -func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) { - log := zerolog.Ctx(ctx) - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c. - Stringer("event_id", eventID). - Stringer("user_id", user.MXID). - Stringer("receipt_ts", receipt.Timestamp) - }) - login, userPortal, err := portal.FindPreferredLogin(ctx, user, false) - if err != nil { - if !errors.Is(err, ErrNotLoggedIn) { - log.Err(err).Msg("Failed to get preferred login for user") - } - return - } else if login == nil { - return - } - rrClient, ok := login.Client.(ReadReceiptHandlingNetworkAPI) - if !ok { - return - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("user_login_id", string(login.ID)) - }) - evt := &MatrixReadReceipt{ - Portal: portal, - EventID: eventID, - Receipt: receipt, - } - evt.ExactMessage, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, eventID) - if err != nil { - log.Err(err).Msg("Failed to get exact message from database") - evt.ReadUpTo = receipt.Timestamp - } else if evt.ExactMessage != nil { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("exact_message_id", string(evt.ExactMessage.ID)).Time("exact_message_ts", evt.ExactMessage.Timestamp) - }) - evt.ReadUpTo = evt.ExactMessage.Timestamp - } else { - evt.ReadUpTo = receipt.Timestamp - } - portal.callReadReceiptHandler(ctx, login, rrClient, evt, userPortal) -} - -func (portal *Portal) callReadReceiptHandler( - ctx context.Context, - login *UserLogin, - rrClient ReadReceiptHandlingNetworkAPI, - evt *MatrixReadReceipt, - userPortal *database.UserPortal, -) { - if rrClient == nil { - var ok bool - rrClient, ok = login.Client.(ReadReceiptHandlingNetworkAPI) - if !ok { - return - } - } - if userPortal == nil { - userPortal = database.UserPortalFor(login.UserLogin, portal.PortalKey) - } else { - evt.LastRead = userPortal.LastRead - userPortal = userPortal.CopyWithoutValues() - } - err := rrClient.HandleMatrixReadReceipt(ctx, evt) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to handle read receipt") - return - } - userPortal.LastRead = evt.ReadUpTo - err = portal.Bridge.DB.UserPortal.Put(ctx, userPortal) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save user portal metadata") - } - portal.Bridge.DisappearLoop.StartAllBefore(ctx, portal.MXID, evt.ReadUpTo) -} - -func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { - content, ok := evt.Content.Parsed.(*event.TypingEventContent) - if !ok { - return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - portal.currentlyTypingLock.Lock() - defer portal.currentlyTypingLock.Unlock() - slices.Sort(content.UserIDs) - stoppedTyping, startedTyping := exslices.SortedDiff(portal.currentlyTyping, content.UserIDs, func(a, b id.UserID) int { - return strings.Compare(string(a), string(b)) - }) - portal.sendTypings(ctx, stoppedTyping, false) - portal.sendTypings(ctx, startedTyping, true) - portal.currentlyTyping = content.UserIDs - // TODO actual status - return EventHandlingResultSuccess -} - -func (portal *Portal) sendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { - for _, userID := range userIDs { - login, ok := portal.currentlyTypingLogins[userID] - if !ok && !typing { - continue - } else if !ok { - user, err := portal.Bridge.GetUserByMXID(ctx, userID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to get user to send typing event") - continue - } else if user == nil { - continue - } - login, _, err = portal.FindPreferredLogin(ctx, user, false) - if err != nil { - if !errors.Is(err, ErrNotLoggedIn) { - zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to get user login to send typing event") - } - continue - } else if login == nil { - continue - } else if _, ok = login.Client.(TypingHandlingNetworkAPI); !ok { - continue - } - portal.currentlyTypingLogins[userID] = login - } - if !typing { - delete(portal.currentlyTypingLogins, userID) - } - typingAPI, ok := login.Client.(TypingHandlingNetworkAPI) - if !ok { - continue - } - err := typingAPI.HandleMatrixTyping(ctx, &MatrixTyping{ - Portal: portal, - IsTyping: typing, - Type: TypingTypeText, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to bridge Matrix typing event") - } else { - zerolog.Ctx(ctx).Debug(). - Stringer("user_id", userID). - Bool("typing", typing). - Msg("Sent typing event") - } - } -} - -func (portal *Portal) periodicTypingUpdater() { - // TODO actually call this function - log := portal.Log.With().Str("component", "typing updater").Logger() - ctx := log.WithContext(context.Background()) - for { - // TODO make delay configurable by network connector - time.Sleep(5 * time.Second) - portal.currentlyTypingLock.Lock() - if len(portal.currentlyTyping) == 0 { - portal.currentlyTypingLock.Unlock() - continue - } - for _, userID := range portal.currentlyTyping { - login, ok := portal.currentlyTypingLogins[userID] - if !ok { - continue - } - typingAPI, ok := login.Client.(TypingHandlingNetworkAPI) - if !ok { - continue - } - err := typingAPI.HandleMatrixTyping(ctx, &MatrixTyping{ - Portal: portal, - IsTyping: true, - Type: TypingTypeText, - }) - if err != nil { - log.Err(err).Stringer("user_id", userID).Msg("Failed to repeat Matrix typing event") - } else { - log.Debug(). - Stringer("user_id", userID). - Bool("typing", true). - Msg("Sent repeated typing event") - } - } - portal.currentlyTypingLock.Unlock() - } -} - -func (portal *Portal) checkMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error { - switch content.MsgType { - case event.MsgText, event.MsgNotice, event.MsgEmote: - // No checks for now, message length is safer to check after conversion inside connector - case event.MsgLocation: - if caps.LocationMessage.Reject() { - return ErrLocationMessagesNotAllowed - } - case event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile, event.CapMsgSticker: - capMsgType := content.GetCapMsgType() - feat, ok := caps.File[capMsgType] - if !ok { - return ErrUnsupportedMessageType - } - if content.MsgType != event.CapMsgSticker && - content.FileName != "" && - content.Body != content.FileName && - feat.Caption.Reject() { - return ErrCaptionsNotAllowed - } - if content.Info != nil { - dur := time.Duration(content.Info.Duration) * time.Millisecond - if feat.MaxDuration != nil && dur > feat.MaxDuration.Duration { - if capMsgType == event.CapMsgVoice { - return fmt.Errorf("%w: %s supports voice messages up to %s long", ErrVoiceMessageDurationTooLong, portal.Bridge.Network.GetName().DisplayName, exfmt.Duration(feat.MaxDuration.Duration)) - } - return fmt.Errorf("%w: %s is longer than the maximum of %s", ErrMediaDurationTooLong, exfmt.Duration(dur), exfmt.Duration(feat.MaxDuration.Duration)) - } - if feat.MaxSize != 0 && int64(content.Info.Size) > feat.MaxSize { - return fmt.Errorf("%w: %.1f MiB is larger than the maximum of %.1f MiB", ErrMediaTooLarge, float64(content.Info.Size)/1024/1024, float64(feat.MaxSize)/1024/1024) - } - if content.Info.MimeType != "" && feat.GetMimeSupport(content.Info.MimeType).Reject() { - return fmt.Errorf("%w (%s in %s)", ErrUnsupportedMediaType, content.Info.MimeType, capMsgType) - } - } - fallthrough - default: - } - return nil -} - -func (portal *Portal) parseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { - if origSender != nil || !strings.HasPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix) { - return "" - } - return networkid.RawTransactionID(strings.TrimPrefix(evt.ID.String(), database.NetworkTxnMXIDPrefix)) -} - -func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - log := zerolog.Ctx(ctx) - var relatesTo *event.RelatesTo - var msgContent *event.MessageEventContent - var pollContent *event.PollStartEventContent - var pollResponseContent *event.PollResponseEventContent - var ok bool - if evt.Type == event.EventUnstablePollStart { - pollContent, ok = evt.Content.Parsed.(*event.PollStartEventContent) - relatesTo = pollContent.RelatesTo - } else if evt.Type == event.EventUnstablePollResponse { - pollResponseContent, ok = evt.Content.Parsed.(*event.PollResponseEventContent) - relatesTo = &pollResponseContent.RelatesTo - } else { - msgContent, ok = evt.Content.Parsed.(*event.MessageEventContent) - relatesTo = msgContent.RelatesTo - if evt.Type == event.EventSticker { - msgContent.MsgType = event.CapMsgSticker - } - if msgContent.MsgType == event.MsgNotice && !portal.Bridge.Config.BridgeNotices { - return EventHandlingResultIgnored.WithMSSError(ErrIgnoringMNotice) - } - } - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed. - WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - caps := sender.Client.GetCapabilities(ctx, portal) - - if relatesTo.GetReplaceID() != "" { - if msgContent == nil { - log.Warn().Msg("Ignoring edit of poll") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w of polls", ErrEditsNotSupported)) - } - return portal.handleMatrixEdit(ctx, sender, origSender, evt, msgContent, caps) - } - var err error - if origSender != nil { - if msgContent == nil { - log.Debug().Msg("Ignoring poll event from relayed user") - return EventHandlingResultIgnored.WithMSSError(ErrIgnoringPollFromRelayedUser) - } - if !caps.PerMessageProfileRelay { - if evt.Type == event.EventSticker { - msgContent.MsgType = event.MsgImage - evt.Type = event.EventMessage - } - msgContent, err = portal.Bridge.Config.Relay.FormatMessage(msgContent, origSender) - if err != nil { - log.Err(err).Msg("Failed to format message for relaying") - return EventHandlingResultFailed.WithMSSError(err) - } - } - } - if msgContent != nil { - if err = portal.checkMessageContentCaps(caps, msgContent); err != nil { - return EventHandlingResultFailed.WithMSSError(err) - } - } else if pollResponseContent != nil || pollContent != nil { - if _, ok = sender.Client.(PollHandlingNetworkAPI); !ok { - log.Debug().Msg("Ignoring poll event as network connector doesn't implement PollHandlingNetworkAPI") - return EventHandlingResultIgnored.WithMSSError(ErrPollsNotSupported) - } - } - - var threadRoot, replyTo, voteTo *database.Message - if evt.Type == event.EventUnstablePollResponse { - voteTo, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, relatesTo.GetReferenceID()) - if err != nil { - log.Err(err).Msg("Failed to get poll target message from database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get poll message: %w", ErrDatabaseError, err)) - } else if voteTo == nil { - log.Warn().Stringer("vote_to_id", relatesTo.GetReferenceID()).Msg("Poll target message not found") - return EventHandlingResultFailed.WithMSSError(ErrUnknownPoll) - } - } - var replyToID id.EventID - threadRootID := relatesTo.GetThreadParent() - if caps.Thread.Partial() { - replyToID = relatesTo.GetNonFallbackReplyTo() - if threadRootID != "" { - threadRoot, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, threadRootID) - if err != nil { - log.Err(err).Msg("Failed to get thread root message from database") - } else if threadRoot == nil { - log.Warn().Stringer("thread_root_id", threadRootID).Msg("Thread root message not found") - } - } - } else { - replyToID = relatesTo.GetReplyTo() - } - if replyToID != "" && (caps.Reply.Partial() || caps.Thread.Partial()) { - replyTo, err = portal.Bridge.DB.Message.GetPartByMXID(ctx, replyToID) - if err != nil { - log.Err(err).Msg("Failed to get reply target message from database") - } else if replyTo == nil { - log.Warn().Stringer("reply_to_id", replyToID).Msg("Reply target message not found") - } else { - // Support replying to threads from non-thread-capable clients. - // The fallback happens if the message is not a Matrix thread and either - // * the replied-to message is in a thread, or - // * the network only supports threads (assume the user wants to start a new thread) - if caps.Thread.Partial() && threadRoot == nil && (replyTo.ThreadRoot != "" || !caps.Reply.Partial()) { - threadRootRemoteID := replyTo.ThreadRoot - if threadRootRemoteID == "" { - threadRootRemoteID = replyTo.ID - } - threadRoot, err = portal.Bridge.DB.Message.GetFirstThreadMessage(ctx, portal.PortalKey, threadRootRemoteID) - if err != nil { - log.Err(err).Msg("Failed to get thread root message from database (via reply fallback)") - } - } - if !caps.Reply.Partial() { - replyTo = nil - } - } - } - var messageTimer *event.BeeperDisappearingTimer - if msgContent != nil { - messageTimer = msgContent.BeeperDisappearingTimer - } - if messageTimer != nil && *portal.Disappear.ToEventContent() != *messageTimer { - log.Warn(). - Any("event_timer", messageTimer). - Any("portal_timer", portal.Disappear.ToEventContent()). - Msg("Mismatching disappearing timer in event") - } - - wrappedMsgEvt := &MatrixMessage{ - MatrixEventBase: MatrixEventBase[*event.MessageEventContent]{ - Event: evt, - Content: msgContent, - OrigSender: origSender, - Portal: portal, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - ThreadRoot: threadRoot, - ReplyTo: replyTo, - } - if portal.Bridge.Config.DeduplicateMatrixMessages { - if part, err := portal.Bridge.DB.Message.GetPartByTxnID(ctx, portal.Receiver, evt.ID, wrappedMsgEvt.InputTransactionID); err != nil { - log.Err(err).Msg("Failed to check db if message is already sent") - } else if part != nil { - log.Debug(). - Stringer("message_mxid", part.MXID). - Stringer("input_event_id", evt.ID). - Msg("Message already sent, ignoring") - return EventHandlingResultIgnored - } - } - - err = portal.autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) - if err != nil { - log.Warn().Err(err).Msg("Failed to auto-accept message request on message") - // TODO stop processing? - } - - var resp *MatrixMessageResponse - if msgContent != nil { - resp, err = sender.Client.HandleMatrixMessage(ctx, wrappedMsgEvt) - } else if pollContent != nil { - resp, err = sender.Client.(PollHandlingNetworkAPI).HandleMatrixPollStart(ctx, &MatrixPollStart{ - MatrixMessage: *wrappedMsgEvt, - Content: pollContent, - }) - } else if pollResponseContent != nil { - resp, err = sender.Client.(PollHandlingNetworkAPI).HandleMatrixPollVote(ctx, &MatrixPollVote{ - MatrixMessage: *wrappedMsgEvt, - VoteTo: voteTo, - Content: pollResponseContent, - }) - } else { - log.Error().Msg("Failed to handle Matrix message: all contents are nil?") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("all contents are nil")) - } - if err != nil { - log.Err(err).Msg("Failed to handle Matrix message") - return EventHandlingResultFailed.WithMSSError(err) - } - message := wrappedMsgEvt.fillDBMessage(resp.DB) - if resp.Pending { - for _, save := range wrappedMsgEvt.pendingSaves { - save.ackedAt = time.Now() - } - } else { - if resp.DB == nil { - log.Error().Msg("Network connector didn't return a message to save") - } else { - if portal.Bridge.Config.OutgoingMessageReID { - message.MXID = portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, message.ID, message.PartID) - } - // Hack to ensure the ghost row exists - // TODO move to better place (like login) - portal.Bridge.GetGhostByID(ctx, message.SenderID) - err = portal.Bridge.DB.Message.Insert(ctx, message) - if err != nil { - log.Err(err).Msg("Failed to save message to database") - } else if resp.PostSave != nil { - resp.PostSave(ctx, message) - } - if resp.RemovePending != "" { - portal.outgoingMessagesLock.Lock() - delete(portal.outgoingMessages, resp.RemovePending) - portal.outgoingMessagesLock.Unlock() - } - } - portal.sendSuccessStatus(ctx, evt, resp.StreamOrder, message.MXID) - } - ds := portal.Disappear - if messageTimer != nil { - ds = database.DisappearingSettingFromEvent(messageTimer) - } - if ds.Type != event.DisappearingTypeNone { - go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ - RoomID: portal.MXID, - EventID: message.MXID, - Timestamp: message.Timestamp, - DisappearingSetting: ds.StartingAt(message.Timestamp), - }) - } - if resp.Pending { - // Not exactly queued, but not finished either - return EventHandlingResultQueued - } - return EventHandlingResultSuccess.WithEventID(message.MXID).WithStreamOrder(resp.StreamOrder) -} - -// AddPendingToIgnore adds a transaction ID that should be ignored if encountered as a new message. -// -// This should be used when the network connector will return the real message ID from HandleMatrixMessage. -// The [MatrixMessageResponse] should include RemovePending with the transaction ID sto remove it from the lit -// after saving to database. -// -// See also: [MatrixMessage.AddPendingToSave] -func (evt *MatrixMessage) AddPendingToIgnore(txnID networkid.TransactionID) { - evt.Portal.outgoingMessagesLock.Lock() - evt.Portal.outgoingMessages[txnID] = &outgoingMessage{ - ignore: true, - } - evt.Portal.outgoingMessagesLock.Unlock() -} - -// AddPendingToSave adds a transaction ID that should be processed and pointed at the existing event if encountered. -// -// This should be used when the network connector returns `Pending: true` from HandleMatrixMessage, -// i.e. when the network connector does not know the message ID at the end of the handler. -// The [MatrixMessageResponse] should set Pending to true to prevent saving the returned message to the database. -// -// The provided function will be called when the message is encountered. -func (evt *MatrixMessage) AddPendingToSave(message *database.Message, txnID networkid.TransactionID, handleEcho RemoteEchoHandler) { - pending := &outgoingMessage{ - db: evt.fillDBMessage(message), - evt: evt.Event, - handle: handleEcho, - } - evt.Portal.outgoingMessagesLock.Lock() - evt.Portal.outgoingMessages[txnID] = pending - evt.pendingSaves = append(evt.pendingSaves, pending) - evt.Portal.outgoingMessagesLock.Unlock() -} - -// RemovePending removes a transaction ID from the list of pending messages. -// This should only be called if sending the message fails. -func (evt *MatrixMessage) RemovePending(txnID networkid.TransactionID) { - evt.Portal.outgoingMessagesLock.Lock() - pendingSave := evt.Portal.outgoingMessages[txnID] - if pendingSave != nil { - evt.pendingSaves = slices.DeleteFunc(evt.pendingSaves, func(save *outgoingMessage) bool { - return save == pendingSave - }) - } - delete(evt.Portal.outgoingMessages, txnID) - evt.Portal.outgoingMessagesLock.Unlock() -} - -func (evt *MatrixMessage) fillDBMessage(message *database.Message) *database.Message { - if message == nil { - message = &database.Message{} - } - if message.MXID == "" { - message.MXID = evt.Event.ID - } - if message.Room.ID == "" { - message.Room = evt.Portal.PortalKey - } - if message.Timestamp.IsZero() { - message.Timestamp = time.UnixMilli(evt.Event.Timestamp) - } - if message.ReplyTo.MessageID == "" && evt.ReplyTo != nil { - message.ReplyTo.MessageID = evt.ReplyTo.ID - message.ReplyTo.PartID = &evt.ReplyTo.PartID - } - if message.ThreadRoot == "" && evt.ThreadRoot != nil { - message.ThreadRoot = evt.ThreadRoot.ID - if evt.ThreadRoot.ThreadRoot != "" { - message.ThreadRoot = evt.ThreadRoot.ThreadRoot - } - } - if message.SenderMXID == "" { - message.SenderMXID = evt.Event.Sender - } - if message.SendTxnID != "" { - message.SendTxnID = evt.InputTransactionID - } - return message -} - -func (portal *Portal) pendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { - ticker := time.NewTicker(cfg.CheckInterval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - portal.checkPendingMessages(ctx, cfg) - case <-ctx.Done(): - return - } - } -} - -func (portal *Portal) checkPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) { - portal.outgoingMessagesLock.Lock() - defer portal.outgoingMessagesLock.Unlock() - for _, msg := range portal.outgoingMessages { - if msg.evt != nil && !msg.timeouted { - if cfg.NoEchoTimeout > 0 && !msg.ackedAt.IsZero() && time.Since(msg.ackedAt) > cfg.NoEchoTimeout { - msg.timeouted = true - portal.sendErrorStatus(ctx, msg.evt, ErrRemoteEchoTimeout.WithMessage(cfg.NoEchoMessage)) - } else if cfg.NoAckTimeout > 0 && time.Since(msg.db.Timestamp) > cfg.NoAckTimeout { - msg.timeouted = true - portal.sendErrorStatus(ctx, msg.evt, ErrRemoteAckTimeout.WithMessage(cfg.NoAckMessage)) - } - } - } -} - -func (portal *Portal) handleMatrixEdit( - ctx context.Context, - sender *UserLogin, - origSender *OrigSender, - evt *event.Event, - content *event.MessageEventContent, - caps *event.RoomFeatures, -) EventHandlingResult { - log := zerolog.Ctx(ctx) - editTargetID := content.RelatesTo.GetReplaceID() - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("edit_target_mxid", editTargetID) - }) - if content.NewContent != nil { - content = content.NewContent - if evt.Type == event.EventSticker { - content.MsgType = event.CapMsgSticker - } - } - if origSender != nil && !caps.PerMessageProfileRelay { - if evt.Type == event.EventSticker { - content.MsgType = event.MsgImage - evt.Type = event.EventMessage - } - var err error - content, err = portal.Bridge.Config.Relay.FormatMessage(content, origSender) - if err != nil { - log.Err(err).Msg("Failed to format message for relaying") - return EventHandlingResultFailed.WithMSSError(err) - } - } - - editingAPI, ok := sender.Client.(EditHandlingNetworkAPI) - if !ok { - log.Debug().Msg("Ignoring edit as network connector doesn't implement EditHandlingNetworkAPI") - return EventHandlingResultIgnored.WithMSSError(ErrEditsNotSupported) - } else if !caps.Edit.Partial() { - log.Debug().Msg("Ignoring edit as room doesn't support edits") - return EventHandlingResultIgnored.WithMSSError(ErrEditsNotSupportedInPortal) - } else if err := portal.checkMessageContentCaps(caps, content); err != nil { - return EventHandlingResultFailed.WithMSSError(err) - } - editTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, editTargetID) - if err != nil { - log.Err(err).Msg("Failed to get edit target message from database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get edit target: %w", ErrDatabaseError, err)) - } else if editTarget == nil { - log.Warn().Msg("Edit target message not found in database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("edit %w", ErrTargetMessageNotFound)) - } else if caps.EditMaxAge != nil && caps.EditMaxAge.Duration > 0 && time.Since(editTarget.Timestamp) > caps.EditMaxAge.Duration { - return EventHandlingResultFailed.WithMSSError(ErrEditTargetTooOld) - } else if caps.EditMaxCount > 0 && editTarget.EditCount >= caps.EditMaxCount { - return EventHandlingResultFailed.WithMSSError(ErrEditTargetTooManyEdits) - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("edit_target_remote_id", string(editTarget.ID)) - }) - err = editingAPI.HandleMatrixEdit(ctx, &MatrixEdit{ - MatrixEventBase: MatrixEventBase[*event.MessageEventContent]{ - Event: evt, - Content: content, - OrigSender: origSender, - Portal: portal, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - EditTarget: editTarget, - }) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix edit") - return EventHandlingResultFailed.WithMSSError(err) - } - err = portal.Bridge.DB.Message.Update(ctx, editTarget) - if err != nil { - log.Err(err).Msg("Failed to save message to database after editing") - } - // TODO allow returning stream order from HandleMatrixEdit - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultSuccess -} - -func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) (handleRes EventHandlingResult) { - log := zerolog.Ctx(ctx) - reactingAPI, ok := sender.Client.(ReactionHandlingNetworkAPI) - if !ok { - log.Debug().Msg("Ignoring reaction as network connector doesn't implement ReactionHandlingNetworkAPI") - return EventHandlingResultIgnored.WithMSSError(ErrReactionsNotSupported) - } - content, ok := evt.Content.Parsed.(*event.ReactionEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("reaction_target_mxid", content.RelatesTo.EventID) - }) - reactionTarget, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.RelatesTo.EventID) - if err != nil { - log.Err(err).Msg("Failed to get reaction target message from database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get reaction target: %w", ErrDatabaseError, err)) - } else if reactionTarget == nil { - log.Warn().Msg("Reaction target message not found in database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("reaction %w", ErrTargetMessageNotFound)) - } - caps := sender.Client.GetCapabilities(ctx, portal) - err = portal.autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) - if err != nil { - log.Warn().Err(err).Msg("Failed to auto-accept message request on reaction") - // TODO stop processing? - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("reaction_target_remote_id", string(reactionTarget.ID)) - }) - react := &MatrixReaction{ - MatrixEventBase: MatrixEventBase[*event.ReactionEventContent]{ - Event: evt, - Content: content, - Portal: portal, - OrigSender: origSender, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - TargetMessage: reactionTarget, - } - preResp, err := reactingAPI.PreHandleMatrixReaction(ctx, react) - if err != nil { - log.Err(err).Msg("Failed to pre-handle Matrix reaction") - return EventHandlingResultFailed.WithMSSError(err) - } - var deterministicID id.EventID - if portal.Bridge.Config.OutgoingMessageReID { - deterministicID = portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, reactionTarget, preResp.SenderID, preResp.EmojiID) - } - defer func() { - // Do this in a defer so that it happens after any potential defer calls to removeOutdatedReaction - if handleRes.Success { - portal.sendSuccessStatus(ctx, evt, 0, deterministicID) - } - }() - removeOutdatedReaction := func(oldReact *database.Reaction, deleteDB bool) { - if !handleRes.Success { - return - } - _, err := portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: oldReact.MXID, - }, - }, nil) - if err != nil { - log.Err(err).Msg("Failed to remove old reaction") - } - if deleteDB { - err = portal.Bridge.DB.Reaction.Delete(ctx, oldReact) - if err != nil { - log.Err(err).Msg("Failed to delete old reaction from database") - } - } - } - existing, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, reactionTarget.ID, reactionTarget.PartID, preResp.SenderID, preResp.EmojiID) - if err != nil { - log.Err(err).Msg("Failed to check if reaction is a duplicate") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to check for existing reaction: %w", ErrDatabaseError, err)) - } else if existing != nil { - if existing.EmojiID != "" || existing.Emoji == preResp.Emoji { - log.Debug().Msg("Ignoring duplicate reaction") - portal.sendSuccessStatus(ctx, evt, 0, deterministicID) - return EventHandlingResultIgnored.WithEventID(deterministicID) - } - react.ReactionToOverride = existing - defer removeOutdatedReaction(existing, false) - } - react.PreHandleResp = &preResp - if preResp.MaxReactions > 0 { - allReactions, err := portal.Bridge.DB.Reaction.GetAllToMessageBySender(ctx, portal.Receiver, reactionTarget.ID, preResp.SenderID) - if err != nil { - log.Err(err).Msg("Failed to get all reactions to message by sender") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get previous reactions: %w", ErrDatabaseError, err)) - } - if len(allReactions) < preResp.MaxReactions { - react.ExistingReactionsToKeep = allReactions - } else { - // Keep n-1 previous reactions and remove the rest - react.ExistingReactionsToKeep = allReactions[:preResp.MaxReactions-1] - for _, oldReaction := range allReactions[preResp.MaxReactions-1:] { - if existing != nil && oldReaction.EmojiID == existing.EmojiID { - // Don't double-delete on networks that only allow one emoji - continue - } - // Intentionally defer in a loop, there won't be that many items, - // and we want all of them to be done after this function completes successfully - //goland:noinspection GoDeferInLoop - defer removeOutdatedReaction(oldReaction, true) - } - } - } - dbReaction, err := reactingAPI.HandleMatrixReaction(ctx, react) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix reaction") - return EventHandlingResultFailed.WithMSSError(err) - } - if dbReaction == nil { - dbReaction = &database.Reaction{} - } - // Fill all fields that are known to allow omitting them in connector code - if dbReaction.Room.ID == "" { - dbReaction.Room = portal.PortalKey - } - if dbReaction.MessageID == "" { - dbReaction.MessageID = reactionTarget.ID - dbReaction.MessagePartID = reactionTarget.PartID - } - if deterministicID != "" { - dbReaction.MXID = deterministicID - } else if dbReaction.MXID == "" { - dbReaction.MXID = evt.ID - } - if dbReaction.Timestamp.IsZero() { - dbReaction.Timestamp = time.UnixMilli(evt.Timestamp) - } - if preResp.EmojiID == "" && dbReaction.EmojiID == "" { - if dbReaction.Emoji == "" { - dbReaction.Emoji = preResp.Emoji - } - } else if dbReaction.EmojiID == "" { - dbReaction.EmojiID = preResp.EmojiID - } - if dbReaction.SenderID == "" { - dbReaction.SenderID = preResp.SenderID - } - if dbReaction.SenderMXID == "" { - dbReaction.SenderMXID = evt.Sender - } - err = portal.Bridge.DB.Reaction.Upsert(ctx, dbReaction) - if err != nil { - log.Err(err).Msg("Failed to save reaction to database") - } - return EventHandlingResultSuccess.WithEventID(deterministicID) -} - -func handleMatrixRoomMeta[APIType any, ContentType any]( - portal *Portal, - ctx context.Context, - sender *UserLogin, - origSender *OrigSender, - evt *event.Event, - isStateRequest bool, - fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) (bool, error), -) EventHandlingResult { - if evt.StateKey == nil || *evt.StateKey != "" { - return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) - } - //caps := sender.Client.GetCapabilities(ctx, portal) - //if stateCap, ok := caps.State[evt.Type.Type]; !ok || stateCap.Level <= event.CapLevelUnsupported { - // return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%s %w", evt.Type.Type, ErrRoomMetadataNotAllowed)) - //} - api, ok := sender.Client.(APIType) - if !ok { - return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%w of type %s", ErrRoomMetadataNotSupported, evt.Type)) - } - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(ContentType) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - switch typedContent := evt.Content.Parsed.(type) { - case *event.RoomNameEventContent: - if typedContent.Name == portal.Name { - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultIgnored - } - case *event.TopicEventContent: - if typedContent.Topic == portal.Topic { - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultIgnored - } - case *event.RoomAvatarEventContent: - if typedContent.URL == portal.AvatarMXC { - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultIgnored - } - case *event.BeeperDisappearingTimer: - if typedContent.Type == event.DisappearingTypeNone || typedContent.Timer.Duration <= 0 { - typedContent.Type = event.DisappearingTypeNone - typedContent.Timer.Duration = 0 - } - if typedContent.Type == portal.Disappear.Type && typedContent.Timer.Duration == portal.Disappear.Timer { - portal.sendSuccessStatus(ctx, evt, 0, "") - return EventHandlingResultIgnored - } - if !sender.Client.GetCapabilities(ctx, portal).DisappearingTimer.Supports(typedContent) { - return EventHandlingResultFailed.WithMSSError(ErrDisappearingTimerUnsupported) - } - } - var prevContent ContentType - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent, _ = evt.Unsigned.PrevContent.Parsed.(ContentType) - } - - changed, err := fn(api, ctx, &MatrixRoomMeta[ContentType]{ - MatrixEventBase: MatrixEventBase[ContentType]{ - Event: evt, - Content: content, - Portal: portal, - OrigSender: origSender, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - IsStateRequest: isStateRequest, - PrevContent: prevContent, - }) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix room metadata") - return EventHandlingResultFailed.WithMSSError(err) - } - if changed { - if evt.Type != event.StateBeeperDisappearingTimer { - portal.UpdateBridgeInfo(ctx) - } - err = portal.Save(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after updating room metadata") - } - } - return EventHandlingResultSuccess.WithMSS() -} - -func handleMatrixAccountData[APIType any, ContentType any]( - portal *Portal, ctx context.Context, sender *UserLogin, evt *event.Event, - fn func(APIType, context.Context, *MatrixRoomMeta[ContentType]) error, -) EventHandlingResult { - api, ok := sender.Client.(APIType) - if !ok { - return EventHandlingResultIgnored - } - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(ContentType) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - var prevContent ContentType - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent, _ = evt.Unsigned.PrevContent.Parsed.(ContentType) - } - - err := fn(api, ctx, &MatrixRoomMeta[ContentType]{ - MatrixEventBase: MatrixEventBase[ContentType]{ - Event: evt, - Content: content, - Portal: portal, - }, - PrevContent: prevContent, - }) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix room account data") - return EventHandlingResultFailed.WithError(err) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) getTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) { - if targetGhost, err := portal.Bridge.GetGhostByMXID(ctx, userID); err != nil { - return nil, fmt.Errorf("failed to get ghost: %w", err) - } else if targetGhost != nil { - return targetGhost, nil - } else if targetUser, err := portal.Bridge.GetUserByMXID(ctx, userID); err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } else if targetUserLogin, _, err := portal.FindPreferredLogin(ctx, targetUser, false); err != nil { - return nil, fmt.Errorf("failed to find preferred login: %w", err) - } else if targetUserLogin != nil { - return targetUserLogin, nil - } else { - // Return raw nil as a separate case to ensure a typed nil isn't returned - return nil, nil - } -} - -func (portal *Portal) handleMatrixAcceptMessageRequest( - ctx context.Context, - sender *UserLogin, - origSender *OrigSender, - evt *event.Event, -) EventHandlingResult { - if origSender != nil { - return EventHandlingResultFailed.WithMSSError(ErrIgnoringAcceptRequestRelayedUser) - } - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(*event.BeeperAcceptMessageRequestEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - api, ok := sender.Client.(MessageRequestAcceptingNetworkAPI) - if !ok { - return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) - } - err := api.HandleMatrixAcceptMessageRequest(ctx, &MatrixAcceptMessageRequest{ - Event: evt, - Content: content, - Portal: portal, - }) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix accept message request") - return EventHandlingResultFailed.WithMSSError(err) - } - if portal.MessageRequest { - portal.MessageRequest = false - portal.UpdateBridgeInfo(ctx) - err = portal.Save(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after accepting message request") - } - } - return EventHandlingResultSuccess.WithMSS() -} - -func (portal *Portal) autoAcceptMessageRequest( - ctx context.Context, evt *event.Event, sender *UserLogin, origSender *OrigSender, caps *event.RoomFeatures, -) error { - if !portal.MessageRequest || caps.MessageRequest == nil || caps.MessageRequest.AcceptWithMessage == event.CapLevelFullySupported { - return nil - } - mran, ok := sender.Client.(MessageRequestAcceptingNetworkAPI) - if !ok { - return nil - } - err := mran.HandleMatrixAcceptMessageRequest(ctx, &MatrixAcceptMessageRequest{ - Event: evt, - Content: &event.BeeperAcceptMessageRequestEventContent{ - IsImplicit: true, - }, - Portal: portal, - OrigSender: origSender, - }) - if err != nil { - return err - } - if portal.MessageRequest { - portal.MessageRequest = false - portal.UpdateBridgeInfo(ctx) - err = portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after accepting message request") - } - } - return nil -} - -func (portal *Portal) handleMatrixDeleteChat( - ctx context.Context, - sender *UserLogin, - origSender *OrigSender, - evt *event.Event, -) EventHandlingResult { - if origSender != nil { - return EventHandlingResultFailed.WithMSSError(ErrIgnoringDeleteChatRelayedUser) - } - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(*event.BeeperChatDeleteEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - api, ok := sender.Client.(DeleteChatHandlingNetworkAPI) - if !ok { - return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) - } - err := api.HandleMatrixDeleteChat(ctx, &MatrixDeleteChat{ - Event: evt, - Content: content, - Portal: portal, - }) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix chat delete") - return EventHandlingResultFailed.WithMSSError(err) - } - if portal.Receiver == "" { - _, others, err := portal.findOtherLogins(ctx, sender) - if err != nil { - log.Err(err).Msg("Failed to check if portal has other logins") - return EventHandlingResultFailed.WithError(err) - } else if len(others) > 0 { - log.Debug().Msg("Not deleting portal after chat delete as other logins are present") - return EventHandlingResultSuccess - } - } - err = portal.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete portal from database") - return EventHandlingResultFailed.WithMSSError(err) - } - // The event context has likely been canceled by delete, so use a background context for the delete call - noCancelCtx := log.WithContext(portal.Bridge.BackgroundCtx) - err = portal.Bridge.Bot.DeleteRoom(noCancelCtx, portal.MXID, false) - if err != nil { - log.Err(err).Msg("Failed to delete Matrix room") - return EventHandlingResultFailed.WithMSSError(err) - } - // No MSS here as the portal was deleted - return EventHandlingResultSuccess -} - -func (portal *Portal) handleMatrixMembership( - ctx context.Context, - sender *UserLogin, - origSender *OrigSender, - evt *event.Event, - isStateRequest bool, -) EventHandlingResult { - if evt.StateKey == nil { - return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) - } - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(*event.MemberEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - prevContent := &event.MemberEventContent{Membership: event.MembershipLeave} - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.MemberEventContent) - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c. - Str("membership", string(content.Membership)). - Str("prev_membership", string(prevContent.Membership)). - Str("target_user_id", evt.GetStateKey()) - }) - api, ok := sender.Client.(MembershipHandlingNetworkAPI) - if !ok { - return EventHandlingResultIgnored.WithMSSError(ErrMembershipNotSupported) - } - targetMXID := id.UserID(*evt.StateKey) - target, err := portal.getTargetUser(ctx, targetMXID) - if err != nil { - log.Err(err).Msg("Failed to get member event target") - return EventHandlingResultFailed.WithMSSError(err) - } - - membershipChangeType := MembershipChangeType{ - From: prevContent.Membership, - To: content.Membership, - IsSelf: evt.Sender == targetMXID, - } - if membershipChangeType.IsSelf && membershipChangeType.To == event.MembershipLeave { - sender.inPortalCache.Remove(portal.PortalKey) - if !portal.Bridge.Config.BridgeMatrixLeave { - log.Debug().Str("prev_membership", string(membershipChangeType.From)).Msg("Dropping leave event") - return EventHandlingResultIgnored - } - } - targetGhost, _ := target.(*Ghost) - membershipChange := &MatrixMembershipChange{ - MatrixRoomMeta: MatrixRoomMeta[*event.MemberEventContent]{ - MatrixEventBase: MatrixEventBase[*event.MemberEventContent]{ - Event: evt, - Content: content, - Portal: portal, - OrigSender: origSender, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - IsStateRequest: isStateRequest, - PrevContent: prevContent, - }, - Target: target, - Type: membershipChangeType, - } - res, err := api.HandleMatrixMembership(ctx, membershipChange) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix membership change") - return EventHandlingResultFailed.WithMSSError(err) - } - didRedirectInvite := membershipChangeType == Invite && - targetGhost != nil && - res != nil && - res.RedirectTo != "" && - res.RedirectTo != targetGhost.ID - if didRedirectInvite { - log.Debug(). - Str("orig_id", string(targetGhost.ID)). - Str("redirect_id", string(res.RedirectTo)). - Msg("Invite was redirected to different ghost") - var redirectGhost *Ghost - redirectGhost, err = portal.Bridge.GetGhostByID(ctx, res.RedirectTo) - if err != nil { - log.Err(err).Msg("Failed to get redirect target ghost") - return EventHandlingResultFailed.WithError(err) - } - if !isStateRequest { - portal.sendRoomMeta( - ctx, - sender.User.DoublePuppet(ctx), - time.UnixMilli(evt.Timestamp), - event.StateMember, - evt.GetStateKey(), - &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: fmt.Sprintf("Invite redirected to %s", res.RedirectTo), - }, - true, - nil, - ) - } - portal.sendRoomMeta( - ctx, - sender.User.DoublePuppet(ctx), - time.UnixMilli(evt.Timestamp), - event.StateMember, - redirectGhost.Intent.GetMXID().String(), - content, - false, - nil, - ) - } - return EventHandlingResultSuccess.WithMSS().WithSkipStateEcho(didRedirectInvite) -} - -func makePLChange(old, new int, newIsSet bool) *SinglePowerLevelChange { - if old == new { - return nil - } - return &SinglePowerLevelChange{OrigLevel: old, NewLevel: new, NewIsSet: newIsSet} -} - -func getUniqueKeys[Key comparable, Value any](maps ...map[Key]Value) map[Key]struct{} { - unique := make(map[Key]struct{}) - for _, m := range maps { - for k := range m { - unique[k] = struct{}{} - } - } - return unique -} - -func (portal *Portal) handleMatrixPowerLevels( - ctx context.Context, - sender *UserLogin, - origSender *OrigSender, - evt *event.Event, - isStateRequest bool, -) EventHandlingResult { - if evt.StateKey == nil || *evt.StateKey != "" { - return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) - } - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - if content.CreateEvent == nil { - ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) - if ok { - var err error - content.CreateEvent, err = ars.GetStateEvent(ctx, portal.MXID, event.StateCreate, "") - if err != nil { - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("failed to get create event for power levels: %w", err)) - } - } - } - api, ok := sender.Client.(PowerLevelHandlingNetworkAPI) - if !ok { - return EventHandlingResultIgnored.WithMSSError(ErrPowerLevelsNotSupported) - } - prevContent := &event.PowerLevelsEventContent{} - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.PowerLevelsEventContent) - prevContent.CreateEvent = content.CreateEvent - } - - plChange := &MatrixPowerLevelChange{ - MatrixRoomMeta: MatrixRoomMeta[*event.PowerLevelsEventContent]{ - MatrixEventBase: MatrixEventBase[*event.PowerLevelsEventContent]{ - Event: evt, - Content: content, - Portal: portal, - OrigSender: origSender, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - IsStateRequest: isStateRequest, - PrevContent: prevContent, - }, - Users: make(map[id.UserID]*UserPowerLevelChange), - Events: make(map[string]*SinglePowerLevelChange), - UsersDefault: makePLChange(prevContent.UsersDefault, content.UsersDefault, true), - EventsDefault: makePLChange(prevContent.EventsDefault, content.EventsDefault, true), - StateDefault: makePLChange(prevContent.StateDefault(), content.StateDefault(), content.StateDefaultPtr != nil), - Invite: makePLChange(prevContent.Invite(), content.Invite(), content.InvitePtr != nil), - Kick: makePLChange(prevContent.Kick(), content.Kick(), content.KickPtr != nil), - Ban: makePLChange(prevContent.Ban(), content.Ban(), content.BanPtr != nil), - Redact: makePLChange(prevContent.Redact(), content.Redact(), content.RedactPtr != nil), - } - for eventType := range getUniqueKeys(content.Events, prevContent.Events) { - newLevel, hasNewLevel := content.Events[eventType] - if !hasNewLevel { - // TODO this doesn't handle state events properly - newLevel = content.EventsDefault - } - if change := makePLChange(prevContent.Events[eventType], newLevel, hasNewLevel); change != nil { - plChange.Events[eventType] = change - } - } - for user := range getUniqueKeys(content.Users, prevContent.Users) { - _, hasNewLevel := content.Users[user] - change := makePLChange(prevContent.GetUserLevel(user), content.GetUserLevel(user), hasNewLevel) - if change == nil { - continue - } - target, err := portal.getTargetUser(ctx, user) - if err != nil { - log.Err(err).Stringer("target_user_id", user).Msg("Failed to get user for power level change") - } else { - plChange.Users[user] = &UserPowerLevelChange{ - Target: target, - SinglePowerLevelChange: *change, - } - } - } - _, err := api.HandleMatrixPowerLevels(ctx, plChange) - if err != nil { - log.Err(err).Msg("Failed to handle Matrix power level change") - return EventHandlingResultFailed.WithMSSError(err) - } - return EventHandlingResultSuccess.WithMSS() -} - -func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { - if evt.StateKey == nil || *evt.StateKey != "" || portal.MXID != evt.RoomID { - return EventHandlingResultIgnored - } - log := *zerolog.Ctx(ctx) - sentByBridge := evt.Sender == portal.Bridge.Bot.GetMXID() || portal.Bridge.IsGhostMXID(evt.Sender) - var senderUser *User - var err error - if !sentByBridge { - senderUser, err = portal.Bridge.GetUserByMXID(ctx, evt.Sender) - if err != nil { - log.Err(err).Msg("Failed to get tombstone sender user") - return EventHandlingResultFailed.WithError(err) - } - } - content, ok := evt.Content.Parsed.(*event.TombstoneEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - log = log.With(). - Stringer("replacement_room", content.ReplacementRoom). - Logger() - if content.ReplacementRoom == "" { - log.Info().Msg("Received tombstone with no replacement room, cleaning up portal") - err := portal.RemoveMXID(ctx) - if err != nil { - log.Err(err).Msg("Failed to remove portal MXID") - return EventHandlingResultFailed.WithMSSError(err) - } - err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true) - if err != nil { - log.Err(err).Msg("Failed to clean up Matrix room") - return EventHandlingResultFailed.WithError(err) - } - return EventHandlingResultSuccess - } - existingMemberEvt, err := portal.Bridge.Matrix.GetMemberInfo(ctx, content.ReplacementRoom, portal.Bridge.Bot.GetMXID()) - if err != nil { - log.Err(err).Msg("Failed to get member info of bot in replacement room") - return EventHandlingResultFailed.WithError(err) - } - leaveOnError := func() { - if existingMemberEvt != nil && existingMemberEvt.Membership == event.MembershipJoin { - return - } - log.Debug().Msg("Leaving replacement room with bot after tombstone validation failed") - _, err = portal.Bridge.Bot.SendState( - ctx, - content.ReplacementRoom, - event.StateMember, - portal.Bridge.Bot.GetMXID().String(), - &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: fmt.Sprintf("Failed to validate tombstone sent by %s from %s", evt.Sender, evt.RoomID), - }, - }, - time.Time{}, - ) - if err != nil { - log.Err(err).Msg("Failed to leave replacement room after tombstone validation failed") - } - } - var via []string - if senderHS := evt.Sender.Homeserver(); senderHS != "" { - via = []string{senderHS} - } - err = portal.Bridge.Bot.EnsureJoined(ctx, content.ReplacementRoom, EnsureJoinedParams{Via: via}) - if err != nil { - log.Err(err).Msg("Failed to join replacement room from tombstone") - return EventHandlingResultFailed.WithError(err) - } - if !sentByBridge && !senderUser.Permissions.Admin { - powers, err := portal.Bridge.Matrix.GetPowerLevels(ctx, content.ReplacementRoom) - if err != nil { - log.Err(err).Msg("Failed to get power levels in replacement room") - leaveOnError() - return EventHandlingResultFailed.WithError(err) - } - if powers.GetUserLevel(evt.Sender) < powers.Invite() { - log.Warn().Msg("Tombstone sender doesn't have enough power to invite the bot to the replacement room") - leaveOnError() - return EventHandlingResultIgnored - } - } - err = portal.UpdateMatrixRoomID(ctx, content.ReplacementRoom, UpdateMatrixRoomIDParams{ - DeleteOldRoom: true, - FetchInfoVia: senderUser, - }) - if errors.Is(err, ErrTargetRoomIsPortal) { - return EventHandlingResultIgnored - } else if err != nil { - return EventHandlingResultFailed.WithError(err) - } - return EventHandlingResultSuccess -} - -var ErrTargetRoomIsPortal = errors.New("target room is already a portal") -var ErrRoomAlreadyExists = errors.New("this portal already has a room") - -type UpdateMatrixRoomIDParams struct { - SyncDBMetadata func() - FailIfMXIDSet bool - OverwriteOldPortal bool - TombstoneOldRoom bool - DeleteOldRoom bool - - RoomCreateAlreadyLocked bool - - FetchInfoVia *User - ChatInfo *ChatInfo - ChatInfoSource *UserLogin -} - -func (portal *Portal) UpdateMatrixRoomID( - ctx context.Context, - newRoomID id.RoomID, - params UpdateMatrixRoomIDParams, -) error { - if !params.RoomCreateAlreadyLocked { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - } - oldRoom := portal.MXID - if oldRoom == newRoomID { - return nil - } else if oldRoom != "" && params.FailIfMXIDSet { - return ErrRoomAlreadyExists - } - err := portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update in_space flag for user portals before updating portal MXID") - } - portal.removeInPortalCache(ctx) - log := zerolog.Ctx(ctx) - portal.Bridge.cacheLock.Lock() - // Wrap unlock in a sync.OnceFunc because we want to both defer it to catch early returns - // and unlock it before return if nothing goes wrong. - unlockCacheLock := sync.OnceFunc(portal.Bridge.cacheLock.Unlock) - defer unlockCacheLock() - if existingPortal, alreadyExists := portal.Bridge.portalsByMXID[newRoomID]; alreadyExists && !params.OverwriteOldPortal { - log.Warn().Msg("Replacement room is already a portal, ignoring") - return ErrTargetRoomIsPortal - } else if alreadyExists { - log.Debug().Msg("Replacement room is already a portal, overwriting") - err = existingPortal.removeMXID(ctx, true) - if err != nil { - return fmt.Errorf("failed to clear mxid of existing portal: %w", err) - } - } - portal.MXID = newRoomID - portal.RoomCreated.Set() - portal.Bridge.portalsByMXID[portal.MXID] = portal - if oldRoom != "" && portal.Bridge.portalsByMXID[oldRoom] == portal { - delete(portal.Bridge.portalsByMXID, oldRoom) - } - portal.NameSet = false - portal.AvatarSet = false - portal.TopicSet = false - portal.InSpace = false - portal.CapState = database.CapabilityState{} - portal.lastCapUpdate = time.Time{} - if params.SyncDBMetadata != nil { - params.SyncDBMetadata() - } - unlockCacheLock() - portal.updateLogger() - - err = portal.Save(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in UpdateMatrixRoomID") - return err - } - log.Info().Msg("Successfully followed tombstone and updated portal MXID") - err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to update in_space flag for user portals after updating portal MXID") - } - go portal.addToUserSpaces(ctx) - if params.FetchInfoVia != nil { - go portal.updateInfoAfterTombstone(ctx, params.FetchInfoVia) - } else if params.ChatInfo != nil { - go portal.UpdateInfo(ctx, params.ChatInfo, params.ChatInfoSource, nil, time.Time{}) - } else if params.ChatInfoSource != nil { - portal.UpdateCapabilities(ctx, params.ChatInfoSource, true) - portal.UpdateBridgeInfo(ctx) - } - go func() { - // TODO this might become unnecessary if UpdateInfo starts taking care of it - _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ - Parsed: &event.ElementFunctionalMembersContent{ - ServiceMembers: []id.UserID{portal.Bridge.Bot.GetMXID()}, - }, - }, time.Time{}) - if err != nil { - if err != nil { - log.Warn().Err(err).Msg("Failed to set service members in new room") - } - } - }() - if params.TombstoneOldRoom && oldRoom != "" { - _, err = portal.Bridge.Bot.SendState(ctx, oldRoom, event.StateTombstone, "", &event.Content{ - Parsed: &event.TombstoneEventContent{ - Body: "Room has been replaced.", - ReplacementRoom: newRoomID, - }, - }, time.Now()) - if err != nil { - log.Err(err).Msg("Failed to send tombstone event to old room") - } - } - if params.DeleteOldRoom && oldRoom != "" { - go func() { - err = portal.Bridge.Bot.DeleteRoom(ctx, oldRoom, true) - if err != nil { - log.Err(err).Msg("Failed to clean up old Matrix room after updating portal MXID") - } - }() - } - return nil -} - -func (portal *Portal) updateInfoAfterTombstone(ctx context.Context, senderUser *User) { - log := zerolog.Ctx(ctx) - logins, err := portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to get user logins in portal to sync info") - return - } - var preferredLogin *UserLogin - for _, login := range logins { - if !login.Client.IsLoggedIn() { - continue - } else if preferredLogin == nil { - preferredLogin = login - } else if senderUser != nil && login.User == senderUser { - preferredLogin = login - } - } - if preferredLogin == nil { - log.Warn().Msg("No logins found to sync info") - return - } - info, err := preferredLogin.Client.GetChatInfo(ctx, portal) - if err != nil { - log.Err(err).Msg("Failed to get chat info") - return - } - log.Info(). - Str("info_source_login", string(preferredLogin.ID)). - Msg("Fetched info to update portal after tombstone") - portal.UpdateInfo(ctx, info, preferredLogin, nil, time.Time{}) -} - -func (portal *Portal) handleMatrixRedaction( - ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, -) EventHandlingResult { - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(*event.RedactionEventContent) - if !ok { - log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) - } - if evt.Redacts != "" && content.Redacts != evt.Redacts { - content.Redacts = evt.Redacts - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("redaction_target_mxid", content.Redacts) - }) - deletingAPI, deleteOK := sender.Client.(RedactionHandlingNetworkAPI) - reactingAPI, reactOK := sender.Client.(ReactionHandlingNetworkAPI) - if !deleteOK && !reactOK { - log.Debug().Msg("Ignoring redaction without checking target as network connector doesn't implement RedactionHandlingNetworkAPI nor ReactionHandlingNetworkAPI") - return EventHandlingResultIgnored.WithMSSError(ErrRedactionsNotSupported) - } - var redactionTargetReaction *database.Reaction - redactionTargetMsg, err := portal.Bridge.DB.Message.GetPartByMXID(ctx, content.Redacts) - if err != nil { - log.Err(err).Msg("Failed to get redaction target message from database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get redaction target message: %w", ErrDatabaseError, err)) - } else if redactionTargetMsg != nil { - if !deleteOK { - log.Debug().Msg("Ignoring message redaction event as network connector doesn't implement RedactionHandlingNetworkAPI") - return EventHandlingResultIgnored.WithMSSError(ErrRedactionsNotSupported) - } - err = deletingAPI.HandleMatrixMessageRemove(ctx, &MatrixMessageRemove{ - MatrixEventBase: MatrixEventBase[*event.RedactionEventContent]{ - Event: evt, - Content: content, - Portal: portal, - OrigSender: origSender, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - TargetMessage: redactionTargetMsg, - }) - } else if redactionTargetReaction, err = portal.Bridge.DB.Reaction.GetByMXID(ctx, content.Redacts); err != nil { - log.Err(err).Msg("Failed to get redaction target reaction from database") - return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: failed to get redaction target message reaction: %w", ErrDatabaseError, err)) - } else if redactionTargetReaction != nil { - if !reactOK { - log.Debug().Msg("Ignoring reaction redaction event as network connector doesn't implement ReactionHandlingNetworkAPI") - return EventHandlingResultIgnored.WithMSSError(ErrReactionsNotSupported) - } - // TODO ignore if sender doesn't match? - err = reactingAPI.HandleMatrixReactionRemove(ctx, &MatrixReactionRemove{ - MatrixEventBase: MatrixEventBase[*event.RedactionEventContent]{ - Event: evt, - Content: content, - Portal: portal, - OrigSender: origSender, - - InputTransactionID: portal.parseInputTransactionID(origSender, evt), - }, - TargetReaction: redactionTargetReaction, - }) - } else { - log.Debug().Msg("Redaction target message not found in database") - return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("redaction %w", ErrTargetMessageNotFound)) - } - if err != nil { - log.Err(err).Msg("Failed to handle Matrix redaction") - return EventHandlingResultFailed.WithMSSError(err) - } - if redactionTargetMsg != nil { - err = portal.Bridge.DB.Message.Delete(ctx, redactionTargetMsg.RowID) - } else if redactionTargetReaction != nil { - err = portal.Bridge.DB.Reaction.Delete(ctx, redactionTargetReaction) - } - if err != nil { - log.Err(err).Msg("Failed to delete redacted event from database") - } - return EventHandlingResultSuccess.WithMSS() -} - -func (portal *Portal) handleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { - log := zerolog.Ctx(ctx) - if portal.MXID == "" { - mcp, ok := evt.(RemoteEventThatMayCreatePortal) - if !ok || !mcp.ShouldCreatePortal() || !portal.Bridge.Config.PortalCreateFilter.ShouldAllow(source.ID, portal.PortalKey) { - log.Debug().Msg("Dropping event as portal doesn't exist") - return EventHandlingResultIgnored - } - infoProvider, ok := mcp.(RemoteChatResyncWithInfo) - var info *ChatInfo - var err error - if ok { - info, err = infoProvider.GetChatInfo(ctx, portal) - if err != nil { - log.Err(err).Msg("Failed to get chat info for portal creation from chat resync event") - } - } - bundleProvider, ok := evt.(RemoteChatResyncBackfillBundle) - var bundle any - if ok { - bundle = bundleProvider.GetBundledBackfillData() - } - err = portal.createMatrixRoomInLoop(ctx, source, info, bundle) - if err != nil { - log.Err(err).Msg("Failed to create portal to handle event") - return EventHandlingResultFailed.WithError(err) - } - if evtType == RemoteEventChatResync { - log.Debug().Msg("Not handling chat resync event further as portal was created by it") - postHandler, ok := evt.(RemotePostHandler) - if ok { - postHandler.PostHandle(ctx, portal) - } - return EventHandlingResultSuccess - } - } - preHandler, ok := evt.(RemotePreHandler) - if ok { - preHandler.PreHandle(ctx, portal) - } - log.Debug().Msg("Handling remote event") - switch evtType { - case RemoteEventUnknown: - log.Debug().Msg("Ignoring remote event with type unknown") - res = EventHandlingResultIgnored - case RemoteEventMessage, RemoteEventMessageUpsert: - res = portal.handleRemoteMessage(ctx, source, evt.(RemoteMessage)) - case RemoteEventEdit: - res = portal.handleRemoteEdit(ctx, source, evt.(RemoteEdit)) - case RemoteEventReaction: - res = portal.handleRemoteReaction(ctx, source, evt.(RemoteReaction)) - case RemoteEventReactionRemove: - res = portal.handleRemoteReactionRemove(ctx, source, evt.(RemoteReactionRemove)) - case RemoteEventReactionSync: - res = portal.handleRemoteReactionSync(ctx, source, evt.(RemoteReactionSync)) - case RemoteEventMessageRemove: - res = portal.handleRemoteMessageRemove(ctx, source, evt.(RemoteMessageRemove)) - case RemoteEventReadReceipt: - res = portal.handleRemoteReadReceipt(ctx, source, evt.(RemoteReadReceipt)) - case RemoteEventMarkUnread: - res = portal.handleRemoteMarkUnread(ctx, source, evt.(RemoteMarkUnread)) - case RemoteEventDeliveryReceipt: - res = portal.handleRemoteDeliveryReceipt(ctx, source, evt.(RemoteDeliveryReceipt)) - case RemoteEventTyping: - res = portal.handleRemoteTyping(ctx, source, evt.(RemoteTyping)) - case RemoteEventChatInfoChange: - res = portal.handleRemoteChatInfoChange(ctx, source, evt.(RemoteChatInfoChange)) - case RemoteEventChatResync: - res = portal.handleRemoteChatResync(ctx, source, evt.(RemoteChatResync)) - case RemoteEventChatDelete: - res = portal.handleRemoteChatDelete(ctx, source, evt.(RemoteChatDelete)) - case RemoteEventBackfill: - res = portal.HandleRemoteBackfill(ctx, source, evt.(RemoteBackfill)) - default: - log.Warn().Msg("Got remote event with unknown type") - } - postHandler, ok := evt.(RemotePostHandler) - if ok { - postHandler.PostHandle(ctx, portal) - } - return -} - -func (portal *Portal) ensureFunctionalMember(ctx context.Context, ghost *Ghost) { - if !ghost.IsBot || portal.RoomType != database.RoomTypeDM || portal.OtherUserID == ghost.ID || portal.MXID == "" { - return - } - ars, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) - if !ok { - return - } - portal.functionalMembersLock.Lock() - defer portal.functionalMembersLock.Unlock() - var functionalMembers *event.ElementFunctionalMembersContent - if portal.functionalMembersCache != nil { - functionalMembers = portal.functionalMembersCache - } else { - evt, err := ars.GetStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "") - if err != nil && !errors.Is(err, mautrix.MNotFound) { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get functional members state event") - return - } - functionalMembers = &event.ElementFunctionalMembersContent{} - if evt != nil { - evtContent, ok := evt.Content.Parsed.(*event.ElementFunctionalMembersContent) - if ok && evtContent != nil { - functionalMembers = evtContent - } - } - } - // TODO what about non-double-puppeted user ghosts? - functionalMembers.Add(portal.Bridge.Bot.GetMXID()) - if functionalMembers.Add(ghost.Intent.GetMXID()) { - _, err := portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ - Parsed: functionalMembers, - }, time.Time{}) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update functional members state event") - return - } - } -} - -func (portal *Portal) getIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { - var ghost *Ghost - if !sender.IsFromMe && sender.ForceDMUser && portal.OtherUserID != "" && sender.Sender != portal.OtherUserID { - zerolog.Ctx(ctx).Warn(). - Str("original_id", string(sender.Sender)). - Str("default_other_user", string(portal.OtherUserID)). - Msg("Overriding event sender with primary other user in DM portal") - // Ensure the ghost row exists anyway to prevent foreign key errors when saving messages - // TODO it'd probably be better to override the sender in the saved message, but that's more effort - _, err = portal.Bridge.GetGhostByID(ctx, sender.Sender) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get ghost with original user ID") - return - } - sender.Sender = portal.OtherUserID - } - if sender.Sender != "" { - ghost, err = portal.Bridge.GetGhostByID(ctx, sender.Sender) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for message sender") - return - } - ghost.UpdateInfoIfNecessary(ctx, source, evtType) - portal.ensureFunctionalMember(ctx, ghost) - } - if sender.IsFromMe { - intent = source.User.DoublePuppet(ctx) - if intent != nil { - return - } - extraUserID = source.UserMXID - } else if sender.SenderLogin != "" && portal.Receiver == "" { - senderLogin := portal.Bridge.GetCachedUserLoginByID(sender.SenderLogin) - if senderLogin != nil { - intent = senderLogin.User.DoublePuppet(ctx) - if intent != nil { - return - } - extraUserID = senderLogin.UserMXID - } - } - if sender.Sender != "" && portal.Receiver == "" && otherLogins != nil { - for _, login := range otherLogins { - if login.Client.IsThisUser(ctx, sender.Sender) { - intent = login.User.DoublePuppet(ctx) - if intent != nil { - return - } - extraUserID = login.UserMXID - } - } - } - if ghost != nil { - intent = ghost.Intent - } - return -} - -func (portal *Portal) GetIntentFor(ctx context.Context, sender EventSender, source *UserLogin, evtType RemoteEventType) (MatrixAPI, bool) { - intent, _, err := portal.getIntentAndUserMXIDFor(ctx, sender, source, nil, evtType) - if err != nil { - return nil, false - } - if intent == nil { - // TODO this is very hacky - we should either insert an empty ghost row automatically - // (and not fetch it at runtime) or make the message sender column nullable. - portal.Bridge.GetGhostByID(ctx, "") - intent = portal.Bridge.Bot - if intent == nil { - panic(fmt.Errorf("bridge bot is nil")) - } - } - return intent, true -} - -func (portal *Portal) getRelationMeta( - ctx context.Context, - currentMsgID networkid.MessageID, - currentMsg *ConvertedMessage, - isBatchSend bool, -) (replyTo, threadRoot, prevThreadEvent *database.Message) { - log := zerolog.Ctx(ctx) - var err error - if currentMsg.ReplyTo != nil { - replyTo, err = portal.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, portal.Receiver, *currentMsg.ReplyTo) - if err != nil { - log.Err(err).Msg("Failed to get reply target message from database") - } else if replyTo == nil { - if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { - // This is somewhat evil - replyTo = &database.Message{ - MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, currentMsg.ReplyTo.MessageID, ptr.Val(currentMsg.ReplyTo.PartID)), - Room: currentMsg.ReplyToRoom, - SenderID: currentMsg.ReplyToUser, - } - if currentMsg.ReplyToLogin != "" && (portal.Receiver == "" || portal.Receiver == currentMsg.ReplyToLogin) { - userLogin, err := portal.Bridge.GetExistingUserLoginByID(ctx, currentMsg.ReplyToLogin) - if err != nil { - log.Err(err). - Str("reply_to_login", string(currentMsg.ReplyToLogin)). - Msg("Failed to get reply target user login") - } else if userLogin != nil { - replyTo.SenderMXID = userLogin.UserMXID - } - } else { - ghost, err := portal.Bridge.GetGhostByID(ctx, currentMsg.ReplyToUser) - if err != nil { - log.Err(err). - Str("reply_to_user_id", string(currentMsg.ReplyToUser)). - Msg("Failed to get reply target ghost") - } else { - replyTo.SenderMXID = ghost.Intent.GetMXID() - } - } - } else { - log.Warn().Any("reply_to", *currentMsg.ReplyTo).Msg("Reply target message not found in database") - } - } - } - if currentMsg.ThreadRoot != nil && *currentMsg.ThreadRoot != currentMsgID { - threadRoot, err = portal.Bridge.DB.Message.GetFirstThreadMessage(ctx, portal.PortalKey, *currentMsg.ThreadRoot) - if err != nil { - log.Err(err).Msg("Failed to get thread root message from database") - } else if threadRoot == nil { - if isBatchSend || portal.Bridge.Config.OutgoingMessageReID { - threadRoot = &database.Message{ - MXID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, *currentMsg.ThreadRoot, ""), - } - } else { - log.Warn().Str("thread_root", string(*currentMsg.ThreadRoot)).Msg("Thread root message not found in database") - } - } else if prevThreadEvent, err = portal.Bridge.DB.Message.GetLastThreadMessage(ctx, portal.PortalKey, *currentMsg.ThreadRoot); err != nil { - log.Err(err).Msg("Failed to get last thread message from database") - } - if prevThreadEvent == nil { - prevThreadEvent = ptr.Clone(threadRoot) - } - } - return -} - -func (portal *Portal) applyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { - if content.Mentions == nil { - content.Mentions = &event.Mentions{} - } - if threadRoot != nil && prevThreadEvent != nil { - content.GetRelatesTo().SetThread(threadRoot.MXID, prevThreadEvent.MXID) - } - if replyTo != nil { - crossRoom := !replyTo.Room.IsEmpty() && replyTo.Room != portal.PortalKey - if !crossRoom || portal.Bridge.Config.CrossRoomReplies { - content.GetRelatesTo().SetReplyTo(replyTo.MXID) - } - if crossRoom && portal.Bridge.Config.CrossRoomReplies { - targetPortal, err := portal.Bridge.GetExistingPortalByKey(ctx, replyTo.Room) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Object("target_portal_key", replyTo.Room). - Msg("Failed to get cross-room reply portal") - } else if targetPortal == nil || targetPortal.MXID == "" { - zerolog.Ctx(ctx).Warn(). - Object("target_portal_key", replyTo.Room). - Msg("Cross-room reply portal not found") - } else { - content.RelatesTo.InReplyTo.UnstableRoomID = targetPortal.MXID - } - } - content.Mentions.Add(replyTo.SenderMXID) - } -} - -func (portal *Portal) sendConvertedMessage( - ctx context.Context, - id networkid.MessageID, - intent MatrixAPI, - senderID networkid.UserID, - converted *ConvertedMessage, - ts time.Time, - streamOrder int64, - logContext func(*zerolog.Event) *zerolog.Event, -) ([]*database.Message, EventHandlingResult) { - if logContext == nil { - logContext = func(e *zerolog.Event) *zerolog.Event { - return e - } - } - log := zerolog.Ctx(ctx) - replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta( - ctx, id, converted, false, - ) - output := make([]*database.Message, 0, len(converted.Parts)) - var errorList []error - for i, part := range converted.Parts { - if ctx.Err() != nil { - errorList = append(errorList, ctx.Err()) - break - } - portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) - part.Content.BeeperDisappearingTimer = converted.Disappear.ToEventContent() - dbMessage := &database.Message{ - ID: id, - PartID: part.ID, - Room: portal.PortalKey, - SenderID: senderID, - SenderMXID: intent.GetMXID(), - Timestamp: ts, - ThreadRoot: ptr.Val(converted.ThreadRoot), - ReplyTo: ptr.Val(converted.ReplyTo), - Metadata: part.DBMetadata, - IsDoublePuppeted: intent.IsDoublePuppet(), - } - if part.DontBridge { - dbMessage.SetFakeMXID() - logContext(log.Debug()). - Stringer("event_id", dbMessage.MXID). - Str("part_id", string(part.ID)). - Msg("Not bridging message part with DontBridge flag to Matrix") - } else { - resp, err := intent.SendMessage(ctx, portal.MXID, part.Type, &event.Content{ - Parsed: part.Content, - Raw: part.Extra, - }, &MatrixSendExtra{ - Timestamp: ts, - MessageMeta: dbMessage, - StreamOrder: streamOrder, - PartIndex: i, - }) - if err != nil { - logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to send message part to Matrix") - errorList = append(errorList, fmt.Errorf("failed to send message part to Matrix: %w", err)) - continue - } - logContext(log.Debug()). - Stringer("event_id", resp.EventID). - Str("part_id", string(part.ID)). - Msg("Sent message part to Matrix") - dbMessage.MXID = resp.EventID - } - err := portal.Bridge.DB.Message.Insert(ctx, dbMessage) - if err != nil { - logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to save message part to database") - errorList = append(errorList, fmt.Errorf("%w: failed to save message part to database: %w", ErrDatabaseError, err)) - } - if converted.Disappear.Type != event.DisappearingTypeNone && !dbMessage.HasFakeMXID() { - if converted.Disappear.Type == event.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() { - converted.Disappear.DisappearAt = dbMessage.Timestamp.Add(converted.Disappear.Timer) - } - portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{ - RoomID: portal.MXID, - EventID: dbMessage.MXID, - Timestamp: dbMessage.Timestamp, - DisappearingSetting: converted.Disappear, - }) - } - if prevThreadEvent != nil && !dbMessage.HasFakeMXID() { - prevThreadEvent = dbMessage - } - output = append(output, dbMessage) - } - if len(errorList) > 0 { - return output, EventHandlingResultFailed.WithError(errors.Join(errorList...)) - } - return output, EventHandlingResultSuccess -} - -func (portal *Portal) checkPendingMessage(ctx context.Context, evt RemoteMessage) (bool, *database.Message) { - evtWithTxn, ok := evt.(RemoteMessageWithTransactionID) - if !ok { - return false, nil - } - txnID := evtWithTxn.GetTransactionID() - if txnID == "" { - return false, nil - } - portal.outgoingMessagesLock.Lock() - defer portal.outgoingMessagesLock.Unlock() - pending, ok := portal.outgoingMessages[txnID] - if !ok { - return false, nil - } else if pending.ignore { - return true, nil - } - delete(portal.outgoingMessages, txnID) - pending.db.ID = evt.GetID() - if pending.db.SenderID == "" { - pending.db.SenderID = evt.GetSender().Sender - } - evtWithTimestamp, ok := evt.(RemoteEventWithTimestamp) - if ok { - ts := evtWithTimestamp.GetTimestamp() - if !ts.IsZero() { - pending.db.Timestamp = ts - } - } - var statusErr error - saveMessage := true - if pending.handle != nil { - saveMessage, statusErr = pending.handle(evt, pending.db) - } - if saveMessage { - if portal.Bridge.Config.OutgoingMessageReID { - pending.db.MXID = portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, pending.db.ID, pending.db.PartID) - } - // Hack to ensure the ghost row exists - // TODO move to better place (like login) - portal.Bridge.GetGhostByID(ctx, pending.db.SenderID) - err := portal.Bridge.DB.Message.Insert(ctx, pending.db) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save message to database after receiving remote echo") - } - } - if !errors.Is(statusErr, ErrNoStatus) { - if statusErr != nil { - portal.sendErrorStatus(ctx, pending.evt, statusErr) - } else { - portal.sendSuccessStatus(ctx, pending.evt, getStreamOrder(evt), pending.evt.ID) - } - } - zerolog.Ctx(ctx).Debug().Stringer("event_id", pending.evt.ID).Msg("Received remote echo for message") - return true, pending.db -} - -func (portal *Portal) handleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) { - log := zerolog.Ctx(ctx) - intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageUpsert) - if !ok { - return - } - res, err := evt.HandleExisting(ctx, portal, intent, existing) - if err != nil { - log.Err(err).Msg("Failed to handle existing message in upsert event after receiving remote echo") - } else { - handleRes = EventHandlingResultSuccess - } - if res.SaveParts { - for _, part := range existing { - err = portal.Bridge.DB.Message.Update(ctx, part) - if err != nil { - log.Err(err).Str("part_id", string(part.PartID)).Msg("Failed to update message part in database") - handleRes = EventHandlingResultFailed.WithError(err) - } - } - } - if len(res.SubEvents) > 0 { - for _, subEvt := range res.SubEvents { - subType := subEvt.GetType() - log := portal.Log.With(). - Str("source_id", string(source.ID)). - Str("action", "handle remote subevent"). - Stringer("bridge_evt_type", subType). - Logger() - subRes := portal.handleRemoteEvent(log.WithContext(ctx), source, subType, subEvt) - if !subRes.Success { - handleRes.Success = false - } - } - } - continueHandling = res.ContinueMessageHandling - return -} - -func (portal *Portal) handleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) { - log := zerolog.Ctx(ctx) - upsertEvt, isUpsert := evt.(RemoteMessageUpsert) - isUpsert = isUpsert && evt.GetType() == RemoteEventMessageUpsert - if wasPending, dbMessage := portal.checkPendingMessage(ctx, evt); wasPending { - if isUpsert && dbMessage != nil { - res, _ = portal.handleRemoteUpsert(ctx, source, upsertEvt, []*database.Message{dbMessage}) - } else { - res = EventHandlingResultIgnored - } - return - } - existing, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, evt.GetID()) - if err != nil { - log.Err(err).Msg("Failed to check if message is a duplicate") - } else if len(existing) > 0 { - if isUpsert { - var continueHandling bool - res, continueHandling = portal.handleRemoteUpsert(ctx, source, upsertEvt, existing) - if continueHandling { - log.Debug().Msg("Upsert handler said to continue message handling normally") - } else { - return res - } - } else { - log.Debug().Stringer("existing_mxid", existing[0].MXID).Msg("Ignoring duplicate message") - return EventHandlingResultIgnored - } - } - intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessage) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - ts := getEventTS(evt) - converted, err := evt.ConvertMessage(ctx, portal, intent) - if err != nil { - if errors.Is(err, ErrIgnoringRemoteEvent) { - log.Debug().Err(err).Msg("Remote message handling was cancelled by convert function") - return EventHandlingResultIgnored - } else { - log.Err(err).Msg("Failed to convert remote message") - portal.sendRemoteErrorNotice(ctx, intent, err, ts, "message") - return EventHandlingResultFailed.WithError(err) - } - } - _, res = portal.sendConvertedMessage(ctx, evt.GetID(), intent, evt.GetSender().Sender, converted, ts, getStreamOrder(evt), nil) - if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { - err = intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) - if err != nil { - log.Warn().Err(err).Msg("Failed to send stop typing event after bridging message") - } - } - return -} - -func (portal *Portal) sendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { - resp, sendErr := intent.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ - Parsed: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("An error occurred while processing an incoming %s", evtTypeName), - Mentions: &event.Mentions{}, - }, - Raw: map[string]any{ - "fi.mau.bridge.internal_error": err.Error(), - }, - }, &MatrixSendExtra{ - Timestamp: ts, - }) - if sendErr != nil { - zerolog.Ctx(ctx).Err(sendErr).Msg("Failed to send error notice after remote event handling failed") - } else { - zerolog.Ctx(ctx).Debug().Stringer("event_id", resp.EventID).Msg("Sent error notice after remote event handling failed") - } -} - -func (portal *Portal) handleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult { - log := zerolog.Ctx(ctx) - var existing []*database.Message - if bundledEvt, ok := evt.(RemoteEventWithBundledParts); ok { - existing = bundledEvt.GetTargetDBMessage() - } - if existing == nil { - targetID := evt.GetTargetMessage() - var err error - existing, err = portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, targetID) - if err != nil { - log.Err(err).Msg("Failed to get edit target message") - return EventHandlingResultFailed.WithError(err) - } - } - if existing == nil { - log.Warn().Msg("Edit target message not found") - return EventHandlingResultIgnored - } - var intent MatrixAPI - if evt.GetSender().ForceEditOrigSender { - var err error - intent, err = portal.getIntentForMXID(ctx, existing[0].SenderMXID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get intent for edit message sender") - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } else if intent == nil { - zerolog.Ctx(ctx).Debug().Msg("Intent not found for edit message") - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - } else { - var ok bool - intent, ok = portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventEdit) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } else if intent.GetMXID() != existing[0].SenderMXID { - log.Warn(). - Stringer("edit_sender_mxid", intent.GetMXID()). - Stringer("original_sender_mxid", existing[0].SenderMXID). - Msg("Not bridging edit: sender doesn't match original message sender") - return EventHandlingResultIgnored - } - } - ts := getEventTS(evt) - converted, err := evt.ConvertEdit(ctx, portal, intent, existing) - if errors.Is(err, ErrIgnoringRemoteEvent) { - log.Debug().Err(err).Msg("Remote edit handling was cancelled by convert function") - return EventHandlingResultIgnored - } else if err != nil { - log.Err(err).Msg("Failed to convert remote edit") - portal.sendRemoteErrorNotice(ctx, intent, err, ts, "edit") - return EventHandlingResultFailed.WithError(err) - } - res := portal.sendConvertedEdit(ctx, existing[0].ID, evt.GetSender().Sender, converted, intent, ts, getStreamOrder(evt)) - if portal.currentlyTypingGhosts.Pop(intent.GetMXID()) { - err = intent.MarkTyping(ctx, portal.MXID, TypingTypeText, 0) - if err != nil { - log.Warn().Err(err).Msg("Failed to send stop typing event after bridging edit") - } - } - return res -} - -func (portal *Portal) sendConvertedEdit( - ctx context.Context, - targetID networkid.MessageID, - senderID networkid.UserID, - converted *ConvertedEdit, - intent MatrixAPI, - ts time.Time, - streamOrder int64, -) EventHandlingResult { - log := zerolog.Ctx(ctx) - var errorList []error - for i, part := range converted.ModifiedParts { - if part.Content.Mentions == nil { - part.Content.Mentions = &event.Mentions{} - } - overrideMXID := true - if part.Part.Room != portal.PortalKey { - part.Part.Room = portal.PortalKey - } else if !part.Part.HasFakeMXID() { - part.Content.SetEdit(part.Part.MXID) - overrideMXID = false - if part.NewMentions != nil { - part.Content.Mentions = part.NewMentions - } else { - part.Content.Mentions = &event.Mentions{} - } - } - if part.TopLevelExtra == nil { - part.TopLevelExtra = make(map[string]any) - } - if part.Extra != nil { - part.TopLevelExtra["m.new_content"] = part.Extra - } - wrappedContent := &event.Content{ - Parsed: part.Content, - Raw: part.TopLevelExtra, - } - if !part.DontBridge { - resp, err := intent.SendMessage(ctx, portal.MXID, part.Type, wrappedContent, &MatrixSendExtra{ - Timestamp: ts, - MessageMeta: part.Part, - StreamOrder: streamOrder, - PartIndex: i, - }) - if err != nil { - log.Err(err).Stringer("part_mxid", part.Part.MXID).Msg("Failed to edit message part") - errorList = append(errorList, fmt.Errorf("failed to edit message part: %w", err)) - continue - } else { - log.Debug(). - Stringer("event_id", resp.EventID). - Str("part_id", string(part.Part.ID)). - Msg("Sent message part edit to Matrix") - if overrideMXID { - part.Part.MXID = resp.EventID - } - } - } - err := portal.Bridge.DB.Message.Update(ctx, part.Part) - if err != nil { - log.Err(err).Int64("part_rowid", part.Part.RowID).Msg("Failed to update message part in database") - errorList = append(errorList, fmt.Errorf("%w: failed to update message part in database: %w", ErrDatabaseError, err)) - } - } - for _, part := range converted.DeletedParts { - redactContent := &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: part.MXID, - }, - } - resp, err := intent.SendMessage(ctx, portal.MXID, event.EventRedaction, redactContent, &MatrixSendExtra{ - Timestamp: ts, - }) - if err != nil { - log.Err(err).Stringer("part_mxid", part.MXID).Msg("Failed to redact message part deleted in edit") - errorList = append(errorList, fmt.Errorf("failed to redact message part deleted in edit: %w", err)) - } else { - log.Debug(). - Stringer("redaction_event_id", resp.EventID). - Stringer("redacted_event_id", part.MXID). - Str("part_id", string(part.ID)). - Msg("Sent redaction of message part to Matrix") - } - err = portal.Bridge.DB.Message.Delete(ctx, part.RowID) - if err != nil { - log.Err(err).Int64("part_rowid", part.RowID).Msg("Failed to delete message part from database") - errorList = append(errorList, fmt.Errorf("%w: failed to delete message part from database: %w", ErrDatabaseError, err)) - } - } - if converted.AddedParts != nil { - _, res := portal.sendConvertedMessage(ctx, targetID, intent, senderID, converted.AddedParts, ts, streamOrder, nil) - if !res.Success { - errorList = append(errorList, res.Error) - } - } - if len(errorList) > 0 { - return EventHandlingResultFailed.WithError(errors.Join(errorList...)) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) getTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) { - if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { - return portal.Bridge.DB.Message.GetPartByID(ctx, portal.Receiver, evt.GetTargetMessage(), partTargeter.GetTargetMessagePart()) - } else { - return portal.Bridge.DB.Message.GetFirstPartByID(ctx, portal.Receiver, evt.GetTargetMessage()) - } -} - -func (portal *Portal) getTargetReaction(ctx context.Context, evt RemoteReactionRemove) (*database.Reaction, error) { - if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { - return portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, evt.GetTargetMessage(), partTargeter.GetTargetMessagePart(), evt.GetSender().Sender, evt.GetRemovedEmojiID()) - } else { - return portal.Bridge.DB.Reaction.GetByIDWithoutMessagePart(ctx, portal.Receiver, evt.GetTargetMessage(), evt.GetSender().Sender, evt.GetRemovedEmojiID()) - } -} - -func getEventTS(evt RemoteEvent) time.Time { - if tsProvider, ok := evt.(RemoteEventWithTimestamp); ok { - return tsProvider.GetTimestamp() - } - return time.Now() -} - -func getStreamOrder(evt RemoteEvent) int64 { - if streamProvider, ok := evt.(RemoteEventWithStreamOrder); ok { - return streamProvider.GetStreamOrder() - } - return 0 -} - -func (portal *Portal) handleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult { - log := zerolog.Ctx(ctx) - eventTS := getEventTS(evt) - targetMessage, err := portal.getTargetMessagePart(ctx, evt) - if err != nil { - log.Err(err).Msg("Failed to get target message for reaction") - return EventHandlingResultFailed.WithError(err) - } else if targetMessage == nil { - // TODO use deterministic event ID as target if applicable? - log.Warn().Msg("Target message for reaction not found") - return EventHandlingResultIgnored - } - var existingReactions []*database.Reaction - if partTargeter, ok := evt.(RemoteEventWithTargetPart); ok { - existingReactions, err = portal.Bridge.DB.Reaction.GetAllToMessagePart(ctx, portal.Receiver, evt.GetTargetMessage(), partTargeter.GetTargetMessagePart()) - } else { - existingReactions, err = portal.Bridge.DB.Reaction.GetAllToMessage(ctx, portal.Receiver, evt.GetTargetMessage()) - } - if err != nil { - log.Err(err).Msg("Failed to get existing reactions for reaction sync") - return EventHandlingResultFailed.WithError(err) - } - existing := make(map[networkid.UserID]map[networkid.EmojiID]*database.Reaction) - for _, existingReaction := range existingReactions { - if existing[existingReaction.SenderID] == nil { - existing[existingReaction.SenderID] = make(map[networkid.EmojiID]*database.Reaction) - } - existing[existingReaction.SenderID][existingReaction.EmojiID] = existingReaction - } - - doAddReaction := func(new *BackfillReaction, intent MatrixAPI) { - if intent == nil { - var ok bool - intent, ok = portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) - if !ok { - return - } - } - portal.sendConvertedReaction( - ctx, new.Sender.Sender, intent, targetMessage, new.EmojiID, new.Emoji, - new.Timestamp, new.DBMetadata, new.ExtraContent, - func(z *zerolog.Event) *zerolog.Event { - return z. - Any("reaction_sender_id", new.Sender). - Time("reaction_ts", new.Timestamp) - }, - ) - } - doRemoveReaction := func(old *database.Reaction, intent MatrixAPI, deleteRow bool) { - if intent == nil && old.SenderMXID != "" { - intent, err = portal.getIntentForMXID(ctx, old.SenderMXID) - if err != nil { - log.Err(err). - Stringer("reaction_sender_mxid", old.SenderMXID). - Msg("Failed to get intent for removing reaction") - } - } - if intent == nil { - log.Warn(). - Str("reaction_sender_id", string(old.SenderID)). - Stringer("reaction_sender_mxid", old.SenderMXID). - Msg("Didn't find intent for removing reaction, using bridge bot") - intent = portal.Bridge.Bot - } - _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: old.MXID, - }, - }, &MatrixSendExtra{Timestamp: eventTS}) - if err != nil { - log.Err(err).Msg("Failed to redact old reaction") - } - if deleteRow { - err = portal.Bridge.DB.Reaction.Delete(ctx, old) - if err != nil { - log.Err(err).Msg("Failed to delete old reaction row") - } - } - } - doOverwriteReaction := func(new *BackfillReaction, old *database.Reaction) { - intent, ok := portal.GetIntentFor(ctx, new.Sender, source, RemoteEventReactionSync) - if !ok { - return - } - doRemoveReaction(old, intent, false) - doAddReaction(new, intent) - } - - newData := evt.GetReactions() - for userID, reactions := range newData.Users { - existingUserReactions := existing[userID] - delete(existing, userID) - for _, reaction := range reactions.Reactions { - if reaction.Timestamp.IsZero() { - reaction.Timestamp = eventTS - } - existingReaction, ok := existingUserReactions[reaction.EmojiID] - if ok { - delete(existingUserReactions, reaction.EmojiID) - if reaction.EmojiID != "" || reaction.Emoji == existingReaction.Emoji { - continue - } - doOverwriteReaction(reaction, existingReaction) - } else { - doAddReaction(reaction, nil) - } - } - totalReactionCount := len(existingUserReactions) + len(reactions.Reactions) - if reactions.HasAllReactions { - for _, existingReaction := range existingUserReactions { - doRemoveReaction(existingReaction, nil, true) - } - } else if reactions.MaxCount > 0 && totalReactionCount > reactions.MaxCount { - remainingReactionList := maps.Values(existingUserReactions) - slices.SortFunc(remainingReactionList, func(a, b *database.Reaction) int { - diff := a.Timestamp.Compare(b.Timestamp) - if diff == 0 { - return cmp.Compare(a.EmojiID, b.EmojiID) - } - return diff - }) - numberToRemove := totalReactionCount - reactions.MaxCount - for i := 0; i < numberToRemove && i < len(remainingReactionList); i++ { - doRemoveReaction(remainingReactionList[i], nil, true) - } - } - } - if newData.HasAllUsers { - for _, userReactions := range existing { - for _, existingReaction := range userReactions { - doRemoveReaction(existingReaction, nil, true) - } - } - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult { - log := zerolog.Ctx(ctx) - targetMessage, err := portal.getTargetMessagePart(ctx, evt) - if err != nil { - log.Err(err).Msg("Failed to get target message for reaction") - return EventHandlingResultFailed.WithError(err) - } else if targetMessage == nil { - // TODO use deterministic event ID as target if applicable? - log.Warn().Msg("Target message for reaction not found") - return EventHandlingResultIgnored - } - emoji, emojiID := evt.GetReactionEmoji() - existingReaction, err := portal.Bridge.DB.Reaction.GetByID(ctx, portal.Receiver, targetMessage.ID, targetMessage.PartID, evt.GetSender().Sender, emojiID) - if err != nil { - log.Err(err).Msg("Failed to check if reaction is a duplicate") - return EventHandlingResultFailed.WithError(err) - } else if existingReaction != nil && (emojiID != "" || existingReaction.Emoji == emoji) { - log.Debug().Msg("Ignoring duplicate reaction") - return EventHandlingResultIgnored - } - ts := getEventTS(evt) - intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReaction) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - var extra map[string]any - if extraContentProvider, ok := evt.(RemoteReactionWithExtraContent); ok { - extra = extraContentProvider.GetReactionExtraContent() - } - var dbMetadata any - if metaProvider, ok := evt.(RemoteReactionWithMeta); ok { - dbMetadata = metaProvider.GetReactionDBMetadata() - } - if existingReaction != nil { - _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: existingReaction.MXID, - }, - }, &MatrixSendExtra{Timestamp: ts}) - if err != nil { - log.Err(err).Msg("Failed to redact old reaction") - } - } - return portal.sendConvertedReaction(ctx, evt.GetSender().Sender, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extra, nil) -} - -func (portal *Portal) sendConvertedReaction( - ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, - emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, - logContext func(*zerolog.Event) *zerolog.Event, -) EventHandlingResult { - if logContext == nil { - logContext = func(e *zerolog.Event) *zerolog.Event { - return e - } - } - log := zerolog.Ctx(ctx) - dbReaction := &database.Reaction{ - Room: portal.PortalKey, - MessageID: targetMessage.ID, - MessagePartID: targetMessage.PartID, - SenderID: senderID, - SenderMXID: intent.GetMXID(), - EmojiID: emojiID, - Timestamp: ts, - Metadata: dbMetadata, - } - if emojiID == "" { - dbReaction.Emoji = emoji - } - resp, err := intent.SendMessage(ctx, portal.MXID, event.EventReaction, &event.Content{ - Parsed: &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: targetMessage.MXID, - Key: variationselector.Add(emoji), - }, - }, - Raw: extraContent, - }, &MatrixSendExtra{ - Timestamp: ts, - ReactionMeta: dbReaction, - }) - if err != nil { - logContext(log.Err(err)).Msg("Failed to send reaction to Matrix") - return EventHandlingResultFailed.WithError(err) - } - logContext(log.Debug()). - Stringer("event_id", resp.EventID). - Msg("Sent reaction to Matrix") - dbReaction.MXID = resp.EventID - err = portal.Bridge.DB.Reaction.Upsert(ctx, dbReaction) - if err != nil { - logContext(log.Err(err)).Msg("Failed to save reaction to database") - return EventHandlingResultFailed.WithError(err) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) getIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) { - if userID == "" { - return nil, nil - } else if userID == portal.Bridge.Bot.GetMXID() { - return portal.Bridge.Bot, nil - } else if ghost, err := portal.Bridge.GetGhostByMXID(ctx, userID); err != nil { - return nil, fmt.Errorf("failed to get ghost: %w", err) - } else if ghost != nil { - return ghost.Intent, nil - } else if user, err := portal.Bridge.GetExistingUserByMXID(ctx, userID); err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } else if user != nil { - return user.DoublePuppet(ctx), nil - } else { - return nil, nil - } -} - -func (portal *Portal) handleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult { - log := zerolog.Ctx(ctx) - targetReaction, err := portal.getTargetReaction(ctx, evt) - if err != nil { - log.Err(err).Msg("Failed to get target reaction for removal") - return EventHandlingResultFailed.WithError(err) - } else if targetReaction == nil { - log.Warn().Msg("Target reaction not found") - return EventHandlingResultIgnored - } - intent, err := portal.getIntentForMXID(ctx, targetReaction.SenderMXID) - if err != nil { - log.Err(err).Stringer("sender_mxid", targetReaction.SenderMXID).Msg("Failed to get intent for removing reaction") - } - if intent == nil { - var ok bool - intent, ok = portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventReactionRemove) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - } - ts := getEventTS(evt) - _, err = intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: targetReaction.MXID, - }, - }, &MatrixSendExtra{Timestamp: ts, ReactionMeta: targetReaction}) - if err != nil { - log.Err(err).Stringer("reaction_mxid", targetReaction.MXID).Msg("Failed to redact reaction") - return EventHandlingResultFailed.WithError(err) - } - err = portal.Bridge.DB.Reaction.Delete(ctx, targetReaction) - if err != nil { - log.Err(err).Msg("Failed to delete target reaction from database") - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult { - log := zerolog.Ctx(ctx) - targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, evt.GetTargetMessage()) - if err != nil { - log.Err(err).Msg("Failed to get target message for removal") - return EventHandlingResultFailed.WithError(err) - } else if len(targetParts) == 0 { - log.Debug().Msg("Target message not found") - return EventHandlingResultIgnored - } - onlyForMeProvider, ok := evt.(RemoteDeleteOnlyForMe) - onlyForMe := ok && onlyForMeProvider.DeleteOnlyForMe() - if onlyForMe && portal.Receiver == "" { - _, others, err := portal.findOtherLogins(ctx, source) - if err != nil { - log.Err(err).Msg("Failed to check if portal has other logins") - return EventHandlingResultFailed.WithError(err) - } else if len(others) > 0 { - log.Debug().Msg("Ignoring delete for me event in portal with multiple logins") - return EventHandlingResultIgnored - } - } - - intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventMessageRemove) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - if intent == portal.Bridge.Bot && len(targetParts) > 0 { - senderIntent, err := portal.getIntentForMXID(ctx, targetParts[0].SenderMXID) - if err != nil { - log.Err(err).Stringer("sender_mxid", targetParts[0].SenderMXID).Msg("Failed to get intent for removing message") - } else if senderIntent != nil { - intent = senderIntent - } - } - dontRenderPlaceholderProvider, ok := evt.(RemoteMessageRemoveWithoutPlaceholder) - dontRenderPlaceholder := ok && dontRenderPlaceholderProvider.DontRenderPlaceholder() - res := portal.redactMessageParts(ctx, targetParts, intent, getEventTS(evt), "", dontRenderPlaceholder) - err = portal.Bridge.DB.Message.DeleteAllParts(ctx, portal.Receiver, evt.GetTargetMessage()) - if err != nil { - log.Err(err).Msg("Failed to delete target message from database") - } - return res -} - -func (portal *Portal) redactMessageParts( - ctx context.Context, - parts []*database.Message, - intent MatrixAPI, - ts time.Time, - reason string, - dontRenderPlaceholder bool, -) EventHandlingResult { - log := zerolog.Ctx(ctx) - var errorList []error - for _, part := range parts { - if part.HasFakeMXID() { - continue - } - resp, err := intent.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{ - Parsed: &event.RedactionEventContent{ - Redacts: part.MXID, - Reason: reason, - DontRenderPlaceholder: dontRenderPlaceholder, - }, - }, &MatrixSendExtra{Timestamp: ts, MessageMeta: part}) - if err != nil { - log.Err(err).Stringer("part_mxid", part.MXID).Msg("Failed to redact message part") - errorList = append(errorList, err) - } else { - log.Debug(). - Stringer("redaction_event_id", resp.EventID). - Stringer("redacted_event_id", part.MXID). - Str("part_id", string(part.ID)). - Msg("Sent redaction of message part to Matrix") - } - } - if len(errorList) > 0 { - return EventHandlingResultFailed.WithError(errors.Join(errorList...)) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult { - log := zerolog.Ctx(ctx) - var err error - var lastTarget *database.Message - readUpTo := evt.GetReadUpTo() - if lastTargetID := evt.GetLastReceiptTarget(); lastTargetID != "" { - lastTarget, err = portal.Bridge.DB.Message.GetLastPartByID(ctx, portal.Receiver, lastTargetID) - if err != nil { - log.Err(err).Str("last_target_id", string(lastTargetID)). - Msg("Failed to get last target message for read receipt") - return EventHandlingResultFailed.WithError(err) - } else if lastTarget == nil { - log.Debug().Str("last_target_id", string(lastTargetID)). - Msg("Last target message not found") - } else if lastTarget.HasFakeMXID() { - log.Debug().Str("last_target_id", string(lastTargetID)). - Msg("Last target message is fake") - if readUpTo.IsZero() { - readUpTo = lastTarget.Timestamp - } - lastTarget = nil - } - } - if lastTarget == nil { - for _, targetID := range evt.GetReceiptTargets() { - target, err := portal.Bridge.DB.Message.GetLastPartByID(ctx, portal.Receiver, targetID) - if err != nil { - log.Err(err).Str("target_id", string(targetID)). - Msg("Failed to get target message for read receipt") - return EventHandlingResultFailed.WithError(err) - } else if target != nil && !target.HasFakeMXID() && (lastTarget == nil || target.Timestamp.After(lastTarget.Timestamp)) { - lastTarget = target - } - } - } - if lastTarget == nil && !readUpTo.IsZero() { - lastTarget, err = portal.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime(ctx, portal.PortalKey, readUpTo) - if err != nil { - log.Err(err).Time("read_up_to", readUpTo).Msg("Failed to get target message for read receipt") - } - } - sender := evt.GetSender() - intent, ok := portal.GetIntentFor(ctx, sender, source, RemoteEventReadReceipt) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - var addTargetLog func(evt *zerolog.Event) *zerolog.Event - if lastTarget == nil { - sevt, evtOK := evt.(RemoteReadReceiptWithStreamOrder) - soIntent, soIntentOK := intent.(StreamOrderReadingMatrixAPI) - if !evtOK || !soIntentOK || sevt.GetReadUpToStreamOrder() == 0 { - log.Warn().Msg("No target message found for read receipt") - return EventHandlingResultIgnored - } - targetStreamOrder := sevt.GetReadUpToStreamOrder() - addTargetLog = func(evt *zerolog.Event) *zerolog.Event { - return evt.Int64("target_stream_order", targetStreamOrder) - } - err = soIntent.MarkStreamOrderRead(ctx, portal.MXID, targetStreamOrder, getEventTS(evt)) - if readUpTo.IsZero() { - readUpTo = getEventTS(evt) - } - } else { - addTargetLog = func(evt *zerolog.Event) *zerolog.Event { - return evt.Stringer("target_mxid", lastTarget.MXID) - } - err = intent.MarkRead(ctx, portal.MXID, lastTarget.MXID, getEventTS(evt)) - readUpTo = lastTarget.Timestamp - } - if err != nil { - addTargetLog(log.Err(err)).Msg("Failed to bridge read receipt") - return EventHandlingResultFailed.WithError(err) - } else { - addTargetLog(log.Debug()).Msg("Bridged read receipt") - } - if sender.IsFromMe { - portal.Bridge.DisappearLoop.StartAllBefore(ctx, portal.MXID, readUpTo) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult { - if !evt.GetSender().IsFromMe { - zerolog.Ctx(ctx).Warn().Msg("Ignoring mark unread event from non-self user") - return EventHandlingResultIgnored - } - dp := source.User.DoublePuppet(ctx) - if dp == nil { - return EventHandlingResultIgnored - } - err := dp.MarkUnread(ctx, portal.MXID, evt.GetUnread()) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge mark unread event") - return EventHandlingResultFailed.WithError(err) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { - if portal.RoomType != database.RoomTypeDM || (evt.GetSender().Sender != portal.OtherUserID && portal.OtherUserID != "") { - return EventHandlingResultIgnored - } - intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventDeliveryReceipt) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - log := zerolog.Ctx(ctx) - for _, target := range evt.GetReceiptTargets() { - targetParts, err := portal.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, target) - if err != nil { - log.Err(err).Str("target_id", string(target)).Msg("Failed to get target message for delivery receipt") - return EventHandlingResultFailed.WithError(err) - } else if len(targetParts) == 0 { - continue - } else if _, sentByGhost := portal.Bridge.Matrix.ParseGhostMXID(targetParts[0].SenderMXID); sentByGhost { - continue - } - for _, part := range targetParts { - portal.Bridge.Matrix.SendMessageStatus(ctx, &MessageStatus{ - Status: event.MessageStatusSuccess, - DeliveredTo: []id.UserID{intent.GetMXID()}, - }, &MessageStatusEventInfo{ - RoomID: portal.MXID, - SourceEventID: part.MXID, - Sender: part.SenderMXID, - - IsSourceEventDoublePuppeted: part.IsDoublePuppeted, - }) - } - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult { - var typingType TypingType - if typedEvt, ok := evt.(RemoteTypingWithType); ok { - typingType = typedEvt.GetTypingType() - } - intent, ok := portal.GetIntentFor(ctx, evt.GetSender(), source, RemoteEventTyping) - if !ok { - return EventHandlingResultFailed.WithError(ErrFailedToGetIntent) - } - timeout := evt.GetTimeout() - err := intent.MarkTyping(ctx, portal.MXID, typingType, timeout) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge typing event") - return EventHandlingResultFailed.WithError(err) - } - if timeout == 0 { - portal.currentlyTypingGhosts.Remove(intent.GetMXID()) - } else { - portal.currentlyTypingGhosts.Add(intent.GetMXID()) - } - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult { - info, err := evt.GetChatInfoChange(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get chat info change") - return EventHandlingResultFailed.WithError(err) - } - portal.ProcessChatInfoChange(ctx, evt.GetSender(), source, info, getEventTS(evt)) - return EventHandlingResultSuccess -} - -func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult { - log := zerolog.Ctx(ctx) - infoProvider, ok := evt.(RemoteChatResyncWithInfo) - if ok { - info, err := infoProvider.GetChatInfo(ctx, portal) - if err != nil { - log.Err(err).Msg("Failed to get chat info from resync event") - } else if info != nil { - portal.UpdateInfo(ctx, info, source, nil, time.Time{}) - } else { - log.Debug().Msg("No chat info provided in resync event") - } - } - backfillChecker, ok := evt.(RemoteChatResyncBackfill) - if portal.Bridge.Config.Backfill.Enabled && ok && portal.RoomType != database.RoomTypeSpace { - latestMessage, err := portal.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, portal.PortalKey, time.Now().Add(10*time.Second)) - if err != nil { - log.Err(err).Msg("Failed to get last message in portal to check if backfill is necessary") - } else if needsBackfill, err := backfillChecker.CheckNeedsBackfill(ctx, latestMessage); err != nil { - log.Err(err).Msg("Failed to check if backfill is needed") - } else if needsBackfill { - bundleProvider, ok := evt.(RemoteChatResyncBackfillBundle) - var bundle any - if ok { - bundle = bundleProvider.GetBundledBackfillData() - } - portal.doForwardBackfill(ctx, source, latestMessage, bundle) - } - } - return EventHandlingResultSuccess -} - -func (portal *Portal) findOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { - others, err = portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) - if err != nil { - return - } - others = slices.DeleteFunc(others, func(up *database.UserPortal) bool { - if up.LoginID == source.ID { - ownUP = up - return true - } - return false - }) - return -} - -type childDeleteProxy struct { - RemoteChatDeleteWithChildren - child networkid.PortalKey - done func() -} - -func (cdp *childDeleteProxy) AddLogContext(c zerolog.Context) zerolog.Context { - return cdp.RemoteChatDeleteWithChildren.AddLogContext(c).Str("subaction", "delete children") -} -func (cdp *childDeleteProxy) GetPortalKey() networkid.PortalKey { return cdp.child } -func (cdp *childDeleteProxy) ShouldCreatePortal() bool { return false } -func (cdp *childDeleteProxy) PreHandle(ctx context.Context, portal *Portal) {} -func (cdp *childDeleteProxy) PostHandle(ctx context.Context, portal *Portal) { cdp.done() } - -func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { - log := zerolog.Ctx(ctx) - if portal.Receiver == "" && evt.DeleteOnlyForMe() { - ownUP, logins, err := portal.findOtherLogins(ctx, source) - if err != nil { - log.Err(err).Msg("Failed to check if portal has other logins") - return EventHandlingResultFailed.WithError(err) - } - if len(logins) > 0 { - log.Debug().Msg("Not deleting portal with other logins in remote chat delete event") - if ownUP != nil { - err = portal.Bridge.DB.UserPortal.Delete(ctx, ownUP) - if err != nil { - log.Err(err).Msg("Failed to delete own user portal row from database") - } else { - log.Debug().Msg("Deleted own user portal row from database") - } - } - _, err = portal.sendStateWithIntentOrBot( - ctx, - source.User.DoublePuppet(ctx), - event.StateMember, - source.UserMXID.String(), - &event.Content{Parsed: &event.MemberEventContent{Membership: event.MembershipLeave}}, - getEventTS(evt), - ) - if err != nil { - log.Err(err).Msg("Failed to send leave state event for user after remote chat delete") - return EventHandlingResultFailed.WithError(err) - } else { - log.Debug().Msg("Sent leave state event for user after remote chat delete") - return EventHandlingResultSuccess - } - } - } - if childDeleter, ok := evt.(RemoteChatDeleteWithChildren); ok && childDeleter.DeleteChildren() && portal.RoomType == database.RoomTypeSpace { - children, err := portal.Bridge.GetChildPortals(ctx, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to fetch children to delete") - return EventHandlingResultFailed.WithError(err) - } - log.Debug(). - Int("portal_count", len(children)). - Msg("Deleting child portals before remote chat delete") - var wg sync.WaitGroup - wg.Add(len(children)) - for _, child := range children { - child.queueEvent(ctx, &portalRemoteEvent{ - evt: &childDeleteProxy{ - RemoteChatDeleteWithChildren: childDeleter, - child: child.PortalKey, - done: wg.Done, - }, - source: source, - evtType: RemoteEventChatDelete, - }) - } - wg.Wait() - log.Debug().Msg("Finished deleting child portals") - } - err := portal.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete portal from database") - return EventHandlingResultFailed.WithError(err) - } - // The event context has likely been canceled by delete, so use a background context for the delete call - noCancelCtx := log.WithContext(portal.Bridge.BackgroundCtx) - err = portal.Bridge.Bot.DeleteRoom(noCancelCtx, portal.MXID, false) - if err != nil { - log.Err(err).Msg("Failed to delete Matrix room") - return EventHandlingResultFailed.WithError(err) - } else { - log.Info().Msg("Deleted room after remote chat delete event") - return EventHandlingResultSuccess - } -} - -func (portal *Portal) HandleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) EventHandlingResult { - if !portal.Bridge.Config.Backfill.Enabled { - zerolog.Ctx(ctx).Debug().Msg("Ignoring async backfill event because backfill is disabled") - return EventHandlingResultIgnored - } - data, err := backfill.GetBackfillData(ctx, portal) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get backfill data") - return EventHandlingResultFailed.WithError(err) - } - portal.Bridge.WakeupBackfillQueue(&ManualBackfill{Source: source, Portal: portal, Data: data}) - return EventHandlingResultSuccess -} - -type ChatInfoChange struct { - // The chat info that changed. Any fields that did not change can be left as nil. - ChatInfo *ChatInfo - // A list of member changes. - // This list should only include changes, not the whole member list. - // To resync the whole list, use the field inside ChatInfo. - MemberChanges *ChatMemberList -} - -func (portal *Portal) ProcessChatInfoChange(ctx context.Context, sender EventSender, source *UserLogin, change *ChatInfoChange, ts time.Time) { - intent, ok := portal.GetIntentFor(ctx, sender, source, RemoteEventChatInfoChange) - if !ok { - return - } - if change.ChatInfo != nil { - portal.UpdateInfo(ctx, change.ChatInfo, source, intent, ts) - } - if change.MemberChanges != nil { - err := portal.syncParticipants(ctx, change.MemberChanges, source, intent, ts) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to sync room members") - } - } -} - -// Deprecated: Renamed to ChatInfo -type PortalInfo = ChatInfo - -type ChatMember struct { - EventSender - Membership event.Membership - // Per-room nickname for the user. Not yet used. - Nickname *string - // The power level to set for the user when syncing power levels. - PowerLevel *int - // Optional user info to sync the ghost user while updating membership. - UserInfo *UserInfo - // The user who sent the membership change (user who invited/kicked/banned this user). - // Not yet used. Not applicable if Membership is join or knock. - MemberSender EventSender - // Extra fields to include in the member event. - MemberEventExtra map[string]any - // The expected previous membership. If this doesn't match, the change is ignored. - PrevMembership event.Membership -} - -type ChatMemberMap map[networkid.UserID]ChatMember - -// Set adds the given entry to this map, overwriting any existing entry with the same Sender field. -func (cmm ChatMemberMap) Set(member ChatMember) ChatMemberMap { - if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { - return cmm - } - cmm[member.Sender] = member - return cmm -} - -// Add adds the given entry to this map, but will ignore it if an entry with the same Sender field already exists. -// It returns true if the entry was added, false otherwise. -func (cmm ChatMemberMap) Add(member ChatMember) bool { - if member.Sender == "" && member.SenderLogin == "" && !member.IsFromMe { - return false - } - if _, exists := cmm[member.Sender]; exists { - return false - } - cmm[member.Sender] = member - return true -} - -type ChatMemberList struct { - // Whether this is the full member list. - // If true, any extra members not listed here will be removed from the portal. - IsFull bool - // Should the bridge call IsThisUser for every member in the list? - // This should be used when SenderLogin can't be filled accurately. - CheckAllLogins bool - // Should any changes have the `com.beeper.exclude_from_timeline` flag set by default? - // This is recommended for syncs with non-real-time changes. - // Real-time changes (e.g. a user joining) should not set this flag set. - ExcludeChangesFromTimeline bool - - // The total number of members in the chat, regardless of how many of those members are included in MemberMap. - TotalMemberCount int - - // For DM portals, the ID of the recipient user. - // This field is optional and will be automatically filled from MemberMap if there are only 2 entries in the map. - OtherUserID networkid.UserID - - // Deprecated: Use MemberMap instead to avoid duplicate entries - Members []ChatMember - MemberMap ChatMemberMap - PowerLevels *PowerLevelOverrides -} - -func (cml *ChatMemberList) memberListToMap(ctx context.Context) { - if cml.Members == nil || cml.MemberMap != nil { - return - } - cml.MemberMap = make(map[networkid.UserID]ChatMember, len(cml.Members)) - for _, member := range cml.Members { - if _, alreadyExists := cml.MemberMap[member.Sender]; alreadyExists { - zerolog.Ctx(ctx).Warn().Str("member_id", string(member.Sender)).Msg("Duplicate member in list") - } - cml.MemberMap[member.Sender] = member - } -} - -type PowerLevelOverrides struct { - Events map[event.Type]int - UsersDefault *int - EventsDefault *int - StateDefault *int - Invite *int - Kick *int - Ban *int - Redact *int - - Custom func(*event.PowerLevelsEventContent) bool -} - -// Deprecated: renamed to PowerLevelOverrides -type PowerLevelChanges = PowerLevelOverrides - -func allowChange(newLevel *int, oldLevel, actorLevel int) bool { - return newLevel != nil && - *newLevel <= actorLevel && oldLevel <= actorLevel && - oldLevel != *newLevel -} - -func (plc *PowerLevelOverrides) Apply(actor id.UserID, content *event.PowerLevelsEventContent) (changed bool) { - if plc == nil || content == nil { - return - } - var actorLevel int - if actor != "" { - actorLevel = content.GetUserLevel(actor) - } else { - actorLevel = (1 << 31) - 1 - } - if allowChange(plc.UsersDefault, content.UsersDefault, actorLevel) { - changed = true - content.UsersDefault = *plc.UsersDefault - } - if allowChange(plc.EventsDefault, content.EventsDefault, actorLevel) { - changed = true - content.EventsDefault = *plc.EventsDefault - } - if allowChange(plc.StateDefault, content.StateDefault(), actorLevel) { - changed = true - content.StateDefaultPtr = plc.StateDefault - } - if allowChange(plc.Invite, content.Invite(), actorLevel) { - changed = true - content.InvitePtr = plc.Invite - } - if allowChange(plc.Kick, content.Kick(), actorLevel) { - changed = true - content.KickPtr = plc.Kick - } - if allowChange(plc.Ban, content.Ban(), actorLevel) { - changed = true - content.BanPtr = plc.Ban - } - if allowChange(plc.Redact, content.Redact(), actorLevel) { - changed = true - content.RedactPtr = plc.Redact - } - for evtType, level := range plc.Events { - changed = content.EnsureEventLevelAs(actor, evtType, level) || changed - } - if plc.Custom != nil { - changed = plc.Custom(content) || changed - } - return changed -} - -// DefaultChatName can be used to explicitly clear the name of a room -// and reset it to the default one based on members. -var DefaultChatName = ptr.Ptr("") - -type ChatInfo struct { - Name *string - Topic *string - Avatar *Avatar - - Members *ChatMemberList - JoinRule *event.JoinRulesEventContent - - Type *database.RoomType - Disappear *database.DisappearingSetting - ParentID *networkid.PortalID - - UserLocal *UserLocalPortalInfo - MessageRequest *bool - CanBackfill bool - - ExcludeChangesFromTimeline bool - - ExtraUpdates ExtraUpdater[*Portal] -} - -type ExtraUpdater[T any] func(context.Context, T) bool - -func MergeExtraUpdaters[T any](funcs ...ExtraUpdater[T]) ExtraUpdater[T] { - funcs = slices.DeleteFunc(funcs, func(f ExtraUpdater[T]) bool { - return f == nil - }) - if len(funcs) == 0 { - return nil - } else if len(funcs) == 1 { - return funcs[0] - } - return func(ctx context.Context, p T) bool { - changed := false - for _, f := range funcs { - changed = f(ctx, p) || changed - } - return changed - } -} - -var Unmuted = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - -type UserLocalPortalInfo struct { - // To signal an indefinite mute, use [event.MutedForever] as the value here. - // To unmute, set any time before now, e.g. [bridgev2.Unmuted]. - MutedUntil *time.Time - Tag *event.RoomTag -} - -func (portal *Portal) updateName( - ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, -) bool { - if portal.Name == name && (portal.NameSet || portal.MXID == "") { - return false - } - portal.Name = name - portal.NameSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, nil, - ) - return true -} - -func (portal *Portal) updateTopic( - ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, -) bool { - if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") { - return false - } - portal.Topic = topic - portal.TopicSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, nil, - ) - return true -} - -func (portal *Portal) updateAvatar( - ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool, -) bool { - if portal.AvatarID == avatar.ID && (avatar.Remove || portal.AvatarMXC != "") && (portal.AvatarSet || portal.MXID == "") { - return false - } - portal.AvatarID = avatar.ID - if sender == nil { - sender = portal.Bridge.Bot - } - if avatar.Remove { - portal.AvatarMXC = "" - portal.AvatarHash = [32]byte{} - } else { - newMXC, newHash, err := avatar.Reupload(ctx, sender, portal.AvatarHash, portal.AvatarMXC) - if err != nil { - portal.AvatarSet = false - zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload room avatar") - return true - } else if newHash == portal.AvatarHash && portal.AvatarMXC != "" && portal.AvatarSet { - return true - } - portal.AvatarMXC = newMXC - portal.AvatarHash = newHash - } - portal.AvatarSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, nil, - ) - return true -} - -func (portal *Portal) GetTopLevelParent() *Portal { - if portal.Parent == nil { - if portal.RoomType != database.RoomTypeSpace { - return nil - } - return portal - } - return portal.Parent.GetTopLevelParent() -} - -func (portal *Portal) getBridgeInfoStateKey() string { - if portal.Bridge.Config.NoBridgeInfoStateKey { - return "" - } - idProvider, ok := portal.Bridge.Matrix.(MatrixConnectorWithBridgeIdentifier) - if ok { - return idProvider.GetUniqueBridgeID() - } - return string(portal.BridgeID) -} - -func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: portal.Bridge.Bot.GetMXID(), - Creator: portal.Bridge.Bot.GetMXID(), - Protocol: portal.Bridge.Network.GetName().AsBridgeInfoSection(), - Channel: event.BridgeInfoSection{ - ID: string(portal.ID), - DisplayName: portal.Name, - AvatarURL: portal.AvatarMXC, - Receiver: string(portal.Receiver), - MessageRequest: portal.MessageRequest, - // TODO external URL? - }, - BeeperRoomTypeV2: string(portal.RoomType), - } - if portal.RoomType == database.RoomTypeDM || portal.RoomType == database.RoomTypeGroupDM { - bridgeInfo.BeeperRoomType = "dm" - } - if bridgeInfo.Protocol.ID == "slackgo" { - bridgeInfo.TempSlackRemoteIDMigratedFlag = true - bridgeInfo.TempSlackRemoteIDMigratedFlag2 = true - } - parent := portal.GetTopLevelParent() - if parent != nil { - bridgeInfo.Network = &event.BridgeInfoSection{ - ID: string(parent.ID), - DisplayName: parent.Name, - AvatarURL: parent.AvatarMXC, - // TODO external URL? - } - } - filler, ok := portal.Bridge.Network.(PortalBridgeInfoFillingNetwork) - if ok { - filler.FillPortalBridgeInfo(portal, &bridgeInfo) - } - return portal.getBridgeInfoStateKey(), bridgeInfo -} - -func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { - if portal.MXID == "" { - return - } - stateKey, bridgeInfo := portal.getBridgeInfo() - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false, nil) - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false, nil) -} - -func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, implicit bool) bool { - if portal.MXID == "" { - return false - } else if !implicit && time.Since(portal.lastCapUpdate) < 24*time.Hour { - return false - } else if portal.CapState.ID != "" && source.ID != portal.CapState.Source && source.ID != portal.Receiver { - // TODO allow capability state source to change if the old user login is removed from the portal - return false - } - caps := source.Client.GetCapabilities(ctx, portal) - capID := caps.GetID() - if capID == portal.CapState.ID { - return false - } - zerolog.Ctx(ctx).Debug(). - Str("user_login_id", string(source.ID)). - Str("old_id", portal.CapState.ID). - Str("new_id", capID). - Msg("Sending new room capability event") - success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false, nil) - if !success { - return false - } - portal.CapState = database.CapabilityState{ - Source: source.ID, - ID: capID, - Flags: portal.CapState.Flags, - } - if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) { - zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event") - success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) - if !success { - return false - } - portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet - } - portal.lastCapUpdate = time.Now() - if implicit { - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal capability state after sending state event") - } - } - return true -} - -func (portal *Portal) sendStateWithIntentOrBot(ctx context.Context, sender MatrixAPI, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { - if sender == nil { - sender = portal.Bridge.Bot - } - resp, err = sender.SendState(ctx, portal.MXID, eventType, stateKey, content, ts) - if errors.Is(err, mautrix.MForbidden) && sender != portal.Bridge.Bot { - if content.Raw == nil { - content.Raw = make(map[string]any) - } - content.Raw["fi.mau.bridge.set_by"] = sender.GetMXID() - resp, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, eventType, stateKey, content, ts) - } - return -} - -func (portal *Portal) sendRoomMeta( - ctx context.Context, - sender MatrixAPI, - ts time.Time, - eventType event.Type, - stateKey string, - content any, - excludeFromTimeline bool, - extra map[string]any, -) bool { - if portal.MXID == "" { - return false - } - if extra == nil { - extra = make(map[string]any) - } - if excludeFromTimeline { - extra["com.beeper.exclude_from_timeline"] = true - } - if !portal.NameIsCustom && (eventType == event.StateRoomName || eventType == event.StateRoomAvatar) { - extra["fi.mau.implicit_name"] = true - } - _, err := portal.sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, &event.Content{ - Parsed: content, - Raw: extra, - }, ts) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("event_type", eventType.Type). - Msg("Failed to set room metadata") - return false - } - if eventType == event.StateBeeperDisappearingTimer { - // TODO remove this debug log at some point - zerolog.Ctx(ctx).Debug(). - Any("content", content). - Msg("Sent new disappearing timer event") - } - return true -} - -func (portal *Portal) revertRoomMeta(ctx context.Context, evt *event.Event) { - if !portal.Bridge.Config.RevertFailedStateChanges { - return - } - if evt.GetStateKey() != "" && evt.Type != event.StateMember { - return - } - switch evt.Type { - case event.StateRoomName: - portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomName, "", &event.RoomNameEventContent{Name: portal.Name}, true, nil) - case event.StateRoomAvatar: - portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, true, nil) - case event.StateTopic: - portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateTopic, "", &event.TopicEventContent{Topic: portal.Topic}, true, nil) - case event.StateBeeperDisappearingTimer: - portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) - case event.StateMember: - var prevContent *event.MemberEventContent - var extra map[string]any - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent = evt.Unsigned.PrevContent.AsMember() - newContent := evt.Content.AsMember() - if prevContent.Membership == newContent.Membership { - return - } - extra = evt.Unsigned.PrevContent.Raw - } else { - prevContent = &event.MemberEventContent{Membership: event.MembershipLeave} - } - if portal.Bridge.Matrix.GetCapabilities().ArbitraryMemberChange { - if extra == nil { - extra = make(map[string]any) - } - extra["com.beeper.member_rollback"] = true - portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateMember, evt.GetStateKey(), prevContent, true, extra) - } - } -} - -func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { - if members == nil { - invite = []id.UserID{source.UserMXID} - return - } - var loginsInPortal []*UserLogin - if members.CheckAllLogins && !portal.Bridge.Config.SplitPortals { - loginsInPortal, err = portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) - if err != nil { - err = fmt.Errorf("failed to get user logins in portal: %w", err) - return - } - } - members.PowerLevels.Apply("", pl) - members.memberListToMap(ctx) - for _, member := range members.MemberMap { - if ctx.Err() != nil { - err = ctx.Err() - return - } - if member.Membership != event.MembershipJoin && member.Membership != "" { - continue - } - if member.Sender != "" && member.UserInfo != nil { - ghost, err := portal.Bridge.GetGhostByID(ctx, member.Sender) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("ghost_id", string(member.Sender)).Msg("Failed to get ghost from member list to update info") - } else { - ghost.UpdateInfo(ctx, member.UserInfo) - } - } - intent, extraUserID, err := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) - if err != nil { - return nil, nil, err - } - if extraUserID != "" { - invite = append(invite, extraUserID) - if member.PowerLevel != nil { - pl.EnsureUserLevel(extraUserID, *member.PowerLevel) - } - if intent != nil { - // If intent is present along with a user ID, it's the ghost of a logged-in user, - // so add it to the functional members list - functional = append(functional, intent.GetMXID()) - } - } - if intent != nil { - invite = append(invite, intent.GetMXID()) - if member.PowerLevel != nil { - pl.EnsureUserLevel(intent.GetMXID(), *member.PowerLevel) - } - } - } - portal.updateOtherUser(ctx, members) - return -} - -func (portal *Portal) updateOtherUser(ctx context.Context, members *ChatMemberList) (changed bool) { - members.memberListToMap(ctx) - var expectedUserID networkid.UserID - if portal.RoomType != database.RoomTypeDM { - // expected user ID is empty - } else if members.OtherUserID != "" { - expectedUserID = members.OtherUserID - } else if len(members.MemberMap) == 2 && members.IsFull { - vals := maps.Values(members.MemberMap) - if vals[0].IsFromMe && !vals[1].IsFromMe { - expectedUserID = vals[1].Sender - } else if vals[1].IsFromMe && !vals[0].IsFromMe { - expectedUserID = vals[0].Sender - } - } - if portal.OtherUserID != expectedUserID { - zerolog.Ctx(ctx).Debug(). - Str("old_other_user_id", string(portal.OtherUserID)). - Str("new_other_user_id", string(expectedUserID)). - Msg("Updating other user ID in DM portal") - portal.OtherUserID = expectedUserID - return true - } - return false -} - -func looksDirectlyJoinable(rule *event.JoinRulesEventContent) bool { - switch rule.JoinRule { - case event.JoinRulePublic: - return true - case event.JoinRuleKnockRestricted, event.JoinRuleRestricted: - for _, allow := range rule.Allow { - if allow.Type == "fi.mau.spam_checker" { - return true - } - } - } - return false -} - -func (portal *Portal) roomIsPublic(ctx context.Context) bool { - mx, ok := portal.Bridge.Matrix.(MatrixConnectorWithArbitraryRoomState) - if !ok { - return false - } - evt, err := mx.GetStateEvent(ctx, portal.MXID, event.StateJoinRules, "") - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get join rules to check if room is public") - return false - } else if evt == nil { - return false - } - content, ok := evt.Content.Parsed.(*event.JoinRulesEventContent) - if !ok { - return false - } - return looksDirectlyJoinable(content) -} - -func (portal *Portal) syncParticipants( - ctx context.Context, - members *ChatMemberList, - source *UserLogin, - sender MatrixAPI, - ts time.Time, -) error { - members.memberListToMap(ctx) - var loginsInPortal []*UserLogin - var err error - if members.CheckAllLogins && !portal.Bridge.Config.SplitPortals { - loginsInPortal, err = portal.Bridge.GetUserLoginsInPortal(ctx, portal.PortalKey) - if err != nil { - return fmt.Errorf("failed to get user logins in portal: %w", err) - } - } - if sender == nil { - sender = portal.Bridge.Bot - } - log := zerolog.Ctx(ctx) - currentPower, err := portal.Bridge.Matrix.GetPowerLevels(ctx, portal.MXID) - if err != nil { - return fmt.Errorf("failed to get current power levels: %w", err) - } - currentMembers, err := portal.Bridge.Matrix.GetMembers(ctx, portal.MXID) - if err != nil { - return fmt.Errorf("failed to get current members: %w", err) - } - delete(currentMembers, portal.Bridge.Bot.GetMXID()) - powerChanged := members.PowerLevels.Apply(portal.Bridge.Bot.GetMXID(), currentPower) - addExcludeFromTimeline := func(raw map[string]any) { - _, hasKey := raw["com.beeper.exclude_from_timeline"] - if !hasKey && members.ExcludeChangesFromTimeline { - raw["com.beeper.exclude_from_timeline"] = true - } - } - syncUser := func(extraUserID id.UserID, member ChatMember, intent MatrixAPI) bool { - if member.Membership == "" { - member.Membership = event.MembershipJoin - } - if member.PowerLevel != nil { - powerChanged = currentPower.EnsureUserLevelAs(portal.Bridge.Bot.GetMXID(), extraUserID, *member.PowerLevel) || powerChanged - } - currentMember, ok := currentMembers[extraUserID] - delete(currentMembers, extraUserID) - if ok && currentMember.Membership == member.Membership { - return false - } - if currentMember == nil { - currentMember = &event.MemberEventContent{Membership: event.MembershipLeave} - } - if member.PrevMembership != "" && member.PrevMembership != currentMember.Membership { - log.Trace(). - Stringer("user_id", extraUserID). - Str("expected_prev_membership", string(member.PrevMembership)). - Str("actual_prev_membership", string(currentMember.Membership)). - Str("target_membership", string(member.Membership)). - Msg("Not updating membership: prev membership mismatch") - return false - } - content := &event.MemberEventContent{ - Membership: member.Membership, - Displayname: currentMember.Displayname, - AvatarURL: currentMember.AvatarURL, - } - wrappedContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} - addExcludeFromTimeline(wrappedContent.Raw) - thisEvtSender := sender - if member.Membership == event.MembershipJoin && (intent == nil || !portal.roomIsPublic(ctx)) { - content.Membership = event.MembershipInvite - if intent != nil { - wrappedContent.Raw["fi.mau.will_auto_accept"] = true - } - if thisEvtSender.GetMXID() == extraUserID { - thisEvtSender = portal.Bridge.Bot - } - } - addLogContext := func(e *zerolog.Event) *zerolog.Event { - return e.Stringer("target_user_id", extraUserID). - Stringer("sender_user_id", thisEvtSender.GetMXID()). - Str("prev_membership", string(currentMember.Membership)) - } - if currentMember != nil && currentMember.Membership == event.MembershipBan && member.Membership != event.MembershipLeave { - unbanContent := *content - unbanContent.Membership = event.MembershipLeave - wrappedUnbanContent := &event.Content{Parsed: &unbanContent} - _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedUnbanContent, ts) - if err != nil { - addLogContext(log.Err(err)). - Str("new_membership", string(unbanContent.Membership)). - Msg("Failed to unban user to update membership") - } else { - addLogContext(log.Trace()). - Str("new_membership", string(unbanContent.Membership)). - Msg("Unbanned user to update membership") - currentMember.Membership = event.MembershipLeave - } - } - if content.Membership == event.MembershipJoin && intent != nil && intent.GetMXID() == extraUserID { - _, err = intent.SendState(ctx, portal.MXID, event.StateMember, extraUserID.String(), wrappedContent, ts) - } else { - _, err = portal.sendStateWithIntentOrBot(ctx, thisEvtSender, event.StateMember, extraUserID.String(), wrappedContent, ts) - } - if err != nil { - addLogContext(log.Err(err)). - Str("new_membership", string(content.Membership)). - Msg("Failed to update user membership") - } else { - addLogContext(log.Trace()). - Str("new_membership", string(content.Membership)). - Msg("Updated membership in room") - currentMember.Membership = content.Membership - - if intent != nil && content.Membership == event.MembershipInvite && member.Membership == event.MembershipJoin { - content.Membership = event.MembershipJoin - wrappedJoinContent := &event.Content{Parsed: content, Raw: exmaps.NonNilClone(member.MemberEventExtra)} - addExcludeFromTimeline(wrappedContent.Raw) - _, err = intent.SendState(ctx, portal.MXID, event.StateMember, intent.GetMXID().String(), wrappedJoinContent, ts) - if err != nil { - addLogContext(log.Err(err)). - Str("new_membership", string(content.Membership)). - Msg("Failed to join with intent") - } else { - addLogContext(log.Trace()). - Str("new_membership", string(content.Membership)). - Msg("Joined room with intent") - } - } - } - return true - } - syncIntent := func(intent MatrixAPI, member ChatMember) { - if !syncUser(intent.GetMXID(), member, intent) { - return - } - if member.Membership == event.MembershipJoin || member.Membership == "" { - err = intent.EnsureJoined(ctx, portal.MXID) - if err != nil { - log.Err(err). - Stringer("user_id", intent.GetMXID()). - Msg("Failed to ensure user is joined to room") - } - } - } - for _, member := range members.MemberMap { - if ctx.Err() != nil { - return ctx.Err() - } - if member.Sender != "" && member.UserInfo != nil { - ghost, err := portal.Bridge.GetGhostByID(ctx, member.Sender) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("ghost_id", string(member.Sender)).Msg("Failed to get ghost from member list to update info") - } else { - ghost.UpdateInfo(ctx, member.UserInfo) - } - } - intent, extraUserID, err := portal.getIntentAndUserMXIDFor(ctx, member.EventSender, source, loginsInPortal, 0) - if err != nil { - return err - } - if intent != nil { - syncIntent(intent, member) - } - if extraUserID != "" { - syncUser(extraUserID, member, nil) - } - } - if powerChanged { - _, err = portal.sendStateWithIntentOrBot(ctx, sender, event.StatePowerLevels, "", &event.Content{Parsed: currentPower}, ts) - if err != nil { - log.Err(err).Msg("Failed to update power levels") - } - } - portal.updateOtherUser(ctx, members) - if members.IsFull { - for extraMember, memberEvt := range currentMembers { - if memberEvt.Membership == event.MembershipLeave || memberEvt.Membership == event.MembershipBan { - continue - } - if !portal.Bridge.IsGhostMXID(extraMember) && (portal.Relay != nil || !portal.Bridge.Config.KickMatrixUsers) { - continue - } - _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateMember, extraMember.String(), &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - AvatarURL: memberEvt.AvatarURL, - Displayname: memberEvt.Displayname, - Reason: "User is not in remote chat", - }, - Raw: map[string]any{ - "com.beeper.exclude_from_timeline": members.ExcludeChangesFromTimeline, - }, - }, time.Now()) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("user_id", extraMember). - Msg("Failed to remove user from room") - } - } - } - return nil -} - -func (portal *Portal) updateUserLocalInfo(ctx context.Context, info *UserLocalPortalInfo, source *UserLogin, didJustCreate bool) { - if portal.MXID == "" { - return - } - dp := source.User.DoublePuppet(ctx) - if dp == nil { - return - } - dmMarkingMatrixAPI, canMarkDM := dp.(MarkAsDMMatrixAPI) - if canMarkDM && portal.OtherUserID != "" && portal.RoomType == database.RoomTypeDM { - dmGhost, err := portal.Bridge.GetGhostByID(ctx, portal.OtherUserID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM ghost to mark room as DM") - } else if err = dmMarkingMatrixAPI.MarkAsDM(ctx, portal.MXID, dmGhost.Intent.GetMXID()); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to mark room as DM") - } - } - if info == nil { - return - } - if info.MutedUntil != nil && (didJustCreate || !portal.Bridge.Config.MuteOnlyOnCreate) && (!didJustCreate || info.MutedUntil.After(time.Now())) { - err := dp.MuteRoom(ctx, portal.MXID, *info.MutedUntil) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to mute room") - } - } - if info.Tag != nil && - len(portal.Bridge.Config.OnlyBridgeTags) > 0 && - (*info.Tag == "" || slices.Contains(portal.Bridge.Config.OnlyBridgeTags, *info.Tag)) && - (didJustCreate || !portal.Bridge.Config.TagOnlyOnCreate) && - (!didJustCreate || *info.Tag != "") { - err := dp.TagRoom(ctx, portal.MXID, *info.Tag, *info.Tag != "") - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to tag room") - } - } -} - -func DisappearingMessageNotice(expiration time.Duration, implicit bool) *event.MessageEventContent { - formattedDuration := exfmt.DurationCustom(expiration, nil, exfmt.Day, time.Hour, time.Minute, time.Second) - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Set the disappearing message timer to %s", formattedDuration), - Mentions: &event.Mentions{}, - } - if expiration == 0 { - if implicit { - content.Body = "Automatically turned off disappearing messages because incoming message is not disappearing" - } else { - content.Body = "Turned off disappearing messages" - } - } else if implicit { - content.Body = fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", formattedDuration) - } - return content -} - -type UpdateDisappearingSettingOpts struct { - Sender MatrixAPI - Timestamp time.Time - Implicit bool - Save bool - SendNotice bool - - ExcludeFromTimeline bool -} - -func (portal *Portal) UpdateDisappearingSetting( - ctx context.Context, - setting database.DisappearingSetting, - opts UpdateDisappearingSettingOpts, -) bool { - setting = setting.Normalize() - if portal.Disappear.Timer == setting.Timer && portal.Disappear.Type == setting.Type { - return false - } - portal.Disappear.Type = setting.Type - portal.Disappear.Timer = setting.Timer - if opts.Save { - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after updating disappearing setting") - } - } - if portal.MXID == "" { - return true - } - - if opts.Sender == nil { - opts.Sender = portal.Bridge.Bot - } - if opts.Timestamp.IsZero() { - opts.Timestamp = time.Now() - } - portal.sendRoomMeta( - ctx, - opts.Sender, - opts.Timestamp, - event.StateBeeperDisappearingTimer, - "", - setting.ToEventContent(), - opts.ExcludeFromTimeline, - nil, - ) - - if !opts.SendNotice { - return true - } - content := DisappearingMessageNotice(setting.Timer, opts.Implicit) - _, err := opts.Sender.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ - Parsed: content, - Raw: map[string]any{ - "com.beeper.action_message": map[string]any{ - "type": "disappearing_timer", - "timer": setting.Timer.Milliseconds(), - "timer_type": setting.Type, - "implicit": opts.Implicit, - }, - }, - }, &MatrixSendExtra{Timestamp: opts.Timestamp}) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send disappearing messages notice") - } else { - zerolog.Ctx(ctx).Debug(). - Dur("new_timer", portal.Disappear.Timer). - Bool("implicit", opts.Implicit). - Msg("Sent disappearing messages notice") - } - return true -} - -func (portal *Portal) updateParent(ctx context.Context, newParentID networkid.PortalID, source *UserLogin) bool { - newParent := networkid.PortalKey{ID: newParentID} - if portal.Bridge.Config.SplitPortals { - newParent.Receiver = portal.Receiver - } - if portal.ParentKey == newParent { - return false - } - var err error - if portal.MXID != "" && portal.InSpace && portal.Parent != nil && portal.Parent.MXID != "" { - err = portal.toggleSpace(ctx, portal.Parent.MXID, false, true) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("old_space_mxid", portal.Parent.MXID).Msg("Failed to remove portal from old space") - } - } - if newParent.ID != "" { - portal.Parent, err = portal.Bridge.GetPortalByKey(ctx, newParent) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get new parent portal") - return false - } - } - portal.ParentKey = newParent - portal.InSpace = false - if portal.MXID != "" && portal.Parent != nil && (source != nil || portal.Parent.MXID != "") { - if portal.Parent.MXID == "" { - zerolog.Ctx(ctx).Info().Msg("Parent portal doesn't exist, creating") - err = portal.Parent.CreateMatrixRoom(ctx, source, nil) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create parent portal") - } - } - if portal.Parent.MXID != "" { - portal.addToParentSpaceAndSave(ctx, false) - } - } - return true -} - -func (portal *Portal) lockedUpdateInfoFromGhost(ctx context.Context, ghost *Ghost) { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - portal.UpdateInfoFromGhost(ctx, ghost) -} - -func (portal *Portal) UpdateInfoFromGhost(ctx context.Context, ghost *Ghost) (changed bool) { - if portal.NameIsCustom || !portal.Bridge.Config.PrivateChatPortalMeta || (portal.OtherUserID == "" && ghost == nil) || portal.RoomType != database.RoomTypeDM { - return - } - var err error - if ghost == nil { - ghost, err = portal.Bridge.GetGhostByID(ctx, portal.OtherUserID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost to update info from") - return - } - } - changed = portal.updateName(ctx, ghost.Name, nil, time.Time{}, false) || changed - changed = portal.updateAvatar(ctx, &Avatar{ - ID: ghost.AvatarID, - MXC: ghost.AvatarMXC, - Hash: ghost.AvatarHash, - Remove: ghost.AvatarID == "", - }, nil, time.Time{}, false) || changed - return -} - -func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *UserLogin, sender MatrixAPI, ts time.Time) { - changed := false - if info.Name == DefaultChatName { - if portal.NameIsCustom { - portal.NameIsCustom = false - changed = portal.updateName(ctx, "", sender, ts, info.ExcludeChangesFromTimeline) || changed - } - } else if info.Name != nil { - portal.NameIsCustom = true - changed = portal.updateName(ctx, *info.Name, sender, ts, info.ExcludeChangesFromTimeline) || changed - } - if info.Avatar != nil { - portal.NameIsCustom = true - changed = portal.updateAvatar(ctx, info.Avatar, sender, ts, info.ExcludeChangesFromTimeline) || changed - } - if info.Type != nil && portal.RoomType != *info.Type { - if portal.MXID != "" && (*info.Type == database.RoomTypeSpace || portal.RoomType == database.RoomTypeSpace) { - zerolog.Ctx(ctx).Warn(). - Str("current_type", string(portal.RoomType)). - Str("target_type", string(*info.Type)). - Msg("Tried to change existing room type from/to space") - } else { - changed = true - portal.RoomType = *info.Type - } - } - if info.ParentID != nil { - changed = portal.updateParent(ctx, *info.ParentID, source) || changed - } - - // if the name, avatar, parent, or room type changes, we need to resend m.bridge events to each child portal as well - childInfoChanged := changed - - if info.Topic != nil { - changed = portal.updateTopic(ctx, *info.Topic, sender, ts, info.ExcludeChangesFromTimeline) || changed - } - if info.Disappear != nil { - changed = portal.UpdateDisappearingSetting(ctx, *info.Disappear, UpdateDisappearingSettingOpts{ - Sender: sender, - Timestamp: ts, - Implicit: false, - Save: false, - - SendNotice: !info.ExcludeChangesFromTimeline, - ExcludeFromTimeline: info.ExcludeChangesFromTimeline, - }) || changed - } - if info.JoinRule != nil { - // TODO change detection instead of spamming this every time? - portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline, nil) - } - if info.MessageRequest != nil && *info.MessageRequest != portal.MessageRequest { - changed = true - portal.MessageRequest = *info.MessageRequest - } - if info.Members != nil && portal.MXID != "" && source != nil { - err := portal.syncParticipants(ctx, info.Members, source, nil, time.Time{}) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to sync room members") - } - // TODO detect changes to functional members list? - } else if info.Members != nil { - portal.updateOtherUser(ctx, info.Members) - } - changed = portal.UpdateInfoFromGhost(ctx, nil) || changed - if source != nil { - source.MarkInPortal(ctx, portal) - portal.updateUserLocalInfo(ctx, info.UserLocal, source, false) - changed = portal.UpdateCapabilities(ctx, source, false) || changed - } - if info.CanBackfill && source != nil && portal.MXID != "" { - err := portal.Bridge.DB.BackfillTask.EnsureExists(ctx, portal.PortalKey, source.ID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure backfill queue task exists") - } - // TODO wake up backfill queue if task was just created - } - if info.ExtraUpdates != nil { - changed = info.ExtraUpdates(ctx, portal) || changed - } - if changed { - portal.UpdateBridgeInfo(ctx) - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after updating info") - } - } - if childInfoChanged { - portal.updateChildBridgeInfo(ctx) - } -} - -func (portal *Portal) updateChildBridgeInfo(ctx context.Context) { - if portal.RoomType != database.RoomTypeSpace { - return - } - portals, err := portal.Bridge.GetChildPortals(ctx, portal.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get child portals to update bridge info") - return - } - zerolog.Ctx(ctx).Debug(). - Int("child_count", len(portals)). - Stringer("parent_key", portal.PortalKey). - Msg("Updating bridge info for child portals") - for _, child := range portals { - child.UpdateBridgeInfo(ctx) - child.updateChildBridgeInfo(ctx) - } -} - -func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserLogin, info *ChatInfo) (retErr error) { - if portal.MXID != "" { - if source != nil { - source.MarkInPortal(ctx, portal) - } - return nil - } - if portal.backgroundCtx.Err() != nil { - return ErrPortalIsDeleted - } - mergedCtx, cancel := context.WithCancel(ctx) - defer cancel() - waiter := make(chan struct{}) - closed := false - evt := &portalCreateEvent{ - ctx: mergedCtx, - source: source, - info: info, - cb: func(err error) { - retErr = err - if !closed { - closed = true - close(waiter) - } - }, - } - if PortalEventBuffer == 0 { - go portal.queueEvent(mergedCtx, evt) - } else { - select { - case portal.events <- evt: - case <-portal.deleted: - return ErrPortalIsDeleted - } - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-portal.deleted: - // This automatically cancels the merged context too - return ErrPortalIsDeleted - case <-waiter: - return - } -} - -func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLogin, info *ChatInfo, backfillBundle any) error { - cancellableCtx, cancel := context.WithCancel(ctx) - defer cancel() - portal.cancelRoomCreate.CompareAndSwap(nil, &cancel) - portal.roomCreateLock.Lock() - portal.cancelRoomCreate.Store(&cancel) - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - if source != nil { - source.MarkInPortal(ctx, portal) - } - return nil - } - if cancellableCtx.Err() != nil { - return cancellableCtx.Err() - } - log := zerolog.Ctx(ctx).With(). - Str("action", "create matrix room"). - Logger() - cancellableCtx = log.WithContext(cancellableCtx) - ctx = log.WithContext(ctx) - log.Info().Msg("Creating Matrix room") - - var err error - if info == nil || info.Members == nil { - if info != nil { - log.Warn().Msg("CreateMatrixRoom got info without members. Refetching info") - } - info, err = source.Client.GetChatInfo(cancellableCtx, portal) - if err != nil { - log.Err(err).Msg("Failed to update portal info for creation") - return err - } - } - - portal.UpdateInfo(cancellableCtx, info, source, nil, time.Time{}) - if cancellableCtx.Err() != nil { - return cancellableCtx.Err() - } - - powerLevels := &event.PowerLevelsEventContent{ - Events: map[string]int{ - event.StateTombstone.Type: 100, - event.StateServerACL.Type: 100, - event.StateEncryption.Type: 100, - }, - Users: map[id.UserID]int{ - portal.Bridge.Bot.GetMXID(): 9001, - }, - } - initialMembers, extraFunctionalMembers, err := portal.getInitialMemberList(cancellableCtx, info.Members, source, powerLevels) - if err != nil { - log.Err(err).Msg("Failed to process participant list for portal creation") - return err - } - powerLevels.EnsureUserLevel(portal.Bridge.Bot.GetMXID(), 9001) - - req := mautrix.ReqCreateRoom{ - Visibility: "private", - CreationContent: make(map[string]any), - InitialState: make([]*event.Event, 0, 6), - Preset: "private_chat", - IsDirect: portal.RoomType == database.RoomTypeDM, - PowerLevelOverride: powerLevels, - BeeperLocalRoomID: portal.Bridge.Matrix.GenerateDeterministicRoomID(portal.PortalKey), - } - autoJoinInvites := portal.Bridge.Matrix.GetCapabilities().AutoJoinInvites - if autoJoinInvites { - req.BeeperInitialMembers = initialMembers - // TODO remove this after initial_members is supported in hungryserv - req.BeeperAutoJoinInvites = true - req.Invite = initialMembers - } - if portal.RoomType == database.RoomTypeSpace { - req.CreationContent["type"] = event.RoomTypeSpace - } - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - roomFeatures := source.Client.GetCapabilities(cancellableCtx, portal) - portal.CapState = database.CapabilityState{ - Source: source.ID, - ID: roomFeatures.GetID(), - } - - req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateElementFunctionalMembers, - Content: event.Content{Parsed: &event.ElementFunctionalMembersContent{ - ServiceMembers: append(extraFunctionalMembers, portal.Bridge.Bot.GetMXID()), - }}, - }, &event.Event{ - StateKey: &bridgeInfoStateKey, - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: &bridgeInfo}, - }, &event.Event{ - StateKey: &bridgeInfoStateKey, - Type: event.StateBridge, - Content: event.Content{Parsed: &bridgeInfo}, - }, &event.Event{ - StateKey: &bridgeInfoStateKey, - Type: event.StateBeeperRoomFeatures, - Content: event.Content{Parsed: roomFeatures}, - }, &event.Event{ - Type: event.StateTopic, - Content: event.Content{ - Parsed: &event.TopicEventContent{Topic: portal.Topic}, - Raw: map[string]any{ - "com.beeper.exclude_from_timeline": true, - }, - }, - }) - if roomFeatures.DisappearingTimer != nil { - req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateBeeperDisappearingTimer, - Content: event.Content{ - Parsed: portal.Disappear.ToEventContent(), - Raw: map[string]any{ - "com.beeper.exclude_from_timeline": true, - }, - }, - }) - portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet - } - if portal.Name != "" { - req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateRoomName, - Content: event.Content{ - Parsed: &event.RoomNameEventContent{Name: portal.Name}, - Raw: map[string]any{ - "com.beeper.exclude_from_timeline": true, - }, - }, - }) - } - if portal.AvatarMXC != "" { - req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, - Raw: map[string]any{ - "com.beeper.exclude_from_timeline": true, - }, - }, - }) - } - if portal.Parent != nil && portal.Parent.MXID != "" { - req.InitialState = append(req.InitialState, &event.Event{ - StateKey: ptr.Ptr(portal.Parent.MXID.String()), - Type: event.StateSpaceParent, - Content: event.Content{Parsed: &event.SpaceParentEventContent{ - Via: []string{portal.Bridge.Matrix.ServerName()}, - Canonical: true, - }}, - }) - } - if info.JoinRule != nil { - req.InitialState = append(req.InitialState, &event.Event{ - Type: event.StateJoinRules, - Content: event.Content{Parsed: info.JoinRule}, - }) - } - if cancellableCtx.Err() != nil { - return cancellableCtx.Err() - } - roomID, err := portal.Bridge.Bot.CreateRoom(ctx, &req) - if err != nil { - log.Err(err).Msg("Failed to create Matrix room") - return err - } - log.Info().Stringer("room_id", roomID).Msg("Matrix room created") - portal.AvatarSet = true - portal.TopicSet = true - portal.NameSet = true - portal.MXID = roomID - portal.RoomCreated.Set() - portal.Bridge.cacheLock.Lock() - portal.Bridge.portalsByMXID[roomID] = portal - portal.Bridge.cacheLock.Unlock() - portal.updateLogger() - err = portal.Save(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal to database after creating Matrix room") - return err - } - if info.CanBackfill && portal.RoomType != database.RoomTypeSpace { - err = portal.Bridge.DB.BackfillTask.Upsert(ctx, &database.BackfillTask{ - PortalKey: portal.PortalKey, - UserLoginID: source.ID, - NextDispatchMinTS: time.Now().Add(BackfillMinBackoffAfterRoomCreate), - }) - if err != nil { - log.Err(err).Msg("Failed to create backfill queue task after creating room") - } - portal.Bridge.WakeupBackfillQueue() - } - withoutCancelCtx := zerolog.Ctx(ctx).WithContext(portal.backgroundCtx) - if portal.Parent != nil { - if portal.Parent.MXID != "" { - portal.addToParentSpaceAndSave(ctx, true) - } else { - log.Info().Msg("Parent portal doesn't exist, creating in background") - go portal.createParentAndAddToSpace(withoutCancelCtx, source) - } - } - portal.updateUserLocalInfo(ctx, info.UserLocal, source, true) - if !autoJoinInvites { - if info.Members == nil { - dp := source.User.DoublePuppet(ctx) - if dp != nil { - err = dp.EnsureJoined(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Failed to ensure user is joined to room after creation") - } - } - } else { - err = portal.syncParticipants(ctx, info.Members, source, nil, time.Time{}) - if err != nil { - log.Err(err).Msg("Failed to sync participants after room creation") - } - } - } - portal.addToUserSpaces(ctx) - if info.CanBackfill && - portal.Bridge.Config.Backfill.Enabled && - portal.RoomType != database.RoomTypeSpace && - !portal.Bridge.Background { - portal.doForwardBackfill(ctx, source, nil, backfillBundle) - } - return nil -} - -func (portal *Portal) addToUserSpaces(ctx context.Context) { - if portal.Parent != nil { - return - } - log := zerolog.Ctx(ctx) - withoutCancelCtx := log.WithContext(portal.backgroundCtx) - if portal.Receiver != "" { - login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) - if login != nil { - up, err := portal.Bridge.DB.UserPortal.GetOrCreate(ctx, login.UserLogin, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to get user portal to add portal to spaces") - } else { - login.inPortalCache.Remove(portal.PortalKey) - go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) - } - } - } else { - userPortals, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) - if err != nil { - log.Err(err).Msg("Failed to get user logins in portal to add portal to spaces") - } else { - for _, up := range userPortals { - login := portal.Bridge.GetCachedUserLoginByID(up.LoginID) - if login != nil { - login.inPortalCache.Remove(portal.PortalKey) - go login.tryAddPortalToSpace(withoutCancelCtx, portal, up.CopyWithoutValues()) - } - } - } - } -} - -func (portal *Portal) Delete(ctx context.Context) error { - if portal.backgroundCtx.Err() != nil { - return nil - } - portal.removeInPortalCache(ctx) - err := portal.safeDBDelete(ctx) - if err != nil { - return err - } - portal.Bridge.cacheLock.Lock() - defer portal.Bridge.cacheLock.Unlock() - portal.unlockedDeleteCache() - return nil -} - -func (portal *Portal) safeDBDelete(ctx context.Context) error { - err := portal.Bridge.DB.Message.DeleteInChunks(ctx, portal.PortalKey) - if err != nil { - return fmt.Errorf("failed to delete messages in portal: %w", err) - } - // TODO delete child portals? - return portal.Bridge.DB.Portal.Delete(ctx, portal.PortalKey) -} - -func (portal *Portal) RemoveMXID(ctx context.Context) error { - return portal.removeMXID(ctx, false) -} - -func (portal *Portal) removeMXID(ctx context.Context, alreadyLocked bool) error { - if portal.MXID == "" { - return nil - } - oldMXID := portal.MXID - portal.MXID = "" - portal.Relay = nil - portal.RelayLoginID = "" - portal.RoomCreated.Clear() - err := portal.Save(ctx) - if err != nil { - return err - } - err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update in_space flag for user portals after removing portal MXID") - } - portal.removeInPortalCache(ctx) - if !alreadyLocked { - portal.Bridge.cacheLock.Lock() - defer portal.Bridge.cacheLock.Unlock() - } - delete(portal.Bridge.portalsByMXID, oldMXID) - return nil -} - -func (portal *Portal) removeInPortalCache(ctx context.Context) { - if portal.Receiver != "" { - login := portal.Bridge.GetCachedUserLoginByID(portal.Receiver) - if login != nil { - login.inPortalCache.Remove(portal.PortalKey) - } - return - } - userPortals, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get user logins in portal to remove user portal cache") - } else { - for _, up := range userPortals { - login := portal.Bridge.GetCachedUserLoginByID(up.LoginID) - if login != nil { - login.inPortalCache.Remove(portal.PortalKey) - } - } - } -} - -func (portal *Portal) unlockedDelete(ctx context.Context) error { - if portal.backgroundCtx.Err() != nil { - return nil - } - err := portal.safeDBDelete(ctx) - if err != nil { - return err - } - portal.unlockedDeleteCache() - return nil -} - -func (portal *Portal) unlockedDeleteCache() { - if portal.backgroundCtx.Err() != nil { - return - } - delete(portal.Bridge.portalsByKey, portal.PortalKey) - if portal.MXID != "" { - delete(portal.Bridge.portalsByMXID, portal.MXID) - } - portal.cancelBackground() - if portal.events != nil { - // TODO there's a small risk of this racing with a queueEvent call - close(portal.events) - } -} - -func (portal *Portal) Save(ctx context.Context) error { - return portal.Bridge.DB.Portal.Update(ctx, portal.Portal) -} - -func (portal *Portal) SetRelay(ctx context.Context, relay *UserLogin) error { - if portal.Receiver != "" && relay.ID != portal.Receiver { - return fmt.Errorf("can't set non-receiver login as relay") - } - portal.Relay = relay - if relay == nil { - portal.RelayLoginID = "" - } else { - portal.RelayLoginID = relay.ID - } - err := portal.Save(ctx) - if err != nil { - return err - } - return nil -} diff --git a/mautrix-patched/bridgev2/portalbackfill.go b/mautrix-patched/bridgev2/portalbackfill.go deleted file mode 100644 index c656ada1..00000000 --- a/mautrix-patched/bridgev2/portalbackfill.go +++ /dev/null @@ -1,646 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "errors" - "fmt" - "slices" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/ptr" - "go.mau.fi/util/variationselector" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (portal *Portal) doForwardBackfill(ctx context.Context, source *UserLogin, lastMessage *database.Message, bundledData any) { - log := zerolog.Ctx(ctx).With().Str("action", "forward backfill").Logger() - if !portal.forwardBackfillLock.TryLock() { - log.Warn().Msg("Another forward backfill is already running") - return - } - defer portal.forwardBackfillLock.Unlock() - ctx = log.WithContext(ctx) - api, ok := source.Client.(BackfillingNetworkAPI) - if !ok { - log.Debug().Msg("Network API does not support backfilling") - return - } - var limit int - var latestMessageID string - if lastMessage != nil { - latestMessageID = string(lastMessage.ID) - limit = portal.Bridge.Config.Backfill.MaxCatchupMessages - } else { - limit = portal.Bridge.Config.Backfill.MaxInitialMessages - } - if limit <= 0 { - return - } - log.Info().Str("latest_message_id", latestMessageID).Msg("Fetching messages for forward backfill") - resp, err := api.FetchMessages(ctx, FetchMessagesParams{ - Portal: portal, - ThreadRoot: "", - Forward: true, - AnchorMessage: lastMessage, - Count: limit, - BundledData: bundledData, - }) - if err != nil { - log.Err(err).Msg("Failed to fetch messages for forward backfill") - return - } else if resp == nil { - log.Debug().Msg("Didn't get backfill response") - return - } else if len(resp.Messages) == 0 { - log.Debug().Msg("No messages to backfill") - if resp.CompleteCallback != nil { - resp.CompleteCallback() - } - return - } - log.Debug(). - Int("message_count", len(resp.Messages)). - Bool("mark_read", resp.MarkRead). - Bool("aggressive_deduplication", resp.AggressiveDeduplication). - Str("queried_latest_message_id", latestMessageID). - Msg("Fetched messages for forward backfill, deduplicating before sending") - // TODO mark backfill queue task as done if last message is nil (-> room was empty) and HasMore is false? - resp.Messages = portal.cutoffMessages(ctx, resp.Messages, resp.AggressiveDeduplication, true, lastMessage) - if len(resp.Messages) == 0 { - log.Warn().Msg("No messages left to backfill after cutting off old messages") - if resp.CompleteCallback != nil { - resp.CompleteCallback() - } - return - } - err = portal.sendBackfill(ctx, source, resp.Messages, true, resp.MarkRead, false, resp.CompleteCallback) - if err != nil { - log.Err(err).Msg("Failed to send forward backfill") - } -} - -var errNoMessagesLeftAfterCutoff = errors.New("no messages left to backfill after cutting off too new messages") - -func (portal *Portal) doBackwardsBackfill(ctx context.Context, source *UserLogin, task *database.BackfillTask, resp *FetchMessagesResponse) (bool, error) { - log := zerolog.Ctx(ctx) - api, ok := source.Client.(BackfillingNetworkAPI) - if !ok { - return false, fmt.Errorf("network API does not support backfilling") - } - firstMessage, err := portal.Bridge.DB.Message.GetFirstPortalMessage(ctx, portal.PortalKey) - if err != nil { - return false, fmt.Errorf("failed to get first portal message: %w", err) - } - logEvt := log.Info(). - Str("cursor", string(task.Cursor)). - Str("task_oldest_message_id", string(task.OldestMessageID)). - Int("current_batch_count", task.BatchCount) - if firstMessage != nil { - logEvt = logEvt.Str("db_oldest_message_id", string(firstMessage.ID)) - } else { - logEvt = logEvt.Str("db_oldest_message_id", "") - } - if resp == nil { - logEvt.Msg("Fetching messages for backward backfill") - resp, err = api.FetchMessages(ctx, FetchMessagesParams{ - Portal: portal, - ThreadRoot: "", - Forward: false, - Cursor: task.Cursor, - AnchorMessage: firstMessage, - Count: portal.Bridge.Config.Backfill.Queue.BatchSize, - Task: task, - AllowSlowFetch: !task.FromQueue || !portal.Bridge.Config.Backfill.Queue.Manual, - }) - if err != nil { - return false, fmt.Errorf("failed to fetch messages for backward backfill: %w", err) - } else if resp == nil { - log.Debug().Msg("Didn't get backfill response, marking task as done") - task.IsDone = true - task.QueueDone = true - return false, nil - } else if resp.Pending { - log.Debug().Msg("Backfill response is pending") - // TODO make configurable by admin and/or network connector? - task.NextDispatchMinTS = time.Now().Add(24 * time.Hour) - return true, nil - } else if resp.MoreRequiresSlowFetch { - if !task.FromQueue { - log.Warn().Msg("Backfill response indicates more messages require slow fetching even though task is not from queue") - } else { - log.Debug().Msg("Backfill response indicates more messages require slow fetching, setting queue done flag") - } - task.QueueDone = true - return true, nil - } - } else { - log.Debug().Msg("Using data from event for backwards backfill") - } - log.Debug(). - Str("new_cursor", string(resp.Cursor)). - Bool("has_more", resp.HasMore). - Int("message_count", len(resp.Messages)). - Msg("Fetched messages for backward backfill") - task.Cursor = resp.Cursor - if !resp.HasMore { - task.IsDone = true - task.QueueDone = true - } - if len(resp.Messages) == 0 { - if !resp.HasMore { - log.Debug().Msg("No messages to backfill, marking backfill task as done") - } else { - log.Warn().Msg("No messages to backfill, but HasMore is true") - } - if resp.CompleteCallback != nil { - resp.CompleteCallback() - } - return false, nil - } - resp.Messages = portal.cutoffMessages(ctx, resp.Messages, resp.AggressiveDeduplication, false, firstMessage) - if len(resp.Messages) == 0 { - if resp.CompleteCallback != nil { - resp.CompleteCallback() - } - // Hack to handle some migrated portals where message timestamps are unknown. - // They can't be backfilled further, so just mark them as done. - if firstMessage != nil && firstMessage.Timestamp.Unix() == 0 { - task.IsDone = true - task.QueueDone = true - return false, nil - } - return false, errNoMessagesLeftAfterCutoff - } - err = portal.sendBackfill(ctx, source, resp.Messages, false, resp.MarkRead, false, resp.CompleteCallback) - if err != nil { - return false, fmt.Errorf("failed to send backward backfill: %w", err) - } - if len(resp.Messages) > 0 { - task.OldestMessageID = resp.Messages[0].ID - } - return false, nil -} - -func (portal *Portal) fetchThreadBackfill(ctx context.Context, source *UserLogin, anchor *database.Message) *FetchMessagesResponse { - log := zerolog.Ctx(ctx) - resp, err := source.Client.(BackfillingNetworkAPI).FetchMessages(ctx, FetchMessagesParams{ - Portal: portal, - ThreadRoot: anchor.ID, - Forward: true, - AnchorMessage: anchor, - Count: portal.Bridge.Config.Backfill.Threads.MaxInitialMessages, - }) - if err != nil { - log.Err(err).Msg("Failed to fetch messages for thread backfill") - return nil - } else if resp == nil { - log.Debug().Msg("Didn't get backfill response") - return nil - } else if len(resp.Messages) == 0 { - log.Debug().Msg("No messages to backfill") - return nil - } - resp.Messages = portal.cutoffMessages(ctx, resp.Messages, resp.AggressiveDeduplication, true, anchor) - if len(resp.Messages) == 0 { - log.Warn().Msg("No messages left to backfill after cutting off old messages") - if resp.CompleteCallback != nil { - resp.CompleteCallback() - } - return nil - } - return resp -} - -func (portal *Portal) doThreadBackfill(ctx context.Context, source *UserLogin, threadID networkid.MessageID) error { - log := zerolog.Ctx(ctx).With(). - Str("subaction", "thread backfill"). - Str("thread_id", string(threadID)). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Backfilling thread inside other backfill") - anchorMessage, err := portal.Bridge.DB.Message.GetLastThreadMessage(ctx, portal.PortalKey, threadID) - if err != nil { - return fmt.Errorf("failed to get last thread message: %w", err) - } else if anchorMessage == nil { - log.Warn().Msg("No messages found in thread?") - return nil - } - resp := portal.fetchThreadBackfill(ctx, source, anchorMessage) - if resp != nil { - return portal.sendBackfill(ctx, source, resp.Messages, true, resp.MarkRead, true, resp.CompleteCallback) - } - return nil -} - -func (portal *Portal) cutoffMessages(ctx context.Context, messages []*BackfillMessage, aggressiveDedup, forward bool, lastMessage *database.Message) []*BackfillMessage { - if lastMessage == nil { - return messages - } - if forward { - cutoff := -1 - for i, msg := range messages { - if msg.ID == lastMessage.ID || msg.Timestamp.Before(lastMessage.Timestamp) { - cutoff = i - } else { - break - } - } - if cutoff != -1 { - zerolog.Ctx(ctx).Debug(). - Int("cutoff_count", cutoff+1). - Int("total_count", len(messages)). - Time("last_bridged_ts", lastMessage.Timestamp). - Msg("Cutting off forward backfill messages older than latest bridged message") - messages = messages[cutoff+1:] - } - } else { - cutoff := -1 - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].ID == lastMessage.ID || messages[i].Timestamp.After(lastMessage.Timestamp) { - cutoff = i - } else { - break - } - } - if cutoff != -1 { - zerolog.Ctx(ctx).Debug(). - Int("cutoff_count", len(messages)-cutoff). - Int("total_count", len(messages)). - Time("oldest_bridged_ts", lastMessage.Timestamp). - Msg("Cutting off backward backfill messages newer than oldest bridged message") - messages = messages[:cutoff] - } - } - if aggressiveDedup { - filteredMessages := messages[:0] - for _, msg := range messages { - existingMsg, err := portal.Bridge.DB.Message.GetFirstPartByID(ctx, portal.Receiver, msg.ID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("message_id", string(msg.ID)).Msg("Failed to check for existing message") - } else if existingMsg != nil { - zerolog.Ctx(ctx).Err(err). - Str("message_id", string(msg.ID)). - Time("message_ts", msg.Timestamp). - Str("message_sender", string(msg.Sender.Sender)). - Msg("Ignoring duplicate message in backfill") - continue - } - if forward && msg.TxnID != "" { - wasPending, _ := portal.checkPendingMessage(ctx, msg) - if wasPending { - zerolog.Ctx(ctx).Err(err). - Str("transaction_id", string(msg.TxnID)). - Str("message_id", string(msg.ID)). - Time("message_ts", msg.Timestamp). - Msg("Found pending message in backfill") - continue - } - } - filteredMessages = append(filteredMessages, msg) - } - messages = filteredMessages - } - return messages -} - -func (portal *Portal) sendBackfill( - ctx context.Context, - source *UserLogin, - messages []*BackfillMessage, - forceForward, - markRead, - inThread bool, - done func(), -) error { - canBatchSend := portal.Bridge.Matrix.GetCapabilities().BatchSending - unreadThreshold := time.Duration(portal.Bridge.Config.Backfill.UnreadHoursThreshold) * time.Hour - forceMarkRead := unreadThreshold > 0 && time.Since(messages[len(messages)-1].Timestamp) > unreadThreshold - zerolog.Ctx(ctx).Info(). - Int("message_count", len(messages)). - Bool("batch_send", canBatchSend). - Bool("mark_read", markRead). - Bool("mark_read_past_threshold", forceMarkRead). - Msg("Sending backfill messages") - var err error - if canBatchSend { - err = portal.sendBatch(ctx, source, messages, forceForward, markRead || forceMarkRead, inThread) - } else { - err = portal.sendLegacyBackfill(ctx, source, messages, markRead || forceMarkRead) - } - if err != nil { - return err - } - if done != nil { - done() - } - zerolog.Ctx(ctx).Debug().Msg("Backfill finished") - if !canBatchSend && !inThread && portal.Bridge.Config.Backfill.Threads.MaxInitialMessages > 0 { - for _, msg := range messages { - if msg.ShouldBackfillThread { - err = portal.doThreadBackfill(ctx, source, msg.ID) - if err != nil { - zerolog.Ctx(ctx).Debug(). - Str("thread_id", string(msg.ID)). - Msg("Failed to backfill thread") - } - } - } - } - return nil -} - -type compileBatchOutput struct { - PrevThreadEvents map[networkid.MessageID]id.EventID - - Events []*event.Event - Extras []*MatrixSendExtra - - DBMessages []*database.Message - DBReactions []*database.Reaction - Disappear []*database.DisappearingMessage -} - -func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin, msg *BackfillMessage, out *compileBatchOutput, inThread bool) { - if len(msg.Parts) == 0 { - return - } - intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) - if !ok { - return - } - replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta( - ctx, msg.ID, msg.ConvertedMessage, true, - ) - if threadRoot != nil && out.PrevThreadEvents[*msg.ThreadRoot] != "" { - prevThreadEvent.MXID = out.PrevThreadEvents[*msg.ThreadRoot] - } - var partIDs []networkid.PartID - partMap := make(map[networkid.PartID]*database.Message, len(msg.Parts)) - var firstPart *database.Message - for i, part := range msg.Parts { - partIDs = append(partIDs, part.ID) - portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent) - part.Content.BeeperDisappearingTimer = msg.Disappear.ToEventContent() - evtID := portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, part.ID) - dbMessage := &database.Message{ - ID: msg.ID, - PartID: part.ID, - MXID: evtID, - Room: portal.PortalKey, - SenderID: msg.Sender.Sender, - SenderMXID: intent.GetMXID(), - Timestamp: msg.Timestamp, - ThreadRoot: ptr.Val(msg.ThreadRoot), - ReplyTo: ptr.Val(msg.ReplyTo), - Metadata: part.DBMetadata, - IsDoublePuppeted: intent.IsDoublePuppet(), - } - if part.DontBridge { - dbMessage.SetFakeMXID() - out.DBMessages = append(out.DBMessages, dbMessage) - continue - } - out.Events = append(out.Events, &event.Event{ - Sender: intent.GetMXID(), - Type: part.Type, - Timestamp: msg.Timestamp.UnixMilli(), - ID: evtID, - RoomID: portal.MXID, - Content: event.Content{ - Parsed: part.Content, - Raw: part.Extra, - }, - }) - if firstPart == nil { - firstPart = dbMessage - } - partMap[part.ID] = dbMessage - out.Extras = append(out.Extras, &MatrixSendExtra{MessageMeta: dbMessage, StreamOrder: msg.StreamOrder, PartIndex: i}) - out.DBMessages = append(out.DBMessages, dbMessage) - if prevThreadEvent != nil { - prevThreadEvent.MXID = evtID - out.PrevThreadEvents[*msg.ThreadRoot] = evtID - } - if msg.Disappear.Type != event.DisappearingTypeNone { - if msg.Disappear.Type == event.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() { - msg.Disappear.DisappearAt = msg.Timestamp.Add(msg.Disappear.Timer) - } - out.Disappear = append(out.Disappear, &database.DisappearingMessage{ - RoomID: portal.MXID, - EventID: evtID, - Timestamp: msg.Timestamp, - DisappearingSetting: msg.Disappear, - }) - } - } - slices.Sort(partIDs) - for _, reaction := range msg.Reactions { - if reaction == nil { - continue - } - reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove) - if !ok { - continue - } - if reaction.TargetPart == nil { - reaction.TargetPart = &partIDs[0] - } - if reaction.Timestamp.IsZero() { - reaction.Timestamp = msg.Timestamp.Add(10 * time.Millisecond) - } - //lint:ignore SA4006 it's a todo - targetPart, ok := partMap[*reaction.TargetPart] - if !ok { - // TODO warning log and/or skip reaction? - } - reactionMXID := portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, targetPart, reaction.Sender.Sender, reaction.EmojiID) - dbReaction := &database.Reaction{ - Room: portal.PortalKey, - MessageID: msg.ID, - MessagePartID: *reaction.TargetPart, - SenderID: reaction.Sender.Sender, - EmojiID: reaction.EmojiID, - MXID: reactionMXID, - Timestamp: reaction.Timestamp, - Emoji: reaction.Emoji, - Metadata: reaction.DBMetadata, - } - out.Events = append(out.Events, &event.Event{ - Sender: reactionIntent.GetMXID(), - Type: event.EventReaction, - Timestamp: reaction.Timestamp.UnixMilli(), - ID: reactionMXID, - RoomID: portal.MXID, - Content: event.Content{ - Parsed: &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, *reaction.TargetPart), - Key: variationselector.Add(reaction.Emoji), - }, - }, - Raw: reaction.ExtraContent, - }, - }) - out.DBReactions = append(out.DBReactions, dbReaction) - out.Extras = append(out.Extras, &MatrixSendExtra{ReactionMeta: dbReaction}) - } - if firstPart != nil && !inThread && portal.Bridge.Config.Backfill.Threads.MaxInitialMessages > 0 && msg.ShouldBackfillThread { - portal.fetchThreadInsideBatch(ctx, source, firstPart, out) - } -} - -func (portal *Portal) fetchThreadInsideBatch(ctx context.Context, source *UserLogin, dbMsg *database.Message, out *compileBatchOutput) { - log := zerolog.Ctx(ctx).With(). - Str("subaction", "thread backfill in batch"). - Str("thread_id", string(dbMsg.ID)). - Logger() - ctx = log.WithContext(ctx) - resp := portal.fetchThreadBackfill(ctx, source, dbMsg) - if resp != nil { - for _, msg := range resp.Messages { - portal.compileBatchMessage(ctx, source, msg, out, true) - } - } -} - -func (portal *Portal) sendBatch(ctx context.Context, source *UserLogin, messages []*BackfillMessage, forceForward, markRead, inThread bool) error { - out := &compileBatchOutput{ - PrevThreadEvents: make(map[networkid.MessageID]id.EventID), - Events: make([]*event.Event, 0, len(messages)), - Extras: make([]*MatrixSendExtra, 0, len(messages)), - DBMessages: make([]*database.Message, 0, len(messages)), - DBReactions: make([]*database.Reaction, 0), - Disappear: make([]*database.DisappearingMessage, 0), - } - for _, msg := range messages { - portal.compileBatchMessage(ctx, source, msg, out, inThread) - } - req := &mautrix.ReqBeeperBatchSend{ - ForwardIfNoMessages: !forceForward, - Forward: forceForward, - SendNotification: !markRead && forceForward && !inThread, - Events: out.Events, - } - if markRead { - req.MarkReadBy = source.UserMXID - } - _, err := portal.Bridge.Matrix.BatchSend(ctx, portal.MXID, req, out.Extras) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send backfill messages") - return err - } - if len(out.Disappear) > 0 { - // TODO mass insert disappearing messages - go func() { - for _, msg := range out.Disappear { - portal.Bridge.DisappearLoop.Add(ctx, msg) - } - }() - } - // TODO mass insert db messages - for _, msg := range out.DBMessages { - err = portal.Bridge.DB.Message.Insert(ctx, msg) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("message_id", string(msg.ID)). - Str("part_id", string(msg.PartID)). - Str("sender_id", string(msg.SenderID)). - Str("portal_id", string(msg.Room.ID)). - Str("portal_receiver", string(msg.Room.Receiver)). - Msg("Failed to insert backfilled message to database") - } - } - // TODO mass insert db reactions - for _, react := range out.DBReactions { - err = portal.Bridge.DB.Reaction.Upsert(ctx, react) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("message_id", string(react.MessageID)). - Str("part_id", string(react.MessagePartID)). - Str("sender_id", string(react.SenderID)). - Str("portal_id", string(react.Room.ID)). - Str("portal_receiver", string(react.Room.Receiver)). - Msg("Failed to insert backfilled reaction to database") - } - } - return nil -} - -func (portal *Portal) sendLegacyBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, markRead bool) error { - var lastPart id.EventID - for _, msg := range messages { - if ctx.Err() != nil { - return ctx.Err() - } - intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage) - if !ok { - continue - } - dbMessages, res := portal.sendConvertedMessage(ctx, msg.ID, intent, msg.Sender.Sender, msg.ConvertedMessage, msg.Timestamp, msg.StreamOrder, func(z *zerolog.Event) *zerolog.Event { - return z. - Str("message_id", string(msg.ID)). - Any("sender_id", msg.Sender). - Time("message_ts", msg.Timestamp) - }) - if !res.Success { - return res.Error - } - if len(dbMessages) > 0 { - lastPart = dbMessages[len(dbMessages)-1].MXID - for _, reaction := range msg.Reactions { - if ctx.Err() != nil { - return ctx.Err() - } - reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReaction) - if !ok { - continue - } - targetPart := dbMessages[0] - if reaction.TargetPart != nil { - targetPartIdx := slices.IndexFunc(dbMessages, func(dbMsg *database.Message) bool { - return dbMsg.PartID == *reaction.TargetPart - }) - if targetPartIdx != -1 { - targetPart = dbMessages[targetPartIdx] - } else { - // TODO warning log and/or skip reaction? - } - } - portal.sendConvertedReaction( - ctx, reaction.Sender.Sender, reactionIntent, targetPart, reaction.EmojiID, reaction.Emoji, - reaction.Timestamp, reaction.DBMetadata, reaction.ExtraContent, - func(z *zerolog.Event) *zerolog.Event { - return z. - Str("target_message_id", string(msg.ID)). - Str("target_part_id", string(targetPart.PartID)). - Any("reaction_sender_id", reaction.Sender). - Time("reaction_ts", reaction.Timestamp) - }, - ) - } - } - } - if markRead { - dp := source.User.DoublePuppet(ctx) - if dp != nil { - err := dp.MarkRead(ctx, portal.MXID, lastPart, time.Now()) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to mark room as read after backfill") - } - } - } - return nil -} diff --git a/mautrix-patched/bridgev2/portalinternal.go b/mautrix-patched/bridgev2/portalinternal.go deleted file mode 100644 index a9ec3a10..00000000 --- a/mautrix-patched/bridgev2/portalinternal.go +++ /dev/null @@ -1,418 +0,0 @@ -// GENERATED BY portalinternal_generate.go; DO NOT EDIT - -//go:generate go run portalinternal_generate.go -//go:generate goimports -local maunium.net/go/mautrix -w portalinternal.go - -package bridgev2 - -import ( - "context" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type PortalInternals Portal - -// Deprecated: portal internals should be used carefully and only when necessary. -func (portal *Portal) Internal() *PortalInternals { - return (*PortalInternals)(portal) -} - -func (portal *PortalInternals) UpdateLogger() { - (*Portal)(portal).updateLogger() -} - -func (portal *PortalInternals) QueueEvent(ctx context.Context, evt portalEvent) EventHandlingResult { - return (*Portal)(portal).queueEvent(ctx, evt) -} - -func (portal *PortalInternals) EventLoop() { - (*Portal)(portal).eventLoop() -} - -func (portal *PortalInternals) HandleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) { - return (*Portal)(portal).handleSingleEventWithDelayLogging(idx, rawEvt) -} - -func (portal *PortalInternals) GetEventCtxWithLog(rawEvt any, idx int) context.Context { - return (*Portal)(portal).getEventCtxWithLog(rawEvt, idx) -} - -func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(EventHandlingResult)) { - (*Portal)(portal).handleSingleEvent(ctx, rawEvt, doneCallback) -} - -func (portal *PortalInternals) UnwrapBeeperSendState(ctx context.Context, evt *event.Event) error { - return (*Portal)(portal).unwrapBeeperSendState(ctx, evt) -} - -func (portal *PortalInternals) SendSuccessStatus(ctx context.Context, evt *event.Event, streamOrder int64, newEventID id.EventID) { - (*Portal)(portal).sendSuccessStatus(ctx, evt, streamOrder, newEventID) -} - -func (portal *PortalInternals) SendErrorStatus(ctx context.Context, evt *event.Event, err error) { - (*Portal)(portal).sendErrorStatus(ctx, evt, err) -} - -func (portal *PortalInternals) CheckConfusableName(ctx context.Context, userID id.UserID, name string) bool { - return (*Portal)(portal).checkConfusableName(ctx, userID, name) -} - -func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult { - return (*Portal)(portal).handleMatrixEvent(ctx, sender, evt, isStateRequest) -} - -func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixReceipts(ctx, evt) -} - -func (portal *PortalInternals) HandleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) { - (*Portal)(portal).handleMatrixReadReceipt(ctx, user, eventID, receipt) -} - -func (portal *PortalInternals) CallReadReceiptHandler(ctx context.Context, login *UserLogin, rrClient ReadReceiptHandlingNetworkAPI, evt *MatrixReadReceipt, userPortal *database.UserPortal) { - (*Portal)(portal).callReadReceiptHandler(ctx, login, rrClient, evt, userPortal) -} - -func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixTyping(ctx, evt) -} - -func (portal *PortalInternals) SendTypings(ctx context.Context, userIDs []id.UserID, typing bool) { - (*Portal)(portal).sendTypings(ctx, userIDs, typing) -} - -func (portal *PortalInternals) PeriodicTypingUpdater() { - (*Portal)(portal).periodicTypingUpdater() -} - -func (portal *PortalInternals) CheckMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error { - return (*Portal)(portal).checkMessageContentCaps(caps, content) -} - -func (portal *PortalInternals) ParseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID { - return (*Portal)(portal).parseInputTransactionID(origSender, evt) -} - -func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt) -} - -func (portal *PortalInternals) PendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) { - (*Portal)(portal).pendingMessageTimeoutLoop(ctx, cfg) -} - -func (portal *PortalInternals) CheckPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) { - (*Portal)(portal).checkPendingMessages(ctx, cfg) -} - -func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) EventHandlingResult { - return (*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps) -} - -func (portal *PortalInternals) HandleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) (handleRes EventHandlingResult) { - return (*Portal)(portal).handleMatrixReaction(ctx, sender, nil, evt) -} - -func (portal *PortalInternals) GetTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) { - return (*Portal)(portal).getTargetUser(ctx, userID) -} - -func (portal *PortalInternals) HandleMatrixAcceptMessageRequest(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixAcceptMessageRequest(ctx, sender, origSender, evt) -} - -func (portal *PortalInternals) AutoAcceptMessageRequest(ctx context.Context, evt *event.Event, sender *UserLogin, origSender *OrigSender, caps *event.RoomFeatures) error { - return (*Portal)(portal).autoAcceptMessageRequest(ctx, evt, sender, origSender, caps) -} - -func (portal *PortalInternals) HandleMatrixDeleteChat(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixDeleteChat(ctx, sender, origSender, evt) -} - -func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult { - return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt, isStateRequest) -} - -func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult { - return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt, isStateRequest) -} - -func (portal *PortalInternals) HandleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixTombstone(ctx, evt) -} - -func (portal *PortalInternals) UpdateInfoAfterTombstone(ctx context.Context, senderUser *User) { - (*Portal)(portal).updateInfoAfterTombstone(ctx, senderUser) -} - -func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { - return (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) -} - -func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) { - return (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt) -} - -func (portal *PortalInternals) EnsureFunctionalMember(ctx context.Context, ghost *Ghost) { - (*Portal)(portal).ensureFunctionalMember(ctx, ghost) -} - -func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { - return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType) -} - -func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsgID networkid.MessageID, currentMsg *ConvertedMessage, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) { - return (*Portal)(portal).getRelationMeta(ctx, currentMsgID, currentMsg, isBatchSend) -} - -func (portal *PortalInternals) ApplyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) { - (*Portal)(portal).applyRelationMeta(ctx, content, replyTo, threadRoot, prevThreadEvent) -} - -func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) ([]*database.Message, EventHandlingResult) { - return (*Portal)(portal).sendConvertedMessage(ctx, id, intent, senderID, converted, ts, streamOrder, logContext) -} - -func (portal *PortalInternals) CheckPendingMessage(ctx context.Context, evt RemoteMessage) (bool, *database.Message) { - return (*Portal)(portal).checkPendingMessage(ctx, evt) -} - -func (portal *PortalInternals) HandleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) { - return (*Portal)(portal).handleRemoteUpsert(ctx, source, evt, existing) -} - -func (portal *PortalInternals) HandleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) { - return (*Portal)(portal).handleRemoteMessage(ctx, source, evt) -} - -func (portal *PortalInternals) SendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) { - (*Portal)(portal).sendRemoteErrorNotice(ctx, intent, err, ts, evtTypeName) -} - -func (portal *PortalInternals) HandleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult { - return (*Portal)(portal).handleRemoteEdit(ctx, source, evt) -} - -func (portal *PortalInternals) SendConvertedEdit(ctx context.Context, targetID networkid.MessageID, senderID networkid.UserID, converted *ConvertedEdit, intent MatrixAPI, ts time.Time, streamOrder int64) EventHandlingResult { - return (*Portal)(portal).sendConvertedEdit(ctx, targetID, senderID, converted, intent, ts, streamOrder) -} - -func (portal *PortalInternals) GetTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) { - return (*Portal)(portal).getTargetMessagePart(ctx, evt) -} - -func (portal *PortalInternals) GetTargetReaction(ctx context.Context, evt RemoteReactionRemove) (*database.Reaction, error) { - return (*Portal)(portal).getTargetReaction(ctx, evt) -} - -func (portal *PortalInternals) HandleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult { - return (*Portal)(portal).handleRemoteReactionSync(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult { - return (*Portal)(portal).handleRemoteReaction(ctx, source, evt) -} - -func (portal *PortalInternals) SendConvertedReaction(ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event) EventHandlingResult { - return (*Portal)(portal).sendConvertedReaction(ctx, senderID, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extraContent, logContext) -} - -func (portal *PortalInternals) GetIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) { - return (*Portal)(portal).getIntentForMXID(ctx, userID) -} - -func (portal *PortalInternals) HandleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult { - return (*Portal)(portal).handleRemoteReactionRemove(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult { - return (*Portal)(portal).handleRemoteMessageRemove(ctx, source, evt) -} - -func (portal *PortalInternals) RedactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time, reason string, dontRenderPlaceholder bool) EventHandlingResult { - return (*Portal)(portal).redactMessageParts(ctx, parts, intent, ts, reason, dontRenderPlaceholder) -} - -func (portal *PortalInternals) HandleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult { - return (*Portal)(portal).handleRemoteReadReceipt(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult { - return (*Portal)(portal).handleRemoteMarkUnread(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult { - return (*Portal)(portal).handleRemoteDeliveryReceipt(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult { - return (*Portal)(portal).handleRemoteTyping(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult { - return (*Portal)(portal).handleRemoteChatInfoChange(ctx, source, evt) -} - -func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult { - return (*Portal)(portal).handleRemoteChatResync(ctx, source, evt) -} - -func (portal *PortalInternals) FindOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { - return (*Portal)(portal).findOtherLogins(ctx, source) -} - -func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { - return (*Portal)(portal).handleRemoteChatDelete(ctx, source, evt) -} - -func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { - return (*Portal)(portal).updateName(ctx, name, sender, ts, excludeFromTimeline) -} - -func (portal *PortalInternals) UpdateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { - return (*Portal)(portal).updateTopic(ctx, topic, sender, ts, excludeFromTimeline) -} - -func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool { - return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts, excludeFromTimeline) -} - -func (portal *PortalInternals) GetBridgeInfoStateKey() string { - return (*Portal)(portal).getBridgeInfoStateKey() -} - -func (portal *PortalInternals) GetBridgeInfo() (string, event.BridgeEventContent) { - return (*Portal)(portal).getBridgeInfo() -} - -func (portal *PortalInternals) SendStateWithIntentOrBot(ctx context.Context, sender MatrixAPI, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { - return (*Portal)(portal).sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, content, ts) -} - -func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool, extra map[string]any) bool { - return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline, extra) -} - -func (portal *PortalInternals) RevertRoomMeta(ctx context.Context, evt *event.Event) { - (*Portal)(portal).revertRoomMeta(ctx, evt) -} - -func (portal *PortalInternals) GetInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { - return (*Portal)(portal).getInitialMemberList(ctx, members, source, pl) -} - -func (portal *PortalInternals) UpdateOtherUser(ctx context.Context, members *ChatMemberList) (changed bool) { - return (*Portal)(portal).updateOtherUser(ctx, members) -} - -func (portal *PortalInternals) RoomIsPublic(ctx context.Context) bool { - return (*Portal)(portal).roomIsPublic(ctx) -} - -func (portal *PortalInternals) SyncParticipants(ctx context.Context, members *ChatMemberList, source *UserLogin, sender MatrixAPI, ts time.Time) error { - return (*Portal)(portal).syncParticipants(ctx, members, source, sender, ts) -} - -func (portal *PortalInternals) UpdateUserLocalInfo(ctx context.Context, info *UserLocalPortalInfo, source *UserLogin, didJustCreate bool) { - (*Portal)(portal).updateUserLocalInfo(ctx, info, source, didJustCreate) -} - -func (portal *PortalInternals) UpdateParent(ctx context.Context, newParentID networkid.PortalID, source *UserLogin) bool { - return (*Portal)(portal).updateParent(ctx, newParentID, source) -} - -func (portal *PortalInternals) LockedUpdateInfoFromGhost(ctx context.Context, ghost *Ghost) { - (*Portal)(portal).lockedUpdateInfoFromGhost(ctx, ghost) -} - -func (portal *PortalInternals) CreateMatrixRoomInLoop(ctx context.Context, source *UserLogin, info *ChatInfo, backfillBundle any) error { - return (*Portal)(portal).createMatrixRoomInLoop(ctx, source, info, backfillBundle) -} - -func (portal *PortalInternals) AddToUserSpaces(ctx context.Context) { - (*Portal)(portal).addToUserSpaces(ctx) -} - -func (portal *PortalInternals) SafeDBDelete(ctx context.Context) error { - return (*Portal)(portal).safeDBDelete(ctx) -} - -func (portal *PortalInternals) RemoveMXID(ctx context.Context, alreadyLocked bool) error { - return (*Portal)(portal).removeMXID(ctx, alreadyLocked) -} - -func (portal *PortalInternals) RemoveInPortalCache(ctx context.Context) { - (*Portal)(portal).removeInPortalCache(ctx) -} - -func (portal *PortalInternals) UnlockedDelete(ctx context.Context) error { - return (*Portal)(portal).unlockedDelete(ctx) -} - -func (portal *PortalInternals) UnlockedDeleteCache() { - (*Portal)(portal).unlockedDeleteCache() -} - -func (portal *PortalInternals) DoForwardBackfill(ctx context.Context, source *UserLogin, lastMessage *database.Message, bundledData any) { - (*Portal)(portal).doForwardBackfill(ctx, source, lastMessage, bundledData) -} - -func (portal *PortalInternals) DoBackwardsBackfill(ctx context.Context, source *UserLogin, task *database.BackfillTask, resp *FetchMessagesResponse) (bool, error) { - return (*Portal)(portal).doBackwardsBackfill(ctx, source, task, resp) -} - -func (portal *PortalInternals) FetchThreadBackfill(ctx context.Context, source *UserLogin, anchor *database.Message) *FetchMessagesResponse { - return (*Portal)(portal).fetchThreadBackfill(ctx, source, anchor) -} - -func (portal *PortalInternals) DoThreadBackfill(ctx context.Context, source *UserLogin, threadID networkid.MessageID) error { - return (*Portal)(portal).doThreadBackfill(ctx, source, threadID) -} - -func (portal *PortalInternals) CutoffMessages(ctx context.Context, messages []*BackfillMessage, aggressiveDedup, forward bool, lastMessage *database.Message) []*BackfillMessage { - return (*Portal)(portal).cutoffMessages(ctx, messages, aggressiveDedup, forward, lastMessage) -} - -func (portal *PortalInternals) SendBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, forceForward, markRead, inThread bool, done func()) error { - return (*Portal)(portal).sendBackfill(ctx, source, messages, forceForward, markRead, inThread, done) -} - -func (portal *PortalInternals) CompileBatchMessage(ctx context.Context, source *UserLogin, msg *BackfillMessage, out *compileBatchOutput, inThread bool) { - (*Portal)(portal).compileBatchMessage(ctx, source, msg, out, inThread) -} - -func (portal *PortalInternals) FetchThreadInsideBatch(ctx context.Context, source *UserLogin, dbMsg *database.Message, out *compileBatchOutput) { - (*Portal)(portal).fetchThreadInsideBatch(ctx, source, dbMsg, out) -} - -func (portal *PortalInternals) SendBatch(ctx context.Context, source *UserLogin, messages []*BackfillMessage, forceForward, markRead, inThread bool) error { - return (*Portal)(portal).sendBatch(ctx, source, messages, forceForward, markRead, inThread) -} - -func (portal *PortalInternals) SendLegacyBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, markRead bool) error { - return (*Portal)(portal).sendLegacyBackfill(ctx, source, messages, markRead) -} - -func (portal *PortalInternals) UnlockedReID(ctx context.Context, target networkid.PortalKey) error { - return (*Portal)(portal).unlockedReID(ctx, target) -} - -func (portal *PortalInternals) CreateParentAndAddToSpace(ctx context.Context, source *UserLogin) { - (*Portal)(portal).createParentAndAddToSpace(ctx, source) -} - -func (portal *PortalInternals) AddToParentSpaceAndSave(ctx context.Context, save bool) { - (*Portal)(portal).addToParentSpaceAndSave(ctx, save) -} - -func (portal *PortalInternals) ToggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error { - return (*Portal)(portal).toggleSpace(ctx, spaceID, canonical, remove) -} diff --git a/mautrix-patched/bridgev2/portalinternal_generate.go b/mautrix-patched/bridgev2/portalinternal_generate.go deleted file mode 100644 index 2ac6c898..00000000 --- a/mautrix-patched/bridgev2/portalinternal_generate.go +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build ignore - -package main - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "os" - "strings" - - "go.mau.fi/util/exerrors" -) - -const header = `// GENERATED BY portalinternal_generate.go; DO NOT EDIT - -//go:generate go run portalinternal_generate.go -//go:generate goimports -local maunium.net/go/mautrix -w portalinternal.go - -package bridgev2 - -` -const postImportHeader = ` -type PortalInternals Portal - -// Deprecated: portal internals should be used carefully and only when necessary. -func (portal *Portal) Internal() *PortalInternals { - return (*PortalInternals)(portal) -} -` - -func getTypeName(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.Ident: - return e.Name - case *ast.StarExpr: - return "*" + getTypeName(e.X) - case *ast.ArrayType: - return "[]" + getTypeName(e.Elt) - case *ast.MapType: - return fmt.Sprintf("map[%s]%s", getTypeName(e.Key), getTypeName(e.Value)) - case *ast.ChanType: - return fmt.Sprintf("chan %s", getTypeName(e.Value)) - case *ast.FuncType: - var params []string - for _, param := range e.Params.List { - params = append(params, getTypeName(param.Type)) - } - var results []string - if e.Results != nil { - for _, result := range e.Results.List { - results = append(results, getTypeName(result.Type)) - } - } - return fmt.Sprintf("func(%s) %s", strings.Join(params, ", "), strings.Join(results, ", ")) - case *ast.SelectorExpr: - return fmt.Sprintf("%s.%s", getTypeName(e.X), e.Sel.Name) - default: - panic(fmt.Errorf("unknown type %T", e)) - } -} - -var write func(str string) -var writef func(format string, args ...any) - -func main() { - fset := token.NewFileSet() - fileNames := []string{"portal.go", "portalbackfill.go", "portalreid.go", "space.go", "matrixinvite.go"} - files := make([]*ast.File, len(fileNames)) - for i, name := range fileNames { - files[i] = exerrors.Must(parser.ParseFile(fset, name, nil, parser.SkipObjectResolution)) - } - file := exerrors.Must(os.OpenFile("portalinternal.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)) - write = func(str string) { - exerrors.Must(file.WriteString(str)) - } - writef = func(format string, args ...any) { - exerrors.Must(fmt.Fprintf(file, format, args...)) - } - write(header) - write("import (\n") - for _, i := range files[0].Imports { - write("\t") - if i.Name != nil { - writef("%s ", i.Name.Name) - } - writef("%s\n", i.Path.Value) - } - write(")\n") - write(postImportHeader) - for _, f := range files { - processFile(f) - } - exerrors.PanicIfNotNil(file.Close()) -} - -func processFile(f *ast.File) { - ast.Inspect(f, func(node ast.Node) (retVal bool) { - retVal = true - funcDecl, ok := node.(*ast.FuncDecl) - if !ok || funcDecl.Name.IsExported() { - return - } - if funcDecl.Recv == nil || len(funcDecl.Recv.List) == 0 || len(funcDecl.Recv.List[0].Names) == 0 || - funcDecl.Recv.List[0].Names[0].Name != "portal" { - return - } - writef("\nfunc (portal *PortalInternals) %s%s(", strings.ToUpper(funcDecl.Name.Name[0:1]), funcDecl.Name.Name[1:]) - for i, param := range funcDecl.Type.Params.List { - if i != 0 { - write(", ") - } - for j, name := range param.Names { - if j != 0 { - write(", ") - } - write(name.Name) - } - if len(param.Names) > 0 { - write(" ") - } - write(getTypeName(param.Type)) - } - write(") ") - if funcDecl.Type.Results != nil && len(funcDecl.Type.Results.List) > 0 { - needsParentheses := len(funcDecl.Type.Results.List) > 1 || len(funcDecl.Type.Results.List[0].Names) > 0 - if needsParentheses { - write("(") - } - for i, result := range funcDecl.Type.Results.List { - if i != 0 { - write(", ") - } - for j, name := range result.Names { - if j != 0 { - write(", ") - } - write(name.Name) - } - if len(result.Names) > 0 { - write(" ") - } - write(getTypeName(result.Type)) - } - if needsParentheses { - write(")") - } - write(" ") - } - write("{\n\t") - if funcDecl.Type.Results != nil { - write("return ") - } - writef("(*Portal)(portal).%s(", funcDecl.Name.Name) - for i, param := range funcDecl.Type.Params.List { - for j, name := range param.Names { - if i != 0 || j != 0 { - write(", ") - } - write(name.Name) - } - } - write(")\n}\n") - return - }) -} diff --git a/mautrix-patched/bridgev2/portalreid.go b/mautrix-patched/bridgev2/portalreid.go deleted file mode 100644 index c976d97c..00000000 --- a/mautrix-patched/bridgev2/portalreid.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "fmt" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" -) - -type ReIDResult int - -const ( - ReIDResultError ReIDResult = iota - ReIDResultNoOp - ReIDResultSourceDeleted - ReIDResultSourceReIDd - ReIDResultTargetDeletedAndSourceReIDd - ReIDResultSourceTombstonedIntoTarget -) - -func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.PortalKey) (ReIDResult, *Portal, error) { - if source == target { - return ReIDResultError, nil, fmt.Errorf("illegal re-ID call: source and target are the same") - } - log := zerolog.Ctx(ctx).With(). - Str("action", "re-id portal"). - Stringer("source_portal_key", source). - Stringer("target_portal_key", target). - Logger() - ctx = log.WithContext(ctx) - defer func() { - log.Debug().Msg("Finished handling portal re-ID") - }() - acquireCacheLock := func() { - if !br.cacheLock.TryLock() { - log.Debug().Msg("Waiting for global cache lock") - br.cacheLock.Lock() - log.Debug().Msg("Acquired global cache lock after waiting") - } else { - log.Trace().Msg("Acquired global cache lock without waiting") - } - } - log.Debug().Msg("Re-ID'ing portal") - sourcePortal, err := br.GetExistingPortalByKey(ctx, source) - if err != nil { - return ReIDResultError, nil, fmt.Errorf("failed to get source portal: %w", err) - } else if sourcePortal == nil { - log.Debug().Msg("Source portal not found, re-ID is no-op") - return ReIDResultNoOp, nil, nil - } - if !sourcePortal.roomCreateLock.TryLock() { - if cancelCreate := sourcePortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { - (*cancelCreate)() - } - log.Debug().Msg("Waiting for source portal room creation lock") - sourcePortal.roomCreateLock.Lock() - log.Debug().Msg("Acquired source portal room creation lock after waiting") - } - defer sourcePortal.roomCreateLock.Unlock() - if sourcePortal.MXID == "" { - log.Info().Msg("Source portal doesn't have Matrix room, deleting row") - err = sourcePortal.unlockedDelete(ctx) - if err != nil { - return ReIDResultError, nil, fmt.Errorf("failed to delete source portal: %w", err) - } - return ReIDResultSourceDeleted, nil, nil - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("source_portal_mxid", sourcePortal.MXID) - }) - - acquireCacheLock() - targetPortal, err := br.UnlockedGetPortalByKey(ctx, target, true) - if err != nil { - br.cacheLock.Unlock() - return ReIDResultError, nil, fmt.Errorf("failed to get target portal: %w", err) - } - if targetPortal == nil { - log.Info().Msg("Target portal doesn't exist, re-ID'ing source portal") - err = sourcePortal.unlockedReID(ctx, target) - br.cacheLock.Unlock() - if err != nil { - return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal: %w", err) - } - return ReIDResultSourceReIDd, sourcePortal, nil - } - br.cacheLock.Unlock() - - if !targetPortal.roomCreateLock.TryLock() { - if cancelCreate := targetPortal.cancelRoomCreate.Swap(nil); cancelCreate != nil { - (*cancelCreate)() - } - log.Debug().Msg("Waiting for target portal room creation lock") - targetPortal.roomCreateLock.Lock() - log.Debug().Msg("Acquired target portal room creation lock after waiting") - } - defer targetPortal.roomCreateLock.Unlock() - if targetPortal.MXID == "" { - log.Info().Msg("Target portal row exists, but doesn't have a Matrix room. Deleting target portal row and re-ID'ing source portal") - acquireCacheLock() - defer br.cacheLock.Unlock() - err = targetPortal.unlockedDelete(ctx) - if err != nil { - return ReIDResultError, nil, fmt.Errorf("failed to delete target portal: %w", err) - } - err = sourcePortal.unlockedReID(ctx, target) - if err != nil { - return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal after deleting target: %w", err) - } - return ReIDResultTargetDeletedAndSourceReIDd, sourcePortal, nil - } else { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("target_portal_mxid", targetPortal.MXID) - }) - log.Info().Msg("Both target and source portals have Matrix rooms, tombstoning source portal") - sourcePortal.removeInPortalCache(ctx) - acquireCacheLock() - defer br.cacheLock.Unlock() - err = sourcePortal.unlockedDelete(ctx) - if err != nil { - return ReIDResultError, nil, fmt.Errorf("failed to delete source portal row: %w", err) - } - go func() { - _, err := br.Bot.SendState(ctx, sourcePortal.MXID, event.StateTombstone, "", &event.Content{ - Parsed: &event.TombstoneEventContent{ - Body: "This room has been merged", - ReplacementRoom: targetPortal.MXID, - }, - }, time.Now()) - if err != nil { - log.Err(err).Msg("Failed to send tombstone to source portal room") - } - err = br.Bot.DeleteRoom(ctx, sourcePortal.MXID, err == nil) - if err != nil { - log.Err(err).Msg("Failed to delete source portal room") - } - }() - return ReIDResultSourceTombstonedIntoTarget, targetPortal, nil - } -} - -func (portal *Portal) unlockedReID(ctx context.Context, target networkid.PortalKey) error { - err := portal.Bridge.DB.Portal.ReID(ctx, portal.PortalKey, target) - if err != nil { - return err - } - delete(portal.Bridge.portalsByKey, portal.PortalKey) - portal.Bridge.portalsByKey[target] = portal - portal.PortalKey = target - return nil -} diff --git a/mautrix-patched/bridgev2/provisionutil/creategroup.go b/mautrix-patched/bridgev2/provisionutil/creategroup.go deleted file mode 100644 index 44b87289..00000000 --- a/mautrix-patched/bridgev2/provisionutil/creategroup.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package provisionutil - -import ( - "context" - "slices" - - "github.com/rs/zerolog" - "go.mau.fi/util/exfmt" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type RespCreateGroup struct { - ID networkid.PortalID `json:"id"` - MXID id.RoomID `json:"mxid"` - Portal *bridgev2.Portal `json:"-"` - - FailedParticipants map[networkid.UserID]*bridgev2.CreateChatFailedParticipant `json:"failed_participants,omitempty"` -} - -func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) { - api, ok := login.Client.(bridgev2.GroupCreatingNetworkAPI) - if !ok { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups")) - } - zerolog.Ctx(ctx).Debug(). - Any("create_params", params). - Msg("Creating group chat on remote network") - caps := login.Bridge.Network.GetCapabilities() - typeSpec, validType := caps.Provisioning.GroupCreation[params.Type] - if !validType { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("Unrecognized group type %s", params.Type)) - } - if len(params.Participants) < typeSpec.Participants.MinLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %s (in addition to yourself)", exfmt.Pluralizable("member")(typeSpec.Participants.MinLength))) - } else if typeSpec.Participants.MaxLength > 0 && len(params.Participants) > typeSpec.Participants.MaxLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at most %s", exfmt.Pluralizable("member")(typeSpec.Participants.MaxLength))) - } - userIDValidatingNetwork, uidValOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) - for i, participant := range params.Participants { - parsedParticipant, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(participant)) - if ok { - participant = parsedParticipant - params.Participants[i] = participant - } - if !typeSpec.Participants.SkipIdentifierValidation { - if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant)) - } - } - } - origParticipantCount := len(params.Participants) - params.Participants = slices.DeleteFunc(params.Participants, func(userID networkid.UserID) bool { - return api.IsThisUser(ctx, userID) - }) - if len(params.Participants) != origParticipantCount { - zerolog.Ctx(ctx).Debug(). - Int("original_count", origParticipantCount). - Int("filtered_count", len(params.Participants)). - Msg("Removed self from participants list") - } - if len(params.Participants) < typeSpec.Participants.MinLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %s (in addition to yourself)", exfmt.Pluralizable("member")(typeSpec.Participants.MinLength))) - } - if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required")) - } else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at least %d characters", typeSpec.Name.MinLength)) - } else if typeSpec.Name.MaxLength > 0 && nameLen > typeSpec.Name.MaxLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at most %d characters", typeSpec.Name.MaxLength)) - } - if (params.Avatar == nil || params.Avatar.URL == "") && typeSpec.Avatar.Required { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Avatar is required")) - } - if (params.Topic == nil || params.Topic.Topic == "") && typeSpec.Topic.Required { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic is required")) - } else if topicLen := len(ptr.Val(params.Topic).Topic); topicLen > 0 && topicLen < typeSpec.Topic.MinLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at least %d characters", typeSpec.Topic.MinLength)) - } else if typeSpec.Topic.MaxLength > 0 && topicLen > typeSpec.Topic.MaxLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at most %d characters", typeSpec.Topic.MaxLength)) - } - if (params.Disappear == nil || params.Disappear.Timer.Duration == 0) && typeSpec.Disappear.Required { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Disappearing timer is required")) - } else if !typeSpec.Disappear.DisappearSettings.Supports(params.Disappear) { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Unsupported value for disappearing timer")) - } - if params.Username == "" && typeSpec.Username.Required { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username is required")) - } else if len(params.Username) > 0 && len(params.Username) < typeSpec.Username.MinLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at least %d characters", typeSpec.Username.MinLength)) - } else if typeSpec.Username.MaxLength > 0 && len(params.Username) > typeSpec.Username.MaxLength { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at most %d characters", typeSpec.Username.MaxLength)) - } - if params.Parent == nil && typeSpec.Parent.Required { - return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Parent is required")) - } - resp, err := api.CreateGroup(ctx, params) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create group") - return nil, err - } - if resp.PortalKey.IsEmpty() { - return nil, ErrNoPortalKey - } - zerolog.Ctx(ctx).Debug(). - Object("portal_key", resp.PortalKey). - Msg("Successfully created group on remote network") - if resp.Portal == nil { - resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") - return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) - } - } - if resp.Portal.MXID == "" { - err = resp.Portal.CreateMatrixRoom(ctx, login, resp.PortalInfo) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") - return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) - } - } - for key, fp := range resp.FailedParticipants { - if fp.InviteEventType == "" { - fp.InviteEventType = event.EventMessage.Type - } - if fp.UserMXID == "" { - ghost, err := login.Bridge.GetGhostByID(ctx, key) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for failed participant") - } else if ghost != nil { - fp.UserMXID = ghost.Intent.GetMXID() - } - } - if fp.DMRoomMXID == "" { - portal, err := login.Bridge.GetDMPortal(ctx, login.ID, key) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM portal for failed participant") - } else if portal != nil { - fp.DMRoomMXID = portal.MXID - } - } - } - return &RespCreateGroup{ - ID: resp.Portal.ID, - MXID: resp.Portal.MXID, - Portal: resp.Portal, - - FailedParticipants: resp.FailedParticipants, - }, nil -} diff --git a/mautrix-patched/bridgev2/provisionutil/imagepack.go b/mautrix-patched/bridgev2/provisionutil/imagepack.go deleted file mode 100644 index 3165f663..00000000 --- a/mautrix-patched/bridgev2/provisionutil/imagepack.go +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package provisionutil - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "maps" - "slices" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exmaps" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type RespImagePackSavedToRoom struct { - EventID id.EventID `json:"event_id"` - RoomID id.RoomID `json:"room_id"` - StateKey string `json:"state_key"` - EventIDs []id.EventID `json:"event_ids,omitempty"` - StateKeys []string `json:"state_keys,omitempty"` -} - -type spaceableNetworkAPI interface { - bridgev2.NetworkAPI - GetSpaceRoom() id.RoomID -} - -func actuallyImportImagePack(ctx context.Context, login *bridgev2.UserLogin, packURL string) (*bridgev2.ImportedImagePack, error) { - api, ok := login.Client.(bridgev2.StickerImportingNetworkAPI) - if !ok { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support importing image packs")) - } - resp, err := api.DownloadImagePack(ctx, packURL) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("pack_url", packURL).Msg("Failed to download image pack") - if !errors.Is(err, mautrix.MNotFound) { - login.TrackAnalytics("Image Pack Import Fail", map[string]any{}) - } - return nil, err - } - if resp.Shortcode == "" && resp.Content.Metadata.BridgedPack != nil { - resp.Shortcode = resp.Content.Metadata.BridgedPack.URL - } - return resp, nil -} - -func PreviewImagePack(ctx context.Context, login *bridgev2.UserLogin, packURL string) (*event.Content, error) { - resp, err := actuallyImportImagePack(ctx, login, packURL) - if err != nil { - return nil, err - } - shortcodeHash := sha256.Sum256([]byte(resp.Shortcode)) - login.TrackAnalytics("Image Pack Previewed", map[string]any{ - "shortcode_hash": hex.EncodeToString(shortcodeHash[:16]), - }) - return &event.Content{ - Parsed: resp.Content, - Raw: resp.Extra, - }, nil -} - -func ImportImagePack(ctx context.Context, login *bridgev2.UserLogin, packURL string) (*RespImagePackSavedToRoom, error) { - spaceRoom, err := login.GetSpaceRoom(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get space room for user") - return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get space room for user")) - } else if spaceRoom == "" { - // Small hack to allow importing emojis on Slack where there's a shared team space portal - // instead of individual personal filtering spaces. - spaceableAPI, ok := login.Client.(spaceableNetworkAPI) - if ok && spaceableAPI.GetSpaceRoom() != "" { - spaceRoom = spaceableAPI.GetSpaceRoom() - } else { - return nil, bridgev2.RespError(mautrix.MNotFound.WithMessage("Can't import image pack to space when personal filtering spaces are disabled")) - } - } - resp, err := actuallyImportImagePack(ctx, login, packURL) - if err != nil { - return nil, err - } - var eventIDs []id.EventID - var stateKeys []string - packs, err := SplitPack(resp) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to split image pack into multiple state events") - return nil, fmt.Errorf("failed to split image pack into multiple state events: %w", err) - } - if len(packs) > 1 { - zerolog.Ctx(ctx).Info(). - Int("pack_count", len(packs)). - Msg("Split pack into parts") - } - for i, content := range packs { - stateKey := resp.Shortcode - if i > 0 { - stateKey = fmt.Sprintf("%s.%03d", resp.Shortcode, i) - } - zerolog.Ctx(ctx).Trace().Str("state_key", stateKey).RawJSON("pack_data", content).Msg("Sending pack") - sendResp, err := login.Bridge.Bot.SendState(ctx, spaceRoom, event.StateImagePack, stateKey, &event.Content{VeryRaw: content}, time.Now()) - if err != nil { - zerolog.Ctx(ctx).Err(err).Int("pack_idx", i).Msg("Failed to send image pack state event to space") - return nil, fmt.Errorf("failed to send image pack state event #%d to space: %w", i+1, err) - } - eventIDs = append(eventIDs, sendResp.EventID) - stateKeys = append(stateKeys, stateKey) - } - shortcodeHash := sha256.Sum256([]byte(resp.Shortcode)) - login.TrackAnalytics("Image Pack Imported", map[string]any{ - "shortcode_hash": hex.EncodeToString(shortcodeHash[:16]), - }) - return &RespImagePackSavedToRoom{ - RoomID: spaceRoom, - EventID: eventIDs[0], - EventIDs: eventIDs, - StateKey: stateKeys[0], - StateKeys: stateKeys, - }, nil -} - -var MaxPackBytes = 62 * 1024 - -func SplitPack(resp *bridgev2.ImportedImagePack) ([]json.RawMessage, error) { - fullPack, err := json.Marshal(&event.Content{ - Parsed: resp.Content, - Raw: resp.Extra, - }) - if err != nil { - return nil, fmt.Errorf("failed to marshal base content: %w", err) - } - if len(fullPack) < MaxPackBytes { - return []json.RawMessage{fullPack}, nil - } - - baseContent := exmaps.NonNilClone(resp.Extra) - baseContent["pack"] = resp.Content.Metadata - baseContent["images"] = map[string]json.RawMessage{} - baseContent["fi.mau.combined_pack_key"] = resp.Shortcode - baseContent["fi.mau.combined_pack_index"] = 0 - baseContent["fi.mau.combined_pack_count"] = 0 - basePack, err := json.Marshal(baseContent) - if err != nil { - return nil, fmt.Errorf("failed to marshal base content: %w", err) - } - - imageKeys := make([]string, 0, len(resp.Content.Images)) - imageJSONs := make(map[string]json.RawMessage, len(resp.Content.Images)) - for key, image := range resp.Content.Images { - imageKeys = append(imageKeys, key) - imageJSONs[key], err = json.Marshal(image) - if err != nil { - return nil, fmt.Errorf("failed to marshal %s: %w", key, err) - } - } - - slices.Sort(imageKeys) - currentPackImages := make(map[string]json.RawMessage) - currentSize := len(basePack) - packs := make([]map[string]any, 0) - compilePack := func() { - if len(currentPackImages) == 0 { - return - } - newPack := maps.Clone(baseContent) - newPack["images"] = currentPackImages - newPack["fi.mau.combined_pack_index"] = len(packs) - meta := resp.Content.Metadata - meta.DisplayName = fmt.Sprintf("%s (part %d)", meta.DisplayName, len(packs)+1) - newPack["pack"] = meta - packs = append(packs, newPack) - currentPackImages = make(map[string]json.RawMessage) - currentSize = len(basePack) - } - if currentSize > MaxPackBytes/3 { - return nil, fmt.Errorf("pack metadata is too large: %d bytes", currentSize) - } - for _, key := range imageKeys { - nextImg := imageJSONs[key] - nextSize := len(nextImg) + len(key) + 4 - if currentSize+nextSize > MaxPackBytes { - compilePack() - } - currentPackImages[key] = nextImg - currentSize += nextSize - } - compilePack() - packJSONs := make([]json.RawMessage, len(packs)) - for i, pack := range packs { - pack["fi.mau.combined_pack_count"] = len(packs) - packJSONs[i], err = json.Marshal(pack) - if err != nil { - return nil, fmt.Errorf("failed to marshal pack %d: %w", i, err) - } - } - return packJSONs, err -} - -func ListImagePacks(ctx context.Context, login *bridgev2.UserLogin) ([]*event.ImagePackMetadata, error) { - api, ok := login.Client.(bridgev2.StickerImportingNetworkAPI) - if !ok { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support importing image packs")) - } - return api.ListImagePacks(ctx) -} diff --git a/mautrix-patched/bridgev2/provisionutil/listcontacts.go b/mautrix-patched/bridgev2/provisionutil/listcontacts.go deleted file mode 100644 index ce163e67..00000000 --- a/mautrix-patched/bridgev2/provisionutil/listcontacts.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package provisionutil - -import ( - "context" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" -) - -type RespGetContactList struct { - Contacts []*RespResolveIdentifier `json:"contacts"` -} - -type RespSearchUsers struct { - Results []*RespResolveIdentifier `json:"results"` -} - -func GetContactList(ctx context.Context, login *bridgev2.UserLogin) (*RespGetContactList, error) { - api, ok := login.Client.(bridgev2.ContactListingNetworkAPI) - if !ok { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support listing contacts")) - } - resp, err := api.GetContactList(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list") - return nil, err - } - return &RespGetContactList{ - Contacts: processResolveIdentifiers(ctx, login.Bridge, resp, false), - }, nil -} - -func SearchUsers(ctx context.Context, login *bridgev2.UserLogin, query string) (*RespSearchUsers, error) { - api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI) - if !ok { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support searching for users")) - } - resp, err := api.SearchUsers(ctx, query) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list") - return nil, err - } - return &RespSearchUsers{ - Results: processResolveIdentifiers(ctx, login.Bridge, resp, true), - }, nil -} - -func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp []*bridgev2.ResolveIdentifierResponse, syncInfo bool) (apiResp []*RespResolveIdentifier) { - apiResp = make([]*RespResolveIdentifier, len(resp)) - for i, contact := range resp { - apiContact := &RespResolveIdentifier{ - ID: contact.UserID, - } - apiResp[i] = apiContact - if contact.UserInfo != nil { - if contact.UserInfo.Name != nil { - apiContact.Name = *contact.UserInfo.Name - } - if contact.UserInfo.Identifiers != nil { - apiContact.Identifiers = contact.UserInfo.Identifiers - } - } - if contact.Ghost != nil { - if syncInfo && contact.UserInfo != nil { - contact.Ghost.UpdateInfo(ctx, contact.UserInfo) - } - if contact.Ghost.Name != "" { - apiContact.Name = contact.Ghost.Name - } - if len(contact.Ghost.Identifiers) >= len(apiContact.Identifiers) { - apiContact.Identifiers = contact.Ghost.Identifiers - } - apiContact.AvatarURL = contact.Ghost.AvatarMXC - apiContact.MXID = contact.Ghost.Intent.GetMXID() - } - if contact.Chat != nil { - if contact.Chat.Portal == nil { - var err error - contact.Chat.Portal, err = br.GetPortalByKey(ctx, contact.Chat.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") - } - } - if contact.Chat.Portal != nil { - apiContact.DMRoomID = contact.Chat.Portal.MXID - } - } - } - return -} diff --git a/mautrix-patched/bridgev2/provisionutil/resolveidentifier.go b/mautrix-patched/bridgev2/provisionutil/resolveidentifier.go deleted file mode 100644 index cfc388d0..00000000 --- a/mautrix-patched/bridgev2/provisionutil/resolveidentifier.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package provisionutil - -import ( - "context" - "errors" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" -) - -type RespResolveIdentifier struct { - ID networkid.UserID `json:"id"` - Name string `json:"name,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - Identifiers []string `json:"identifiers,omitempty"` - MXID id.UserID `json:"mxid,omitempty"` - DMRoomID id.RoomID `json:"dm_room_mxid,omitempty"` - - Portal *bridgev2.Portal `json:"-"` - Ghost *bridgev2.Ghost `json:"-"` - JustCreated bool `json:"-"` -} - -var ErrNoPortalKey = errors.New("network API didn't return portal key for createChat request") - -func ResolveIdentifier( - ctx context.Context, - login *bridgev2.UserLogin, - identifier string, - createChat bool, -) (*RespResolveIdentifier, error) { - api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI) - if !ok { - return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers")) - } - var resp *bridgev2.ResolveIdentifierResponse - parsedUserID, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(identifier)) - validator, vOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork) - if ok && (!vOK || validator.ValidateUserID(parsedUserID)) { - ghost, err := login.Bridge.GetGhostByID(ctx, parsedUserID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost by ID") - return nil, err - } - resp = &bridgev2.ResolveIdentifierResponse{ - Ghost: ghost, - UserID: parsedUserID, - } - gdcAPI, ok := api.(bridgev2.GhostDMCreatingNetworkAPI) - if ok && createChat { - resp.Chat, err = gdcAPI.CreateChatWithGhost(ctx, ghost) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create chat") - return nil, err - } - } else if createChat || ghost.Name == "" { - zerolog.Ctx(ctx).Debug(). - Bool("create_chat", createChat). - Bool("has_name", ghost.Name != ""). - Msg("Falling back to resolving identifier") - resp = nil - identifier = string(parsedUserID) - } - } - if resp == nil { - var err error - resp, err = api.ResolveIdentifier(ctx, identifier, createChat) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to resolve identifier") - return nil, err - } else if resp == nil { - return nil, nil - } - } - apiResp := &RespResolveIdentifier{ - ID: resp.UserID, - Ghost: resp.Ghost, - } - if resp.Ghost != nil { - if resp.UserInfo != nil { - resp.Ghost.UpdateInfo(ctx, resp.UserInfo) - } - apiResp.Name = resp.Ghost.Name - apiResp.AvatarURL = resp.Ghost.AvatarMXC - apiResp.Identifiers = resp.Ghost.Identifiers - apiResp.MXID = resp.Ghost.Intent.GetMXID() - } else if resp.UserInfo != nil && resp.UserInfo.Name != nil { - apiResp.Name = *resp.UserInfo.Name - } - if resp.Chat != nil { - if resp.Chat.PortalKey.IsEmpty() { - return nil, ErrNoPortalKey - } - if resp.Chat.Portal == nil { - var err error - resp.Chat.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.Chat.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") - return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) - } - } - resp.Chat.Portal.CleanupOrphanedDM(ctx, login.UserMXID) - if createChat && resp.Chat.Portal.MXID == "" { - apiResp.JustCreated = true - err := resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") - return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) - } - } - apiResp.Portal = resp.Chat.Portal - apiResp.DMRoomID = resp.Chat.Portal.MXID - } - return apiResp, nil -} diff --git a/mautrix-patched/bridgev2/queue.go b/mautrix-patched/bridgev2/queue.go deleted file mode 100644 index f8e144fd..00000000 --- a/mautrix-patched/bridgev2/queue.go +++ /dev/null @@ -1,275 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func rejectInvite(ctx context.Context, evt *event.Event, intent MatrixAPI, reason string) { - resp, err := intent.SendState(ctx, evt.RoomID, event.StateMember, intent.GetMXID().String(), &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: reason, - }, - }, time.Time{}) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("room_id", evt.RoomID). - Stringer("inviter_id", evt.Sender). - Stringer("invitee_id", intent.GetMXID()). - Str("reason", reason). - Msg("Failed to reject invite") - } else { - zerolog.Ctx(ctx).Debug(). - Stringer("leave_event_id", resp.EventID). - Stringer("room_id", evt.RoomID). - Stringer("inviter_id", evt.Sender). - Stringer("invitee_id", intent.GetMXID()). - Str("reason", reason). - Msg("Rejected invite") - } -} - -func (br *Bridge) rejectInviteOnNoPermission(ctx context.Context, evt *event.Event, permType string) bool { - if evt.Type != event.StateMember || evt.Content.AsMember().Membership != event.MembershipInvite { - return false - } - userID := id.UserID(evt.GetStateKey()) - parsed, isGhost := br.Matrix.ParseGhostMXID(userID) - if userID != br.Bot.GetMXID() && !isGhost { - return false - } - var intent MatrixAPI - if userID == br.Bot.GetMXID() { - intent = br.Bot - } else { - intent = br.Matrix.GhostIntent(parsed) - } - rejectInvite(ctx, evt, intent, "You don't have permission to "+permType+" this bridge") - return true -} - -var ( - ErrEventSenderUserNotFound = WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage() - ErrNoPermissionToInteract = WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage() - ErrNoPermissionForCommands = WrapErrorInStatus(WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()) - ErrCantRelayStateRequest = WrapErrorInStatus(errors.New("relayed users can't use beeper state requests")).WithIsCertain(true).WithErrorAsMessage() -) - -func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventHandlingResult { - // TODO maybe HandleMatrixEvent would be more appropriate as this also handles bot invites and commands - - log := zerolog.Ctx(ctx) - var sender *User - if evt.Sender != "" { - var err error - sender, err = br.GetUserByMXID(ctx, evt.Sender) - if err != nil { - log.Err(err).Msg("Failed to get sender user for incoming Matrix event") - err = fmt.Errorf("%w: failed to get sender user: %w", ErrDatabaseError, err) - status := WrapErrorInStatus(err) - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return EventHandlingResultFailed.WithError(err) - } else if sender == nil { - log.Error().Msg("Couldn't get sender for incoming non-ephemeral Matrix event") - br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt)) - return EventHandlingResultFailed.WithError(ErrEventSenderUserNotFound) - } else if !sender.Permissions.SendEvents { - if !br.rejectInviteOnNoPermission(ctx, evt, "interact with") { - br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionToInteract, StatusEventInfoFromEvent(evt)) - } - return EventHandlingResultIgnored - } else if !sender.Permissions.Commands && br.rejectInviteOnNoPermission(ctx, evt, "send commands to") { - return EventHandlingResultIgnored - } - } else if evt.Type.Class != event.EphemeralEventType { - log.Error().Msg("Missing sender for incoming non-ephemeral Matrix event") - br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt)) - return EventHandlingResultIgnored - } - if evt.Type == event.EventMessage && sender != nil { - msg := evt.Content.AsMessage() - msg.RemoveReplyFallback() - msg.RemovePerMessageProfileFallback() - if strings.HasPrefix(msg.Body, br.Config.CommandPrefix) || evt.RoomID == sender.ManagementRoom { - if !sender.Permissions.Commands { - br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionForCommands, StatusEventInfoFromEvent(evt)) - return EventHandlingResultIgnored - } - go br.Commands.Handle( - ctx, - evt.RoomID, - evt.ID, - sender, - strings.TrimPrefix(msg.Body, br.Config.CommandPrefix+" "), - msg.RelatesTo.GetReplyTo(), - ) - return EventHandlingResultQueued - } - } - if evt.Type == event.StateMember && evt.GetStateKey() == br.Bot.GetMXID().String() && evt.Content.AsMember().Membership == event.MembershipInvite && sender != nil { - return br.handleBotInvite(ctx, evt, sender) - } else if sender != nil && evt.RoomID == sender.ManagementRoom { - if evt.Type == event.StateMember && evt.Content.AsMember().Membership == event.MembershipLeave && (evt.GetStateKey() == br.Bot.GetMXID().String() || evt.GetStateKey() == sender.MXID.String()) { - sender.ManagementRoom = "" - err := br.DB.User.Update(ctx, sender.User) - if err != nil { - log.Err(err).Msg("Failed to clear user's management room in database") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to clear user's management room: %w", err)) - } - log.Debug().Msg("Cleared user's management room due to leave event") - } - return EventHandlingResultSuccess - } - portal, err := br.GetPortalByMXID(ctx, evt.RoomID) - if err != nil { - log.Err(err).Msg("Failed to get portal for incoming Matrix event") - err = fmt.Errorf("%w: failed to get portal: %w", ErrDatabaseError, err) - status := WrapErrorInStatus(err) - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return EventHandlingResultFailed.WithError(err) - } else if portal != nil { - return portal.queueEvent(ctx, &portalMatrixEvent{ - evt: evt, - sender: sender, - }) - } else if evt.Type == event.StateMember && br.IsGhostMXID(id.UserID(evt.GetStateKey())) && evt.Content.AsMember().Membership == event.MembershipInvite && evt.Content.AsMember().IsDirect && sender != nil { - return br.handleGhostDMInvite(ctx, evt, sender) - } else if evt.Type == event.BeeperDeleteChat && sender != nil && sender.Permissions.Admin { - err = br.Bot.DeleteRoom(ctx, evt.RoomID, true) - if err != nil { - log.Err(err).Msg("Failed to delete non-portal room") - status := WrapErrorInStatus(err) - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return EventHandlingResultFailed.WithError(err) - } - log.Debug().Msg("Successfully deleted non-portal room after delete chat event") - return EventHandlingResultSuccess - } else { - status := WrapErrorInStatus(ErrNoPortal) - br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt)) - return EventHandlingResultIgnored - } -} - -type EventHandlingResult struct { - Success bool - Ignored bool - Queued bool - - SkipStateEcho bool - - // Error is an optional reason for failure. It is not required, Success may be false even without a specific error. - Error error - // Whether the Error should be sent as a MSS event. - SendMSS bool - - // EventID from the network - EventID id.EventID - // Stream order from the network - StreamOrder int64 -} - -func (ehr EventHandlingResult) WithEventID(id id.EventID) EventHandlingResult { - ehr.EventID = id - return ehr -} - -func (ehr EventHandlingResult) WithStreamOrder(order int64) EventHandlingResult { - ehr.StreamOrder = order - return ehr -} - -func (ehr EventHandlingResult) WithError(err error) EventHandlingResult { - if err == nil { - return ehr - } - ehr.Error = err - ehr.Success = false - return ehr -} - -func (ehr EventHandlingResult) WithMSS() EventHandlingResult { - ehr.SendMSS = true - return ehr -} - -func (ehr EventHandlingResult) WithSkipStateEcho(skip bool) EventHandlingResult { - ehr.SkipStateEcho = skip - return ehr -} - -func (ehr EventHandlingResult) WithMSSError(err error) EventHandlingResult { - if err == nil { - return ehr - } - return ehr.WithError(err).WithMSS() -} - -var ( - EventHandlingResultFailed = EventHandlingResult{} - EventHandlingResultQueued = EventHandlingResult{Success: true, Queued: true} - EventHandlingResultSuccess = EventHandlingResult{Success: true} - EventHandlingResultIgnored = EventHandlingResult{Success: true, Ignored: true} -) - -func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) EventHandlingResult { - return ul.Bridge.QueueRemoteEvent(ul, evt) -} - -func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) EventHandlingResult { - log := login.Log - ctx := log.WithContext(br.BackgroundCtx) - maybeUncertain, ok := evt.(RemoteEventWithUncertainPortalReceiver) - isUncertain := ok && maybeUncertain.PortalReceiverIsUncertain() - key := evt.GetPortalKey() - var portal *Portal - var err error - if isUncertain && !br.Config.SplitPortals { - portal, err = br.GetExistingPortalByKey(ctx, key) - if err == nil && portal == nil { - fetcher, ok := evt.(RemoteEventWithUncertainPortalReceiverFetcher) - if ok { - newKey := fetcher.FetchCertainPortalKey(ctx) - if !newKey.IsEmpty() { - key = newKey - portal, err = br.GetPortalByKey(ctx, newKey) - } - } - } - } else { - portal, err = br.GetPortalByKey(ctx, key) - } - if err != nil { - log.Err(err).Object("portal_key", key).Bool("uncertain_receiver", isUncertain). - Msg("Failed to get portal to handle remote event") - return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get portal: %w", err)) - } else if portal == nil { - log.Warn(). - Stringer("event_type", evt.GetType()). - Object("portal_key", key). - Bool("uncertain_receiver", isUncertain). - Msg("Portal not found to handle remote event") - return EventHandlingResultIgnored - } - // TODO put this in a better place, and maybe cache to avoid constant db queries - login.MarkInPortal(ctx, portal) - return portal.queueEvent(ctx, &portalRemoteEvent{ - evt: evt, - source: login, - }) -} diff --git a/mautrix-patched/bridgev2/simpleremoteevent.go b/mautrix-patched/bridgev2/simpleremoteevent.go deleted file mode 100644 index 66058e3e..00000000 --- a/mautrix-patched/bridgev2/simpleremoteevent.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" -) - -// SimpleRemoteEvent is a simple implementation of RemoteEvent that can be used with struct fields and some callbacks. -// -// Using this type is only recommended for simple bridges. More advanced ones should implement -// the remote event interfaces themselves by wrapping the remote network library event types. -// -// Deprecated: use the types in the simplevent package instead. -type SimpleRemoteEvent[T any] struct { - Type RemoteEventType - LogContext func(c zerolog.Context) zerolog.Context - PortalKey networkid.PortalKey - Data T - CreatePortal bool - - ID networkid.MessageID - Sender EventSender - TargetMessage networkid.MessageID - EmojiID networkid.EmojiID - Emoji string - ReactionDBMeta any - Timestamp time.Time - ChatInfoChange *ChatInfoChange - - ResyncChatInfo *ChatInfo - ResyncBackfillNeeded bool - - BackfillData *FetchMessagesResponse - - ConvertMessageFunc func(ctx context.Context, portal *Portal, intent MatrixAPI, data T) (*ConvertedMessage, error) - ConvertEditFunc func(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message, data T) (*ConvertedEdit, error) -} - -var ( - _ RemoteMessage = (*SimpleRemoteEvent[any])(nil) - _ RemoteEdit = (*SimpleRemoteEvent[any])(nil) - _ RemoteEventWithTimestamp = (*SimpleRemoteEvent[any])(nil) - _ RemoteReaction = (*SimpleRemoteEvent[any])(nil) - _ RemoteReactionWithMeta = (*SimpleRemoteEvent[any])(nil) - _ RemoteReactionRemove = (*SimpleRemoteEvent[any])(nil) - _ RemoteMessageRemove = (*SimpleRemoteEvent[any])(nil) - _ RemoteChatInfoChange = (*SimpleRemoteEvent[any])(nil) - _ RemoteChatResyncWithInfo = (*SimpleRemoteEvent[any])(nil) - _ RemoteChatResyncBackfill = (*SimpleRemoteEvent[any])(nil) - _ RemoteBackfill = (*SimpleRemoteEvent[any])(nil) -) - -func (sre *SimpleRemoteEvent[T]) AddLogContext(c zerolog.Context) zerolog.Context { - return sre.LogContext(c) -} - -func (sre *SimpleRemoteEvent[T]) GetPortalKey() networkid.PortalKey { - return sre.PortalKey -} - -func (sre *SimpleRemoteEvent[T]) GetTimestamp() time.Time { - if sre.Timestamp.IsZero() { - return time.Now() - } - return sre.Timestamp -} - -func (sre *SimpleRemoteEvent[T]) ConvertMessage(ctx context.Context, portal *Portal, intent MatrixAPI) (*ConvertedMessage, error) { - return sre.ConvertMessageFunc(ctx, portal, intent, sre.Data) -} - -func (sre *SimpleRemoteEvent[T]) ConvertEdit(ctx context.Context, portal *Portal, intent MatrixAPI, existing []*database.Message) (*ConvertedEdit, error) { - return sre.ConvertEditFunc(ctx, portal, intent, existing, sre.Data) -} - -func (sre *SimpleRemoteEvent[T]) GetID() networkid.MessageID { - return sre.ID -} - -func (sre *SimpleRemoteEvent[T]) GetSender() EventSender { - return sre.Sender -} - -func (sre *SimpleRemoteEvent[T]) GetTargetMessage() networkid.MessageID { - return sre.TargetMessage -} - -func (sre *SimpleRemoteEvent[T]) GetReactionEmoji() (string, networkid.EmojiID) { - return sre.Emoji, sre.EmojiID -} - -func (sre *SimpleRemoteEvent[T]) GetRemovedEmojiID() networkid.EmojiID { - return sre.EmojiID -} - -func (sre *SimpleRemoteEvent[T]) GetReactionDBMetadata() any { - return sre.ReactionDBMeta -} - -func (sre *SimpleRemoteEvent[T]) GetChatInfoChange(ctx context.Context) (*ChatInfoChange, error) { - return sre.ChatInfoChange, nil -} - -func (sre *SimpleRemoteEvent[T]) GetType() RemoteEventType { - return sre.Type -} - -func (sre *SimpleRemoteEvent[T]) ShouldCreatePortal() bool { - return sre.CreatePortal -} - -func (sre *SimpleRemoteEvent[T]) GetBackfillData(ctx context.Context, portal *Portal) (*FetchMessagesResponse, error) { - return sre.BackfillData, nil -} - -func (sre *SimpleRemoteEvent[T]) CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) { - return sre.ResyncBackfillNeeded, nil -} - -func (sre *SimpleRemoteEvent[T]) GetChatInfo(ctx context.Context, portal *Portal) (*ChatInfo, error) { - return sre.ResyncChatInfo, nil -} diff --git a/mautrix-patched/bridgev2/simplevent/chat.go b/mautrix-patched/bridgev2/simplevent/chat.go deleted file mode 100644 index e9dae59e..00000000 --- a/mautrix-patched/bridgev2/simplevent/chat.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package simplevent - -import ( - "context" - "time" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" -) - -// ChatResync is a simple implementation of [bridgev2.RemoteChatResync]. -// -// If GetChatInfoFunc is set, it will be used to get the chat info. Otherwise, ChatInfo will be used. -// -// If CheckNeedsBackfillFunc is set, it will be used to determine if backfill is required. -// Otherwise, the latest database message timestamp is compared to LatestMessageTS. -// -// All four fields are optional. -type ChatResync struct { - EventMeta - - ChatInfo *bridgev2.ChatInfo - GetChatInfoFunc func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) - - LatestMessageTS time.Time - CheckNeedsBackfillFunc func(ctx context.Context, latestMessage *database.Message) (bool, error) - BundledBackfillData any -} - -var ( - _ bridgev2.RemoteChatResync = (*ChatResync)(nil) - _ bridgev2.RemoteChatResyncWithInfo = (*ChatResync)(nil) - _ bridgev2.RemoteChatResyncBackfill = (*ChatResync)(nil) - _ bridgev2.RemoteChatResyncBackfillBundle = (*ChatResync)(nil) -) - -func (evt *ChatResync) CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) { - if evt.CheckNeedsBackfillFunc != nil { - return evt.CheckNeedsBackfillFunc(ctx, latestMessage) - } else if latestMessage == nil { - return !evt.LatestMessageTS.IsZero(), nil - } else { - return evt.LatestMessageTS.After(latestMessage.Timestamp), nil - } -} - -func (evt *ChatResync) GetBundledBackfillData() any { - return evt.BundledBackfillData -} - -func (evt *ChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - if evt.GetChatInfoFunc != nil { - return evt.GetChatInfoFunc(ctx, portal) - } - return evt.ChatInfo, nil -} - -// ChatDelete is a simple implementation of [bridgev2.RemoteChatDelete]. -type ChatDelete struct { - EventMeta - OnlyForMe bool - Children bool -} - -var _ bridgev2.RemoteChatDeleteWithChildren = (*ChatDelete)(nil) - -func (evt *ChatDelete) DeleteOnlyForMe() bool { - return evt.OnlyForMe -} - -func (evt *ChatDelete) DeleteChildren() bool { - return evt.Children -} - -// ChatInfoChange is a simple implementation of [bridgev2.RemoteChatInfoChange]. -type ChatInfoChange struct { - EventMeta - - ChatInfoChange *bridgev2.ChatInfoChange -} - -var _ bridgev2.RemoteChatInfoChange = (*ChatInfoChange)(nil) - -func (evt *ChatInfoChange) GetChatInfoChange(ctx context.Context) (*bridgev2.ChatInfoChange, error) { - return evt.ChatInfoChange, nil -} - -// Backfill is a simple implementation of [bridgev2.RemoteBackfill]. -type Backfill struct { - EventMeta - Data *bridgev2.FetchMessagesResponse - GetDataFunc func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.FetchMessagesResponse, error) -} - -var _ bridgev2.RemoteBackfill = (*Backfill)(nil) - -func (evt *Backfill) GetBackfillData(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.FetchMessagesResponse, error) { - if evt.GetDataFunc != nil { - return evt.GetDataFunc(ctx, portal) - } - return evt.Data, nil -} diff --git a/mautrix-patched/bridgev2/simplevent/message.go b/mautrix-patched/bridgev2/simplevent/message.go deleted file mode 100644 index 8f3a43b1..00000000 --- a/mautrix-patched/bridgev2/simplevent/message.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package simplevent - -import ( - "context" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" -) - -// Message is a simple implementation of [bridgev2.RemoteMessage], [bridgev2.RemoteEdit] and [bridgev2.RemoteMessageUpsert]. -type Message[T any] struct { - EventMeta - Data T - - ID networkid.MessageID - TransactionID networkid.TransactionID - TargetMessage networkid.MessageID - - ConvertMessageFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data T) (*bridgev2.ConvertedMessage, error) - ConvertEditFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data T) (*bridgev2.ConvertedEdit, error) - HandleExistingFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data T) (bridgev2.UpsertResult, error) -} - -var ( - _ bridgev2.RemoteMessage = (*Message[any])(nil) - _ bridgev2.RemoteEdit = (*Message[any])(nil) - _ bridgev2.RemoteMessageUpsert = (*Message[any])(nil) - _ bridgev2.RemoteMessageWithTransactionID = (*Message[any])(nil) -) - -func (evt *Message[T]) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { - return evt.ConvertMessageFunc(ctx, portal, intent, evt.Data) -} - -func (evt *Message[T]) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) { - return evt.ConvertEditFunc(ctx, portal, intent, existing, evt.Data) -} - -func (evt *Message[T]) HandleExisting(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) { - return evt.HandleExistingFunc(ctx, portal, intent, existing, evt.Data) -} - -func (evt *Message[T]) GetID() networkid.MessageID { - return evt.ID -} - -func (evt *Message[T]) GetTargetMessage() networkid.MessageID { - return evt.TargetMessage -} - -func (evt *Message[T]) GetTransactionID() networkid.TransactionID { - return evt.TransactionID -} - -// PreConvertedMessage is a simple implementation of [bridgev2.RemoteMessage] with pre-converted data. -type PreConvertedMessage struct { - EventMeta - Data *bridgev2.ConvertedMessage - ID networkid.MessageID - TransactionID networkid.TransactionID - - HandleExistingFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) -} - -var ( - _ bridgev2.RemoteMessage = (*PreConvertedMessage)(nil) - _ bridgev2.RemoteMessageUpsert = (*PreConvertedMessage)(nil) - _ bridgev2.RemoteMessageWithTransactionID = (*PreConvertedMessage)(nil) -) - -func (evt *PreConvertedMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { - return evt.Data, nil -} - -func (evt *PreConvertedMessage) GetID() networkid.MessageID { - return evt.ID -} - -func (evt *PreConvertedMessage) GetTransactionID() networkid.TransactionID { - return evt.TransactionID -} - -func (evt *PreConvertedMessage) HandleExisting(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) { - if evt.HandleExistingFunc == nil { - return bridgev2.UpsertResult{}, nil - } - return evt.HandleExistingFunc(ctx, portal, intent, existing) -} - -type MessageRemove struct { - EventMeta - - TargetMessage networkid.MessageID - OnlyForMe bool - HidePlaceholder bool -} - -var ( - _ bridgev2.RemoteMessageRemove = (*MessageRemove)(nil) - _ bridgev2.RemoteDeleteOnlyForMe = (*MessageRemove)(nil) - _ bridgev2.RemoteMessageRemoveWithoutPlaceholder = (*MessageRemove)(nil) -) - -func (evt *MessageRemove) GetTargetMessage() networkid.MessageID { - return evt.TargetMessage -} - -func (evt *MessageRemove) DeleteOnlyForMe() bool { - return evt.OnlyForMe -} - -func (evt *MessageRemove) DontRenderPlaceholder() bool { - return evt.HidePlaceholder -} diff --git a/mautrix-patched/bridgev2/simplevent/meta.go b/mautrix-patched/bridgev2/simplevent/meta.go deleted file mode 100644 index 34a65f6c..00000000 --- a/mautrix-patched/bridgev2/simplevent/meta.go +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package simplevent - -import ( - "context" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" -) - -// EventMeta is a struct containing metadata fields used by most event types. -type EventMeta struct { - Type bridgev2.RemoteEventType - LogContext func(c zerolog.Context) zerolog.Context - PortalKey networkid.PortalKey - UncertainReceiver bool - Sender bridgev2.EventSender - CreatePortal bool - Timestamp time.Time - StreamOrder int64 - - PreHandleFunc func(context.Context, *bridgev2.Portal) - PostHandleFunc func(context.Context, *bridgev2.Portal) - MutateContextFunc func(context.Context) context.Context - - FetchCertainPortalKeyFunc func(context.Context) networkid.PortalKey -} - -var ( - _ bridgev2.RemoteEvent = (*EventMeta)(nil) - _ bridgev2.RemoteEventWithUncertainPortalReceiver = (*EventMeta)(nil) - _ bridgev2.RemoteEventWithUncertainPortalReceiverFetcher = (*EventMeta)(nil) - _ bridgev2.RemoteEventThatMayCreatePortal = (*EventMeta)(nil) - _ bridgev2.RemoteEventWithTimestamp = (*EventMeta)(nil) - _ bridgev2.RemoteEventWithStreamOrder = (*EventMeta)(nil) - _ bridgev2.RemotePreHandler = (*EventMeta)(nil) - _ bridgev2.RemotePostHandler = (*EventMeta)(nil) - _ bridgev2.RemoteEventWithContextMutation = (*EventMeta)(nil) -) - -func (evt *EventMeta) AddLogContext(c zerolog.Context) zerolog.Context { - if evt.LogContext == nil { - return c - } - return evt.LogContext(c) -} - -func (evt *EventMeta) GetPortalKey() networkid.PortalKey { - return evt.PortalKey -} - -func (evt *EventMeta) PortalReceiverIsUncertain() bool { - return evt.UncertainReceiver -} - -func (evt *EventMeta) FetchCertainPortalKey(ctx context.Context) networkid.PortalKey { - if evt.FetchCertainPortalKeyFunc == nil { - return networkid.PortalKey{} - } - return evt.FetchCertainPortalKeyFunc(ctx) -} - -func (evt *EventMeta) GetTimestamp() time.Time { - if evt.Timestamp.IsZero() { - return time.Now() - } - return evt.Timestamp -} - -func (evt *EventMeta) GetStreamOrder() int64 { - return evt.StreamOrder -} - -func (evt *EventMeta) GetSender() bridgev2.EventSender { - return evt.Sender -} - -func (evt *EventMeta) GetType() bridgev2.RemoteEventType { - return evt.Type -} - -func (evt *EventMeta) ShouldCreatePortal() bool { - return evt.CreatePortal -} - -func (evt *EventMeta) PreHandle(ctx context.Context, portal *bridgev2.Portal) { - if evt.PreHandleFunc != nil { - evt.PreHandleFunc(ctx, portal) - } -} - -func (evt *EventMeta) PostHandle(ctx context.Context, portal *bridgev2.Portal) { - if evt.PostHandleFunc != nil { - evt.PostHandleFunc(ctx, portal) - } -} - -func (evt *EventMeta) MutateContext(ctx context.Context) context.Context { - if evt.MutateContextFunc == nil { - return ctx - } - return evt.MutateContextFunc(ctx) -} - -func (evt EventMeta) WithType(t bridgev2.RemoteEventType) EventMeta { - evt.Type = t - return evt -} - -func (evt EventMeta) WithLogContext(f func(c zerolog.Context) zerolog.Context) EventMeta { - evt.LogContext = f - return evt -} - -func (evt EventMeta) WithMoreLogContext(f func(c zerolog.Context) zerolog.Context) EventMeta { - origFunc := evt.LogContext - if origFunc == nil { - evt.LogContext = f - return evt - } - evt.LogContext = func(c zerolog.Context) zerolog.Context { - return f(origFunc(c)) - } - return evt -} - -func (evt EventMeta) WithPortalKey(p networkid.PortalKey) EventMeta { - evt.PortalKey = p - return evt -} - -func (evt EventMeta) WithUncertainReceiver(u bool) EventMeta { - evt.UncertainReceiver = u - return evt -} - -func (evt EventMeta) WithSender(s bridgev2.EventSender) EventMeta { - evt.Sender = s - return evt -} - -func (evt EventMeta) WithCreatePortal(c bool) EventMeta { - evt.CreatePortal = c - return evt -} - -func (evt EventMeta) WithTimestamp(t time.Time) EventMeta { - evt.Timestamp = t - return evt -} - -func (evt EventMeta) WithStreamOrder(s int64) EventMeta { - evt.StreamOrder = s - return evt -} diff --git a/mautrix-patched/bridgev2/simplevent/reaction.go b/mautrix-patched/bridgev2/simplevent/reaction.go deleted file mode 100644 index 34e0b025..00000000 --- a/mautrix-patched/bridgev2/simplevent/reaction.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package simplevent - -import ( - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" -) - -// Reaction is a simple implementation of [bridgev2.RemoteReaction] and [bridgev2.RemoteReactionRemove]. -type Reaction struct { - EventMeta - TargetMessage networkid.MessageID - EmojiID networkid.EmojiID - Emoji string - ExtraContent map[string]any - ReactionDBMeta any -} - -var ( - _ bridgev2.RemoteReaction = (*Reaction)(nil) - _ bridgev2.RemoteReactionWithMeta = (*Reaction)(nil) - _ bridgev2.RemoteReactionWithExtraContent = (*Reaction)(nil) - _ bridgev2.RemoteReactionRemove = (*Reaction)(nil) -) - -func (evt *Reaction) GetTargetMessage() networkid.MessageID { - return evt.TargetMessage -} - -func (evt *Reaction) GetReactionEmoji() (string, networkid.EmojiID) { - return evt.Emoji, evt.EmojiID -} - -func (evt *Reaction) GetRemovedEmojiID() networkid.EmojiID { - return evt.EmojiID -} - -func (evt *Reaction) GetReactionDBMetadata() any { - return evt.ReactionDBMeta -} - -func (evt *Reaction) GetReactionExtraContent() map[string]any { - return evt.ExtraContent -} - -type ReactionSync struct { - EventMeta - TargetMessage networkid.MessageID - Reactions *bridgev2.ReactionSyncData -} - -var ( - _ bridgev2.RemoteReactionSync = (*ReactionSync)(nil) -) - -func (evt *ReactionSync) GetTargetMessage() networkid.MessageID { - return evt.TargetMessage -} - -func (evt *ReactionSync) GetReactions() *bridgev2.ReactionSyncData { - return evt.Reactions -} diff --git a/mautrix-patched/bridgev2/simplevent/receipt.go b/mautrix-patched/bridgev2/simplevent/receipt.go deleted file mode 100644 index 41614e40..00000000 --- a/mautrix-patched/bridgev2/simplevent/receipt.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package simplevent - -import ( - "time" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" -) - -type Receipt struct { - EventMeta - - LastTarget networkid.MessageID - Targets []networkid.MessageID - ReadUpTo time.Time - - ReadUpToStreamOrder int64 -} - -var ( - _ bridgev2.RemoteReadReceipt = (*Receipt)(nil) - _ bridgev2.RemoteDeliveryReceipt = (*Receipt)(nil) -) - -func (evt *Receipt) GetLastReceiptTarget() networkid.MessageID { - return evt.LastTarget -} - -func (evt *Receipt) GetReceiptTargets() []networkid.MessageID { - return evt.Targets -} - -func (evt *Receipt) GetReadUpTo() time.Time { - return evt.ReadUpTo -} - -func (evt *Receipt) GetReadUpToStreamOrder() int64 { - return evt.ReadUpToStreamOrder -} - -type MarkUnread struct { - EventMeta - Unread bool -} - -var ( - _ bridgev2.RemoteMarkUnread = (*MarkUnread)(nil) -) - -func (evt *MarkUnread) GetUnread() bool { - return evt.Unread -} - -type Typing struct { - EventMeta - Timeout time.Duration - Type bridgev2.TypingType -} - -var ( - _ bridgev2.RemoteTyping = (*Typing)(nil) - _ bridgev2.RemoteTypingWithType = (*Typing)(nil) -) - -func (evt *Typing) GetTimeout() time.Duration { - return evt.Timeout -} - -func (evt *Typing) GetTypingType() bridgev2.TypingType { - return evt.Type -} diff --git a/mautrix-patched/bridgev2/space.go b/mautrix-patched/bridgev2/space.go deleted file mode 100644 index d9c656f4..00000000 --- a/mautrix-patched/bridgev2/space.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "fmt" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (ul *UserLogin) MarkInPortal(ctx context.Context, portal *Portal) { - if ul.inPortalCache.Has(portal.PortalKey) { - return - } - userPortal, err := ul.Bridge.DB.UserPortal.GetOrCreate(ctx, ul.UserLogin, portal.PortalKey) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure user portal row exists") - return - } - ul.inPortalCache.Add(portal.PortalKey) - if portal.MXID != "" { - dp := ul.User.DoublePuppet(ctx) - if dp != nil { - err = dp.EnsureJoined(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure double puppet is joined to portal") - } - } else { - err = ul.Bridge.Bot.EnsureInvited(ctx, portal.MXID, ul.UserMXID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure user is invited to portal") - } - } - if ul.Bridge.Config.PersonalFilteringSpaces && (userPortal.InSpace == nil || !*userPortal.InSpace) { - go ul.tryAddPortalToSpace(context.WithoutCancel(ctx), portal, userPortal.CopyWithoutValues()) - } - } -} - -func (ul *UserLogin) tryAddPortalToSpace(ctx context.Context, portal *Portal, userPortal *database.UserPortal) { - err := ul.AddPortalToSpace(ctx, portal, userPortal) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to add portal to space") - } -} - -func (ul *UserLogin) AddPortalToSpace(ctx context.Context, portal *Portal, userPortal *database.UserPortal) error { - if portal.MXID == "" || portal.Parent != nil { - return nil - } - spaceRoom, err := ul.GetSpaceRoom(ctx) - if err != nil { - return fmt.Errorf("failed to get space room: %w", err) - } else if spaceRoom == "" { - return nil - } - err = portal.toggleSpace(ctx, spaceRoom, false, false) - if err != nil { - return fmt.Errorf("failed to add portal to space: %w", err) - } - inSpace := true - userPortal.InSpace = &inSpace - err = ul.Bridge.DB.UserPortal.Put(ctx, userPortal) - if err != nil { - return fmt.Errorf("failed to save user portal row: %w", err) - } - zerolog.Ctx(ctx).Debug().Stringer("space_room_id", spaceRoom).Msg("Added portal to space") - return nil -} - -func (portal *Portal) createParentAndAddToSpace(ctx context.Context, source *UserLogin) { - err := portal.Parent.CreateMatrixRoom(ctx, source, nil) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create parent portal") - } else { - portal.addToParentSpaceAndSave(ctx, true) - } -} - -func (portal *Portal) addToParentSpaceAndSave(ctx context.Context, save bool) { - err := portal.toggleSpace(ctx, portal.Parent.MXID, true, false) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("space_mxid", portal.Parent.MXID).Msg("Failed to add portal to space") - } else { - portal.InSpace = true - if save { - err = portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal to database after adding to space") - } - } - } -} - -func (portal *Portal) toggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error { - via := []string{portal.Bridge.Matrix.ServerName()} - if remove { - via = nil - } - _, err := portal.Bridge.Bot.SendState(ctx, spaceID, event.StateSpaceChild, portal.MXID.String(), &event.Content{ - Parsed: &event.SpaceChildEventContent{ - Via: via, - }, - }, time.Now()) - if err != nil { - return err - } - if canonical { - _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateSpaceParent, spaceID.String(), &event.Content{ - Parsed: &event.SpaceParentEventContent{ - Via: via, - Canonical: !remove, - }, - }, time.Now()) - if err != nil { - return err - } - } - return nil -} - -func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { - if !ul.Bridge.Config.PersonalFilteringSpaces { - return ul.SpaceRoom, nil - } - ul.spaceCreateLock.Lock() - defer ul.spaceCreateLock.Unlock() - if ul.SpaceRoom != "" { - return ul.SpaceRoom, nil - } - netName := ul.Bridge.Network.GetName() - var err error - autoJoin := ul.Bridge.Matrix.GetCapabilities().AutoJoinInvites - doublePuppet := ul.User.DoublePuppet(ctx) - req := &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: fmt.Sprintf("%s (%s)", netName.DisplayName, ul.RemoteName), - Topic: fmt.Sprintf("Your %s bridged chats - %s", netName.DisplayName, ul.RemoteName), - InitialState: []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: netName.NetworkIcon, - }, - }, - }, { - Type: event.StateBridge, - Content: event.Content{ - Parsed: &event.BridgeEventContent{ - BridgeBot: ul.Bridge.Bot.GetMXID(), - Protocol: netName.AsBridgeInfoSection(), - Channel: event.BridgeInfoSection{ - ID: "__personal_filtering_space__", - Receiver: string(ul.ID), - }, - BeeperRoomTypeV2: "personal_filtering_space", - }, - }, - }}, - CreationContent: map[string]any{ - "type": event.RoomTypeSpace, - }, - PowerLevelOverride: &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - ul.Bridge.Bot.GetMXID(): 9001, - ul.UserMXID: 50, - }, - }, - Invite: []id.UserID{ul.UserMXID}, - BeeperLocalRoomID: ul.Bridge.Matrix.GenerateDeterministicRoomID(networkid.PortalKey{ - ID: "__personal_filtering_space__", - Receiver: ul.ID, - }), - } - if autoJoin { - req.BeeperInitialMembers = []id.UserID{ul.UserMXID} - // TODO remove this after initial_members is supported in hungryserv - req.BeeperAutoJoinInvites = true - } - pfc, ok := ul.Client.(PersonalFilteringCustomizingNetworkAPI) - if ok { - pfc.CustomizePersonalFilteringSpace(req) - } - ul.SpaceRoom, err = ul.Bridge.Bot.CreateRoom(ctx, req) - if err != nil { - return "", fmt.Errorf("failed to create space room: %w", err) - } - if !autoJoin && doublePuppet != nil { - err = doublePuppet.EnsureJoined(ctx, ul.SpaceRoom) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to auto-join created space room with double puppet") - } - } - err = ul.Save(ctx) - if err != nil { - return "", fmt.Errorf("failed to save space room ID: %w", err) - } - return ul.SpaceRoom, nil -} diff --git a/mautrix-patched/bridgev2/status/bridgestate.go b/mautrix-patched/bridgev2/status/bridgestate.go deleted file mode 100644 index 5925dd4f..00000000 --- a/mautrix-patched/bridgev2/status/bridgestate.go +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package status - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "maps" - "net/http" - "reflect" - "time" - - "github.com/tidwall/sjson" - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type BridgeStateEvent string -type BridgeStateErrorCode string - -type BridgeStateErrorMap map[BridgeStateErrorCode]string - -func (bem BridgeStateErrorMap) Update(data BridgeStateErrorMap) { - for key, value := range data { - bem[key] = value - } -} - -var BridgeStateHumanErrors = make(BridgeStateErrorMap) - -const ( - StateStarting BridgeStateEvent = "STARTING" - StateUnconfigured BridgeStateEvent = "UNCONFIGURED" - StateRunning BridgeStateEvent = "RUNNING" - StateBridgeUnreachable BridgeStateEvent = "BRIDGE_UNREACHABLE" - - StateConnecting BridgeStateEvent = "CONNECTING" - StateBackfilling BridgeStateEvent = "BACKFILLING" - StateConnected BridgeStateEvent = "CONNECTED" - StateTransientDisconnect BridgeStateEvent = "TRANSIENT_DISCONNECT" - StateBadCredentials BridgeStateEvent = "BAD_CREDENTIALS" - StateUnknownError BridgeStateEvent = "UNKNOWN_ERROR" - StateLoggedOut BridgeStateEvent = "LOGGED_OUT" -) - -func (e BridgeStateEvent) IsValid() bool { - switch e { - case - StateStarting, - StateUnconfigured, - StateRunning, - StateBridgeUnreachable, - StateConnecting, - StateBackfilling, - StateConnected, - StateTransientDisconnect, - StateBadCredentials, - StateUnknownError, - StateLoggedOut: - return true - default: - return false - } -} - -type BridgeStateUserAction string - -const ( - UserActionOpenNative BridgeStateUserAction = "OPEN_NATIVE" - UserActionRelogin BridgeStateUserAction = "RELOGIN" - UserActionRestart BridgeStateUserAction = "RESTART" -) - -type RemoteProfile struct { - Phone string `json:"phone,omitempty"` - Email string `json:"email,omitempty"` - Username string `json:"username,omitempty"` - Name string `json:"name,omitempty"` - Avatar id.ContentURIString `json:"avatar,omitempty"` - - AvatarFile *event.EncryptedFileInfo `json:"avatar_file,omitempty"` -} - -func coalesce[T ~string](a, b T) T { - if a != "" { - return a - } - return b -} - -func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile { - other.Phone = coalesce(rp.Phone, other.Phone) - other.Email = coalesce(rp.Email, other.Email) - other.Username = coalesce(rp.Username, other.Username) - other.Name = coalesce(rp.Name, other.Name) - other.Avatar = coalesce(rp.Avatar, other.Avatar) - if rp.AvatarFile != nil { - other.AvatarFile = rp.AvatarFile - } - return other -} - -func (rp *RemoteProfile) IsZero() bool { - return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil) -} - -type BridgeState struct { - StateEvent BridgeStateEvent `json:"state_event"` - Timestamp jsontime.Unix `json:"timestamp"` - TTL int `json:"ttl"` - - Source string `json:"source,omitempty"` - Error BridgeStateErrorCode `json:"error,omitempty"` - Message string `json:"message,omitempty"` - - UserAction BridgeStateUserAction `json:"user_action,omitempty"` - - UserID id.UserID `json:"user_id,omitempty"` - RemoteID networkid.UserLoginID `json:"remote_id,omitempty"` - RemoteName string `json:"remote_name,omitempty"` - RemoteProfile RemoteProfile `json:"remote_profile,omitzero"` - - Reason string `json:"reason,omitempty"` - Info map[string]interface{} `json:"info,omitempty"` -} - -type GlobalBridgeState struct { - RemoteStates map[string]BridgeState `json:"remoteState"` - BridgeState BridgeState `json:"bridgeState"` -} - -type BridgeStateFiller interface { - FillBridgeState(BridgeState) BridgeState -} - -// Deprecated: use BridgeStateFiller instead -type StandaloneCustomBridgeStateFiller = BridgeStateFiller - -func (pong BridgeState) Fill(user BridgeStateFiller) BridgeState { - if user != nil { - pong = user.FillBridgeState(pong) - } - - pong.Timestamp = jsontime.UnixNow() - pong.Source = "bridge" - if len(pong.Error) > 0 { - pong.TTL = 3600 - msg, ok := BridgeStateHumanErrors[pong.Error] - if ok { - pong.Message = msg - } - } else { - pong.TTL = 21600 - } - return pong -} - -func (pong *BridgeState) SendHTTP(ctx context.Context, url, token string) error { - var body []byte - var err error - if body, err = json.Marshal(&pong); err != nil { - return fmt.Errorf("failed to encode bridge state JSON: %w", err) - } - - if pong.StateEvent == StateBridgeUnreachable { - body, err = sjson.SetBytes(body, "stateEvent", pong.StateEvent) - if err != nil { - return fmt.Errorf("failed to add stateEvent field to bridge_unreachable state") - } - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to prepare request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (bridge state sender)") - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode > 299 { - respBody, _ := io.ReadAll(resp.Body) - if respBody != nil { - respBody = bytes.ReplaceAll(respBody, []byte("\n"), []byte("\\n")) - } - return fmt.Errorf("unexpected status code %d sending bridge state update: %s", resp.StatusCode, respBody) - } - return nil -} - -func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool { - return pong != nil && - pong.StateEvent == newPong.StateEvent && - pong.RemoteName == newPong.RemoteName && - pong.UserAction == newPong.UserAction && - pong.RemoteProfile == newPong.RemoteProfile && - pong.Error == newPong.Error && - maps.EqualFunc(pong.Info, newPong.Info, reflect.DeepEqual) && - pong.Timestamp.Add(time.Duration(pong.TTL)*time.Second).After(time.Now()) -} diff --git a/mautrix-patched/bridgev2/status/localbridgestate.go b/mautrix-patched/bridgev2/status/localbridgestate.go deleted file mode 100644 index 3ad66538..00000000 --- a/mautrix-patched/bridgev2/status/localbridgestate.go +++ /dev/null @@ -1,23 +0,0 @@ -package status - -type LocalBridgeAccountState string - -const ( - // LocalBridgeAccountStateSetup means the user wants this account to be setup and connected - LocalBridgeAccountStateSetup LocalBridgeAccountState = "SETUP" - // LocalBridgeAccountStateDeleted means the user wants this account to be deleted - LocalBridgeAccountStateDeleted LocalBridgeAccountState = "DELETED" -) - -type LocalBridgeDeviceState string - -const ( - // LocalBridgeDeviceStateSetup means this device is setup to be connected to this account - LocalBridgeDeviceStateSetup LocalBridgeDeviceState = "SETUP" - // LocalBridgeDeviceStateLoggedOut means the user has logged this particular device out while wanting their other devices to remain setup - LocalBridgeDeviceStateLoggedOut LocalBridgeDeviceState = "LOGGED_OUT" - // LocalBridgeDeviceStateError means this particular device has fallen into a persistent error state that may need user intervention to fix - LocalBridgeDeviceStateError LocalBridgeDeviceState = "ERROR" - // LocalBridgeDeviceStateDeleted means this particular device has cleaned up after the account as a whole was requested to be deleted - LocalBridgeDeviceStateDeleted LocalBridgeDeviceState = "DELETED" -) diff --git a/mautrix-patched/bridgev2/status/messagecheckpoint.go b/mautrix-patched/bridgev2/status/messagecheckpoint.go deleted file mode 100644 index b3c05f4f..00000000 --- a/mautrix-patched/bridgev2/status/messagecheckpoint.go +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) 2021 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package status - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type MessageCheckpointStep string - -const ( - MsgStepClient MessageCheckpointStep = "CLIENT" - MsgStepHomeserver MessageCheckpointStep = "HOMESERVER" - MsgStepBridge MessageCheckpointStep = "BRIDGE" - MsgStepDecrypted MessageCheckpointStep = "DECRYPTED" - MsgStepRemote MessageCheckpointStep = "REMOTE" - MsgStepCommand MessageCheckpointStep = "COMMAND" -) - -func (mcs MessageCheckpointStep) order() int { - checkpointOrder := map[MessageCheckpointStep]int{ - MsgStepClient: 0, - MsgStepHomeserver: 1, - MsgStepBridge: 2, - MsgStepDecrypted: 3, - MsgStepRemote: 4, - MsgStepCommand: 4, - } - if order, ok := checkpointOrder[mcs]; !ok { - panic(fmt.Sprintf("Unknown checkpoint step %s", mcs)) - } else { - return order - } -} - -func (mcs MessageCheckpointStep) Before(other MessageCheckpointStep) bool { - return mcs.order() < other.order() -} - -func (mcs MessageCheckpointStep) IsValid() bool { - switch mcs { - case MsgStepClient, MsgStepHomeserver, MsgStepBridge, MsgStepDecrypted, MsgStepRemote, MsgStepCommand: - return true - } - return false -} - -type MessageCheckpointStatus string - -const ( - MsgStatusSuccess MessageCheckpointStatus = "SUCCESS" - MsgStatusWillRetry MessageCheckpointStatus = "WILL_RETRY" - MsgStatusPermFailure MessageCheckpointStatus = "PERM_FAILURE" - MsgStatusUnsupported MessageCheckpointStatus = "UNSUPPORTED" - MsgStatusTimeout MessageCheckpointStatus = "TIMEOUT" - MsgStatusDelivered MessageCheckpointStatus = "DELIVERED" - MsgStatusDeliveryFailed MessageCheckpointStatus = "DELIVERY_FAILED" -) - -func (mcs MessageCheckpointStatus) IsValid() bool { - switch mcs { - case MsgStatusSuccess, MsgStatusWillRetry, MsgStatusPermFailure, MsgStatusUnsupported, MsgStatusTimeout, MsgStatusDelivered, MsgStatusDeliveryFailed: - return true - } - return false -} - -func ReasonToCheckpointStatus(reason event.MessageStatusReason, status event.MessageStatus) MessageCheckpointStatus { - if status == event.MessageStatusPending { - return MsgStatusWillRetry - } - switch reason { - case event.MessageStatusUnsupported: - return MsgStatusUnsupported - case event.MessageStatusTooOld: - return MsgStatusTimeout - default: - return MsgStatusPermFailure - } -} - -type MessageCheckpointReportedBy string - -const ( - MsgReportedByAsmux MessageCheckpointReportedBy = "ASMUX" - MsgReportedByBridge MessageCheckpointReportedBy = "BRIDGE" - MsgReportedByHungry MessageCheckpointReportedBy = "HUNGRYSERV" -) - -func (mcrb MessageCheckpointReportedBy) IsValid() bool { - switch mcrb { - case MsgReportedByAsmux, MsgReportedByBridge, MsgReportedByHungry: - return true - } - return false -} - -type MessageCheckpoint struct { - EventID id.EventID `json:"event_id"` - RoomID id.RoomID `json:"room_id"` - Step MessageCheckpointStep `json:"step"` - Timestamp jsontime.UnixMilli `json:"timestamp"` - Status MessageCheckpointStatus `json:"status"` - EventType event.Type `json:"event_type"` - ReportedBy MessageCheckpointReportedBy `json:"reported_by"` - RetryNum int `json:"retry_num"` - MessageType event.MessageType `json:"message_type,omitempty"` - Info string `json:"info,omitempty"` - - ClientType string `json:"client_type,omitempty"` - ClientVersion string `json:"client_version,omitempty"` - - OriginalEventID id.EventID `json:"original_event_id,omitempty"` - ManualRetryCount int `json:"manual_retry_count,omitempty"` -} - -var CheckpointTypes = map[event.Type]struct{}{ - event.EventRedaction: {}, - event.EventMessage: {}, - event.EventEncrypted: {}, - event.EventSticker: {}, - event.EventReaction: {}, - //event.CallInvite: {}, - //event.CallCandidates: {}, - //event.CallSelectAnswer: {}, - //event.CallAnswer: {}, - //event.CallHangup: {}, - //event.CallReject: {}, - //event.CallNegotiate: {}, -} - -func NewMessageCheckpoint(evt *event.Event, step MessageCheckpointStep, status MessageCheckpointStatus, retryNum int) *MessageCheckpoint { - checkpoint := MessageCheckpoint{ - EventID: evt.ID, - RoomID: evt.RoomID, - Step: step, - Timestamp: jsontime.UnixMilliNow(), - Status: status, - EventType: evt.Type, - ReportedBy: MsgReportedByBridge, - RetryNum: retryNum, - } - if evt.Type == event.EventMessage { - checkpoint.MessageType = evt.Content.AsMessage().MsgType - } - if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil { - checkpoint.OriginalEventID = retryMeta.OriginalEventID - checkpoint.ManualRetryCount = retryMeta.RetryCount - } - return &checkpoint -} - -type CheckpointsJSON struct { - Checkpoints []*MessageCheckpoint `json:"checkpoints"` -} - -func (cj *CheckpointsJSON) SendHTTP(ctx context.Context, cli *http.Client, endpoint string, token string) error { - var body bytes.Buffer - if err := json.NewEncoder(&body).Encode(cj); err != nil { - return fmt.Errorf("failed to encode message checkpoint JSON: %w", err) - } - - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) - if err != nil { - return err - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (checkpoint sender)") - req.Header.Set("Content-Type", "application/json") - - if cli == nil { - cli = http.DefaultClient - } - resp, err := cli.Do(req) - if err != nil { - return mautrix.HTTPError{ - Request: req, - Response: resp, - - WrappedError: err, - Message: "failed to send message checkpoint", - } - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "failed to send message checkpoint", - } - } - return nil -} diff --git a/mautrix-patched/bridgev2/unorganized-docs/FEATURES.md b/mautrix-patched/bridgev2/unorganized-docs/FEATURES.md deleted file mode 100644 index 73da364d..00000000 --- a/mautrix-patched/bridgev2/unorganized-docs/FEATURES.md +++ /dev/null @@ -1,49 +0,0 @@ -# Megabridge features - -* [ ] Messages - * [x] Text (incl. formatting and mentions) - * [x] Attachments - * [ ] Polls - * [x] Replies - * [x] Threads - * [x] Edits - * [x] Reactions - * [x] Reaction mass-syncing - * [x] Deletions - * [x] Message status events and error notices - * [x] Backfilling history -* [x] Login -* [x] Logout -* [x] Re-login after credential expiry -* [x] Disappearing messages -* [x] Read receipts -* [ ] Presence -* [x] Typing notifications -* [x] Spaces -* [x] Relay mode -* [x] Chat metadata - * [x] Archive/low priority - * [x] Pin/favorite - * [x] Mark unread - * [x] Mute status - * [x] Temporary mutes ("snooze") -* [x] User metadata (name/avatar) -* [x] Group metadata - * [x] Initial meta and full resyncs - * [x] Name, avatar, topic - * [x] Members - * [x] Permissions - * [x] Change events - * [x] Name, avatar, topic - * [x] Members (join, leave, invite, kick, ban, knock) - * [x] Permissions (promote, demote) -* [ ] Misc actions - * [ ] Invites / accepting message requests - * [x] Create group - * [x] Create DM - * [x] Get contact list - * [x] Check if identifier is on remote network - * [x] Search users on remote network - * [ ] Delete chat - * [ ] Report spam -* [ ] Custom emojis diff --git a/mautrix-patched/bridgev2/unorganized-docs/README.md b/mautrix-patched/bridgev2/unorganized-docs/README.md deleted file mode 100644 index 62cc731a..00000000 --- a/mautrix-patched/bridgev2/unorganized-docs/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Megabridge -Megabridge, also known as bridgev2 (final naming is subject to change), is a -new high-level framework for writing puppeting Matrix bridges with hopefully -minimal boilerplate code. - -## General architecture -Megabridge is split into three components: network connectors, the central -bridge module, and Matrix connectors. - -* Network connectors are responsible for connecting to the remote (non-Matrix) - network and handling all the protocol-specific details. -* The central bridge module has most of the generic bridge logic, such as - keeping track of portal mappings and handling messages. -* Matrix connectors are responsible for connecting to Matrix. Initially there - will be two Matrix connectors: one for the standard setup that connects to - a Matrix homeserver as an application service, and another for Beeper's local - bridge system. However, in the future there could be a third connector which - uses a single bot account and [MSC4144] instead of an appservice with ghost - users. - - [MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144 - -The central bridge module defines interfaces that it uses to interact with the -connectors on both sides. Additionally, the connectors are allowed to directly -call interface methods on other side. - -## Getting started with a new network connector -To create a new network connector, you need to implement the -`NetworkConnector`, `LoginProcess`, `NetworkAPI` and `RemoteEvent` interfaces. - -* `NetworkConnector` is the main entry point to the remote network. It is - responsible for general non-user-specific things, as well as creating - `NetworkAPI`s and starting login flows. -* `LoginProcess` is a state machine for logging into the remote network. -* `NetworkAPI` is the remote network client for a single login. It is - responsible for maintaining the connection to the remote network, receiving - incoming events, sending outgoing events, and fetching information like - chat/user metadata. -* `RemoteEvent` represents a single event from the remote network, such as a - message or a reaction. When the NetworkAPI receives an event, it should create - a `RemoteEvent` object and pass it to the bridge using `Bridge.QueueRemoteEvent`. - -### Login -Logins are implemented by combining three types of steps: - -* `user_input` asks the user to enter some information, such as a phone number, - username, email, password, or 2FA code. -* `cookies` either asks the user to extract cookies from their browser, or opens - a webview to do it automatically (depending on whether the login is being done - via bridge commands or a more advanced client). -* `display_and_wait` displays a QR code or other data to the user and waits until - the remote network accepts the login. - -The general flow is: - -1. Login handler (bridge command or client) calls `NetworkConnector.GetLoginFlows` - to get available login flows, and asks the user to pick one (or alternatively - automatically picks the first one if there's only one option). -2. Login handler calls `NetworkConnector.CreateLogin` with the chosen flow ID and - the network connector returns a `LoginProcess` object that remembers the user - and flow. -3. Login handler calls `LoginProcess.Start` to get the first step. -4. Login handler calls the appropriate functions (`Wait`, `SubmitUserInput` or - `SubmitCookies`) based on the step data as many times as needed. -5. When the login is done, the login process creates the `UserLogin` object and - returns a `complete` step. diff --git a/mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml b/mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml deleted file mode 100644 index ae13ee74..00000000 --- a/mautrix-patched/bridgev2/unorganized-docs/incoming-matrix-message.uml +++ /dev/null @@ -1,23 +0,0 @@ -title Bridge v2 incoming Matrix message - -participant Network Library -participant Network Connector -participant Bridge -participant Portal -participant Database -participant Matrix - -Matrix->Bridge: QueueMatrixEvent(evt) -note over Bridge: GetPortalByID(evt.GetPortalID()) -Bridge->Portal: portal.events <- evt -loop event queue consumer - Portal->+Portal: \n evt := <-portal.events - note over Portal: Check for edit, reply/thread, etc - Portal->+Network Connector: HandleMatrixMessage(evt, replyTo) - Network Connector->Network Connector: msg := ConvertMatrixMessage(evt) - Network Connector->+Network Library: SendMessage(msg) - Network Library->-Network Connector: OK - Network Connector->-Portal: *database.Message{msg.ID} - Portal->-Database: Message.Insert() - Portal->Matrix: Success checkpoint -end diff --git a/mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml b/mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml deleted file mode 100644 index f86d6e65..00000000 --- a/mautrix-patched/bridgev2/unorganized-docs/incoming-remote-message.uml +++ /dev/null @@ -1,22 +0,0 @@ -title Bridge v2 incoming remote message - -participant Network Library -participant Network Connector -participant Bridge -participant Portal -participant Database -participant Matrix - -Network Library->Network Connector: New event -Network Connector->Bridge: QueueRemoteEvent(evt) -note over Bridge: GetPortalByID(evt.GetPortalID()) -Bridge->Portal: portal.events <- evt -loop event queue consumer - Portal->+Portal: \n evt := <-portal.events - note over Portal: CreateMatrixRoom() if applicable - Portal->+Network Connector: ConvertRemoteMessage(evt) - Network Connector->-Portal: *ConvertedMessage - Portal->+Matrix: SendMessage(convertedMsg) - Matrix->-Portal: event ID - Portal->-Database: Message.Insert() -end diff --git a/mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json b/mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json deleted file mode 100644 index b039354f..00000000 --- a/mautrix-patched/bridgev2/unorganized-docs/login-step.schema.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://go.mau.fi/mautrix/bridgev2/login-step.json", - "title": "Login step data", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["user_input", "cookies", "display_and_wait", "complete"] - }, - "step_id": { - "type": "string", - "description": "An unique ID identifying this step. This can be used to implement special behavior in clients." - }, - "instructions": { - "type": "string", - "description": "Human-readable instructions for completing this login step." - }, - "user_input": { - "type": "object", - "title": "User input params", - "description": "Parameters for the `user_input` login type", - "properties": { - "fields": { - "type": "array", - "description": "The list of fields that the user must fill", - "items": { - "title": "Field", - "description": "A field that the user must fill", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["username", "phone_number", "email", "password", "2fa_code", "token"] - }, - "id": { - "type": "string", - "description": "The ID of the field. This should be used when submitting the form.", - "examples": ["uid", "email", "2fa_password", "meow"] - }, - "name": { - "type": "string", - "description": "The name of the field shown to the user", - "examples": ["Username", "Password", "Phone number", "2FA code", "Meow"] - }, - "description": { - "type": "string", - "description": "The description of the field shown to the user", - "examples": ["Include the country code with a +"] - }, - "pattern": { - "type": "string", - "description": "A regular expression that the field value must match" - } - }, - "required": ["type", "id", "name"] - } - } - }, - "required": ["fields"] - }, - "cookies": { - "type": "object", - "title": "Cookie params", - "description": "Parameters for the `cookies` login type", - "properties": { - "url": { - "type": "string", - "description": "The URL to open when using a webview to extract cookies" - }, - "user_agent": { - "type": "string", - "description": "The user agent to use when opening the URL" - }, - "fields": { - "type": "array", - "description": "The list of cookies (or other stored data) that must be extracted", - "items": { - "title": "Cookie Field", - "description": "A cookie (or other stored data) that must be extracted", - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "The type of data to extract", - "enum": ["cookie", "local_storage", "request_header", "request_body", "special"] - }, - "name": { - "type": "string", - "description": "The name of the cookie or key in the storage" - }, - "request_url_regex": { - "type": "string", - "description": "For the `request_header` and `request_body` types, a regex that matches the URLs from which the values can be extracted." - }, - "cookie_domain": { - "type": "string", - "description": "For the `cookie` type, the domain of the cookie" - } - }, - "required": ["type", "name"] - } - }, - "extract_js": { - "type": "string", - "description": "JavaScript code that can be evaluated inside the webview to extract the special keys" - } - }, - "required": ["url"] - }, - "display_and_wait": { - "type": "object", - "title": "Display and wait params", - "description": "Parameters for the `display_and_wait` login type", - "properties": { - "type": { - "type": "string", - "description": "The type of thing to display", - "enum": ["qr", "emoji", "code", "nothing"] - }, - "data": { - "type": "string", - "description": "The thing to display (raw data for QR, unicode emoji for emoji, plain string for code)" - }, - "image_url": { - "type": "string", - "description": "An image containing the thing to display. If present, this is recommended over using data directly. For emojis, the URL to the canonical image representation of the emoji" - } - }, - "required": ["type"] - }, - "complete": { - "type": "object", - "title": "Login complete information", - "description": "Information about a successful login", - "properties": { - "user_login_id": { - "type": "string", - "description": "The ID of the user login entry" - } - } - } - }, - "required": [ - "type", - "step_id", - "instructions" - ], - "oneOf": [ - {"title":"User input type","properties":{"type": {"type":"string","const": "user_input"}}, "required": ["user_input"]}, - {"title":"Cookies type","properties":{"type": {"type":"string","const": "cookies"}}, "required": ["cookies"]}, - {"title":"Display and wait type","properties":{"type": {"type":"string","const": "display_and_wait"}}, "required": ["display_and_wait"]}, - {"title":"Login complete","properties":{"type": {"type":"string","const": "complete"}}} - ] -} diff --git a/mautrix-patched/bridgev2/unorganized-docs/login-steps.uml b/mautrix-patched/bridgev2/unorganized-docs/login-steps.uml deleted file mode 100644 index 5af9c88e..00000000 --- a/mautrix-patched/bridgev2/unorganized-docs/login-steps.uml +++ /dev/null @@ -1,43 +0,0 @@ -title Login flows - -participant User -participant Client -participant Bridge -participant User's device - -alt Username+Password/Phone number/2FA code - Client->+Bridge: /login - Bridge->-Client: step=user_input, fields=[...] - Client->User: input box(es) - User->Client: submit input - Client->+Bridge: /login/user_input - Bridge->-Client: success=true, step=next step -end - -alt Cookies - Client->+Bridge: /login - Bridge->-Client: step=cookies, url=..., cookies=[...] - Client->User: webview - User->Client: login in webview - Client->Bridge: /login/cookies - Bridge->-Client: success=true, step=next step -end - -alt QR/Emoji/Code - Client->+Bridge: /login - Bridge->-Client: step=display_and_wait, data=... - Client->+Bridge: /login/wait - Client->User: display QR/emoji/code - loop Refresh QR - Bridge->-Client: step=display_and_wait, data=new QR - Client->User: display new QR - Client->+Bridge: /login/wait - end -else Successful case - User->User's device: Scan QR/tap emoji/enter code - User's device->Bridge: Login successful - Bridge->-Client: success=true, step=next step -else Error - Bridge->Client: error=timeout - Client->User: error -end diff --git a/mautrix-patched/bridgev2/user.go b/mautrix-patched/bridgev2/user.go deleted file mode 100644 index 9a7896d6..00000000 --- a/mautrix-patched/bridgev2/user.go +++ /dev/null @@ -1,275 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "context" - "fmt" - "strings" - "sync" - "unsafe" - - "github.com/rs/zerolog" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type User struct { - *database.User - Bridge *Bridge - Log zerolog.Logger - - CommandState unsafe.Pointer - Permissions bridgeconfig.Permissions - - doublePuppetIntent MatrixAPI - doublePuppetInitialized bool - doublePuppetLock sync.Mutex - - managementCreateLock sync.Mutex - - logins map[networkid.UserLoginID]*UserLogin -} - -func (br *Bridge) loadUser(ctx context.Context, dbUser *database.User, queryErr error, userID *id.UserID) (*User, error) { - if queryErr != nil { - return nil, fmt.Errorf("failed to query db: %w", queryErr) - } - if dbUser == nil { - if userID == nil { - return nil, nil - } - dbUser = &database.User{ - BridgeID: br.ID, - MXID: *userID, - } - err := br.DB.User.Insert(ctx, dbUser) - if err != nil { - return nil, fmt.Errorf("failed to insert new user: %w", err) - } - } - user := &User{ - User: dbUser, - Bridge: br, - Log: br.Log.With().Stringer("user_mxid", dbUser.MXID).Logger(), - logins: make(map[networkid.UserLoginID]*UserLogin), - Permissions: br.Config.Permissions.Get(dbUser.MXID), - } - br.usersByMXID[user.MXID] = user - err := br.unlockedLoadUserLoginsByMXID(ctx, user) - if err != nil { - return nil, fmt.Errorf("failed to load user logins: %w", err) - } - return user, nil -} - -func (br *Bridge) unlockedGetUserByMXID(ctx context.Context, userID id.UserID, onlyIfExists bool) (*User, error) { - cached, ok := br.usersByMXID[userID] - if ok { - return cached, nil - } - idPtr := &userID - if onlyIfExists { - idPtr = nil - } - db, err := br.DB.User.GetByMXID(ctx, userID) - return br.loadUser(ctx, db, err, idPtr) -} - -func (br *Bridge) GetUserByMXID(ctx context.Context, userID id.UserID) (*User, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.unlockedGetUserByMXID(ctx, userID, false) -} - -func (br *Bridge) GetExistingUserByMXID(ctx context.Context, userID id.UserID) (*User, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.unlockedGetUserByMXID(ctx, userID, true) -} - -func (user *User) LogoutDoublePuppet(ctx context.Context) { - user.doublePuppetLock.Lock() - defer user.doublePuppetLock.Unlock() - user.AccessToken = "" - err := user.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save removed access token") - } - user.doublePuppetIntent = nil - user.doublePuppetInitialized = false -} - -func (user *User) LoginDoublePuppet(ctx context.Context, token string) error { - if token == "" { - return fmt.Errorf("no token provided") - } - user.doublePuppetLock.Lock() - defer user.doublePuppetLock.Unlock() - intent, newToken, err := user.Bridge.Matrix.NewUserIntent(ctx, user.MXID, token) - if err != nil { - return err - } - user.AccessToken = newToken - user.doublePuppetIntent = intent - user.doublePuppetInitialized = true - err = user.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save new access token") - } - if newToken != token { - return fmt.Errorf("logging in manually is not supported when automatic double puppeting is enabled") - } - return nil -} - -func (user *User) DoublePuppet(ctx context.Context) MatrixAPI { - user.doublePuppetLock.Lock() - defer user.doublePuppetLock.Unlock() - if user.doublePuppetInitialized { - return user.doublePuppetIntent - } - user.doublePuppetInitialized = true - log := user.Log.With().Str("action", "setup double puppet").Logger() - ctx = log.WithContext(ctx) - intent, newToken, err := user.Bridge.Matrix.NewUserIntent(ctx, user.MXID, user.AccessToken) - if err != nil { - log.Err(err).Msg("Failed to create new user intent") - return nil - } - user.doublePuppetIntent = intent - if newToken != user.AccessToken { - user.AccessToken = newToken - err = user.Save(ctx) - if err != nil { - log.Warn().Err(err).Msg("Failed to save new access token") - } - } - return intent -} - -func (user *User) GetUserLoginIDs() []networkid.UserLoginID { - user.Bridge.cacheLock.Lock() - defer user.Bridge.cacheLock.Unlock() - return maps.Keys(user.logins) -} - -// Deprecated: renamed to GetUserLogins -func (user *User) GetCachedUserLogins() []*UserLogin { - return user.GetUserLogins() -} - -func (user *User) GetUserLogins() []*UserLogin { - user.Bridge.cacheLock.Lock() - defer user.Bridge.cacheLock.Unlock() - return maps.Values(user.logins) -} - -func (user *User) HasTooManyLogins() bool { - return user.Permissions.MaxLogins > 0 && len(user.GetUserLoginIDs()) >= user.Permissions.MaxLogins -} - -func (user *User) GetFormattedUserLogins() string { - user.Bridge.cacheLock.Lock() - logins := make([]string, len(user.logins)) - for key, val := range user.logins { - logins = append(logins, fmt.Sprintf("* `%s` (%s) - `%s`", key, val.RemoteName, val.BridgeState.GetPrev().StateEvent)) - } - user.Bridge.cacheLock.Unlock() - return strings.Join(logins, "\n") -} - -func (user *User) GetDefaultLogin() *UserLogin { - user.Bridge.cacheLock.Lock() - defer user.Bridge.cacheLock.Unlock() - if len(user.logins) == 0 { - return nil - } - loginKeys := maps.Keys(user.logins) - slices.Sort(loginKeys) - return user.logins[loginKeys[0]] -} - -func (user *User) GetManagementRoom(ctx context.Context) (id.RoomID, error) { - user.managementCreateLock.Lock() - defer user.managementCreateLock.Unlock() - if user.ManagementRoom != "" { - return user.ManagementRoom, nil - } - netName := user.Bridge.Network.GetName() - var err error - autoJoin := user.Bridge.Matrix.GetCapabilities().AutoJoinInvites - doublePuppet := user.DoublePuppet(ctx) - req := &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: netName.DisplayName, - Topic: fmt.Sprintf("%s bridge management room", netName.DisplayName), - InitialState: []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: netName.NetworkIcon, - }, - }, - }}, - PowerLevelOverride: &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - user.Bridge.Bot.GetMXID(): 9001, - user.MXID: 50, - }, - }, - Invite: []id.UserID{user.MXID}, - IsDirect: true, - } - if autoJoin { - req.BeeperInitialMembers = []id.UserID{user.MXID} - // TODO remove this after initial_members is supported in hungryserv - req.BeeperAutoJoinInvites = true - } - user.ManagementRoom, err = user.Bridge.Bot.CreateRoom(ctx, req) - if err != nil { - return "", fmt.Errorf("failed to create management room: %w", err) - } - if !autoJoin && doublePuppet != nil { - err = doublePuppet.EnsureJoined(ctx, user.ManagementRoom) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to auto-join created management room with double puppet") - } - } - err = user.Save(ctx) - if err != nil { - return "", fmt.Errorf("failed to save management room ID: %w", err) - } - return user.ManagementRoom, nil -} - -func (user *User) Save(ctx context.Context) error { - return user.Bridge.DB.User.Update(ctx, user.User) -} - -func (br *Bridge) TrackAnalytics(userID id.UserID, event string, props map[string]any) { - analyticSender, ok := br.Matrix.(MatrixConnectorWithAnalytics) - if ok { - analyticSender.TrackAnalytics(userID, event, props) - } -} - -func (user *User) TrackAnalytics(event string, props map[string]any) { - user.Bridge.TrackAnalytics(user.MXID, event, props) -} - -func (ul *UserLogin) TrackAnalytics(event string, props map[string]any) { - // TODO include user login ID? - ul.Bridge.TrackAnalytics(ul.UserMXID, event, props) -} diff --git a/mautrix-patched/bridgev2/userlogin.go b/mautrix-patched/bridgev2/userlogin.go deleted file mode 100644 index 9d4a5f1f..00000000 --- a/mautrix-patched/bridgev2/userlogin.go +++ /dev/null @@ -1,577 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package bridgev2 - -import ( - "cmp" - "context" - "fmt" - "maps" - "slices" - "sync" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exsync" - - "maunium.net/go/mautrix/bridgev2/bridgeconfig" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" -) - -type UserLogin struct { - *database.UserLogin - Bridge *Bridge - User *User - Log zerolog.Logger - - Client NetworkAPI - BridgeState *BridgeStateQueue - - inPortalCache *exsync.Set[networkid.PortalKey] - - spaceCreateLock sync.Mutex - deleteLock sync.Mutex - disconnectOnce sync.Once -} - -func (br *Bridge) loadUserLogin(ctx context.Context, user *User, dbUserLogin *database.UserLogin) (*UserLogin, error) { - if dbUserLogin == nil { - return nil, nil - } - if user == nil { - var err error - user, err = br.unlockedGetUserByMXID(ctx, dbUserLogin.UserMXID, true) - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - // TODO if loading the user caused the provided userlogin to be loaded, cancel here? - // Currently this will double-load it - } - userLogin := &UserLogin{ - UserLogin: dbUserLogin, - Bridge: br, - User: user, - Log: user.Log.With().Str("login_id", string(dbUserLogin.ID)).Logger(), - - inPortalCache: exsync.NewSet[networkid.PortalKey](), - } - err := br.Network.LoadUserLogin(ctx, userLogin) - if err != nil { - userLogin.Log.Err(err).Msg("Failed to load user login") - return nil, nil - } else if userLogin.Client == nil { - userLogin.Log.Error().Msg("LoadUserLogin didn't fill Client") - return nil, nil - } - userLogin.BridgeState = br.NewBridgeStateQueue(userLogin) - user.logins[userLogin.ID] = userLogin - br.userLoginsByID[userLogin.ID] = userLogin - return userLogin, nil -} - -func (br *Bridge) loadManyUserLogins(ctx context.Context, user *User, logins []*database.UserLogin) ([]*UserLogin, error) { - output := make([]*UserLogin, 0, len(logins)) - for _, dbLogin := range logins { - if cached, ok := br.userLoginsByID[dbLogin.ID]; ok { - output = append(output, cached) - } else { - loaded, err := br.loadUserLogin(ctx, user, dbLogin) - if err != nil { - return nil, err - } else if loaded != nil { - output = append(output, loaded) - } - } - } - return output, nil -} - -func (br *Bridge) unlockedLoadUserLoginsByMXID(ctx context.Context, user *User) error { - logins, err := br.DB.UserLogin.GetAllForUser(ctx, user.MXID) - if err != nil { - return err - } - _, err = br.loadManyUserLogins(ctx, user, logins) - return err -} - -func (br *Bridge) GetUserLoginsInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserLogin, error) { - if portal.Receiver != "" { - ul := br.GetCachedUserLoginByID(portal.Receiver) - if ul == nil { - return nil, nil - } - return []*UserLogin{ul}, nil - } - logins, err := br.DB.UserLogin.GetAllInPortal(ctx, portal) - if err != nil { - return nil, err - } - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.loadManyUserLogins(ctx, nil, logins) -} - -func (br *Bridge) GetExistingUserLoginByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.unlockedGetExistingUserLoginByID(ctx, id) -} - -func (br *Bridge) unlockedGetExistingUserLoginByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) { - cached, ok := br.userLoginsByID[id] - if ok { - return cached, nil - } - login, err := br.DB.UserLogin.GetByID(ctx, id) - if err != nil { - return nil, err - } - return br.loadUserLogin(ctx, nil, login) -} - -func (br *Bridge) GetCachedUserLoginByID(id networkid.UserLoginID) *UserLogin { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return br.userLoginsByID[id] -} - -func (br *Bridge) GetAllCachedUserLogins() (logins []*UserLogin) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - return slices.Collect(maps.Values(br.userLoginsByID)) -} - -func (br *Bridge) GetCurrentBridgeStates() (states []status.BridgeState) { - br.cacheLock.Lock() - defer br.cacheLock.Unlock() - if len(br.userLoginsByID) == 0 { - return []status.BridgeState{{ - StateEvent: status.StateUnconfigured, - }} - } - states = make([]status.BridgeState, len(br.userLoginsByID)) - i := 0 - for _, login := range br.userLoginsByID { - states[i] = login.BridgeState.GetPrev() - i++ - } - return -} - -type NewLoginParams struct { - LoadUserLogin func(context.Context, *UserLogin) error - DeleteOnConflict bool - DontReuseExisting bool -} - -// NewLogin creates a UserLogin object for this user with the given parameters. -// -// If a login already exists with the same ID, it is reused after updating the remote name -// and metadata from the provided data, unless DontReuseExisting is set in params. -// -// If the existing login belongs to another user, this returns an error, -// unless DeleteOnConflict is set in the params, in which case the existing login is deleted. -// -// This will automatically call LoadUserLogin after creating the UserLogin object. -// The load method defaults to the network connector's LoadUserLogin method, but it can be overridden in params. -func (user *User) NewLogin(ctx context.Context, data *database.UserLogin, params *NewLoginParams) (*UserLogin, error) { - user.Bridge.cacheLock.Lock() - defer user.Bridge.cacheLock.Unlock() - data.BridgeID = user.BridgeID - data.UserMXID = user.MXID - if data.Metadata == nil { - metaTypes := user.Bridge.Network.GetDBMetaTypes() - if metaTypes.UserLogin != nil { - data.Metadata = metaTypes.UserLogin() - } - } - if params == nil { - params = &NewLoginParams{} - } - if params.LoadUserLogin == nil { - params.LoadUserLogin = user.Bridge.Network.LoadUserLogin - } - ul, err := user.Bridge.unlockedGetExistingUserLoginByID(ctx, data.ID) - if err != nil { - return nil, fmt.Errorf("failed to check if login already exists: %w", err) - } - var doInsert bool - if ul != nil && ul.UserMXID != user.MXID { - if params.DeleteOnConflict { - ul.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN_ANOTHER_USER"}, DeleteOpts{ - LogoutRemote: false, - unlocked: true, - }) - ul = nil - } else { - return nil, fmt.Errorf("%s is already logged in with that account", ul.UserMXID) - } - } - if ul != nil { - if params.DontReuseExisting { - return nil, fmt.Errorf("login already exists") - } - doInsert = false - ul.RemoteName = data.RemoteName - ul.RemoteProfile = ul.RemoteProfile.Merge(data.RemoteProfile) - if merger, ok := ul.Metadata.(database.MetaMerger); ok { - merger.CopyFrom(data.Metadata) - } else { - ul.Metadata = data.Metadata - } - } else { - doInsert = true - ul = &UserLogin{ - UserLogin: data, - Bridge: user.Bridge, - User: user, - Log: user.Log.With().Str("login_id", string(data.ID)).Logger(), - } - ul.BridgeState = user.Bridge.NewBridgeStateQueue(ul) - } - noCancelCtx := ul.Log.WithContext(user.Bridge.BackgroundCtx) - err = params.LoadUserLogin(noCancelCtx, ul) - if err != nil { - return nil, err - } else if ul.Client == nil { - ul.Log.Error().Msg("LoadUserLogin didn't fill Client in NewLogin") - return nil, fmt.Errorf("client not filled by LoadUserLogin") - } - if doInsert { - err = user.Bridge.DB.UserLogin.Insert(noCancelCtx, ul.UserLogin) - if err != nil { - return nil, err - } - user.Bridge.userLoginsByID[ul.ID] = ul - user.logins[ul.ID] = ul - } else { - err = ul.Save(noCancelCtx) - if err != nil { - return nil, err - } - } - return ul, nil -} - -func (ul *UserLogin) Save(ctx context.Context) error { - return ul.Bridge.DB.UserLogin.Update(ctx, ul.UserLogin) -} - -func (ul *UserLogin) Logout(ctx context.Context) { - ul.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut}, DeleteOpts{LogoutRemote: true}) -} - -type DeleteOpts struct { - LogoutRemote bool - DontCleanupRooms bool - BlockingCleanup bool - unlocked bool -} - -func (ul *UserLogin) Delete(ctx context.Context, state status.BridgeState, opts DeleteOpts) { - cleanupRooms := !opts.DontCleanupRooms && ul.Bridge.Config.CleanupOnLogout.Enabled - zerolog.Ctx(ctx).Info().Str("user_login_id", string(ul.ID)). - Bool("logout_remote", opts.LogoutRemote). - Bool("cleanup_rooms", cleanupRooms). - Msg("Deleting user login") - ul.deleteLock.Lock() - defer ul.deleteLock.Unlock() - if ul.BridgeState == nil { - return - } - if opts.LogoutRemote { - ul.Client.LogoutRemote(ctx) - } else { - // we probably shouldn't delete the login if disconnect isn't finished - ul.Disconnect() - } - var portals []*database.UserPortal - var err error - if cleanupRooms { - portals, err = ul.Bridge.DB.UserPortal.GetAllForLogin(ctx, ul.UserLogin) - if err != nil { - ul.Log.Err(err).Msg("Failed to get user portals") - } - } - err = ul.Bridge.DB.UserLogin.Delete(ctx, ul.ID) - if err != nil { - ul.Log.Err(err).Msg("Failed to delete user login") - } - if !opts.unlocked { - ul.Bridge.cacheLock.Lock() - } - delete(ul.User.logins, ul.ID) - delete(ul.Bridge.userLoginsByID, ul.ID) - if !opts.unlocked { - ul.Bridge.cacheLock.Unlock() - } - backgroundCtx := zerolog.Ctx(ctx).WithContext(ul.Bridge.BackgroundCtx) - if !opts.BlockingCleanup { - go ul.deleteSpace(backgroundCtx) - } else { - ul.deleteSpace(backgroundCtx) - } - if portals != nil { - if !opts.BlockingCleanup { - go ul.kickUserFromPortals(backgroundCtx, portals, state.StateEvent == status.StateBadCredentials, false) - } else { - ul.kickUserFromPortals(backgroundCtx, portals, state.StateEvent == status.StateBadCredentials, false) - } - } - if state.StateEvent != "" { - ul.BridgeState.Send(state) - } - ul.BridgeState.Destroy() - ul.BridgeState = nil -} - -func (ul *UserLogin) deleteSpace(ctx context.Context) { - if ul.SpaceRoom == "" { - return - } - err := ul.Bridge.Bot.DeleteRoom(ctx, ul.SpaceRoom, false) - if err != nil { - ul.Log.Err(err).Msg("Failed to delete space room") - } -} - -// KickUserFromPortalsForBadCredentials can be called to kick the user from portals without deleting the entire UserLogin object. -func (ul *UserLogin) KickUserFromPortalsForBadCredentials(ctx context.Context) { - log := zerolog.Ctx(ctx) - portals, err := ul.Bridge.DB.UserPortal.GetAllForLogin(ctx, ul.UserLogin) - if err != nil { - log.Err(err).Msg("Failed to get user portals") - } - ul.kickUserFromPortals(ctx, portals, true, true) -} - -func DeleteManyPortals(ctx context.Context, portals []*Portal, errorCallback func(portal *Portal, delete bool, err error)) { - // TODO is there a more sensible place/name for this function? - if len(portals) == 0 { - return - } - getDepth := func(portal *Portal) int { - depth := 0 - for portal.Parent != nil { - depth++ - portal = portal.Parent - } - return depth - } - // Sort portals so parents are last (to avoid errors caused by deleting parent portals before children) - slices.SortFunc(portals, func(a, b *Portal) int { - return cmp.Compare(getDepth(b), getDepth(a)) - }) - for _, portal := range portals { - err := portal.Delete(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("portal_mxid", portal.MXID). - Object("portal_key", portal.PortalKey). - Msg("Failed to delete portal row from database") - if errorCallback != nil { - errorCallback(portal, false, err) - } - continue - } - if portal.MXID != "" { - err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("portal_mxid", portal.MXID). - Msg("Failed to clean up portal room") - if errorCallback != nil { - errorCallback(portal, true, err) - } - } - } - } -} - -func (ul *UserLogin) kickUserFromPortals(ctx context.Context, portals []*database.UserPortal, badCredentials, deleteRow bool) { - var portalsToDelete []*Portal - for _, up := range portals { - portalToDelete, err := ul.kickUserFromPortal(ctx, up, badCredentials, deleteRow) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Object("portal_key", up.Portal). - Stringer("user_mxid", up.UserMXID). - Msg("Failed to apply logout action") - } else if portalToDelete != nil { - portalsToDelete = append(portalsToDelete, portalToDelete) - } - } - DeleteManyPortals(ctx, portalsToDelete, nil) -} - -func (ul *UserLogin) kickUserFromPortal(ctx context.Context, up *database.UserPortal, badCredentials, deleteRow bool) (*Portal, error) { - portal, action, reason, err := ul.getLogoutAction(ctx, up, badCredentials) - if err != nil { - return nil, err - } else if portal == nil { - return nil, nil - } - zerolog.Ctx(ctx).Debug(). - Str("login_id", string(ul.ID)). - Stringer("user_mxid", ul.UserMXID). - Str("logout_action", string(action)). - Str("action_reason", reason). - Object("portal_key", portal.PortalKey). - Stringer("portal_mxid", portal.MXID). - Msg("Calculated portal action for logout processing") - switch action { - case bridgeconfig.CleanupActionNull, bridgeconfig.CleanupActionNothing: - // do nothing - case bridgeconfig.CleanupActionKick: - _, err = ul.Bridge.Bot.SendState(ctx, portal.MXID, event.StateMember, ul.UserMXID.String(), &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: "Logged out of bridge", - }, - }, time.Time{}) - if err != nil { - return nil, fmt.Errorf("failed to kick user from portal: %w", err) - } - zerolog.Ctx(ctx).Debug(). - Str("login_id", string(ul.ID)). - Stringer("user_mxid", ul.UserMXID). - Stringer("portal_mxid", portal.MXID). - Msg("Kicked user from portal") - if deleteRow { - err = ul.Bridge.DB.UserPortal.Delete(ctx, up) - if err != nil { - zerolog.Ctx(ctx).Warn(). - Str("login_id", string(ul.ID)). - Stringer("user_mxid", ul.UserMXID). - Stringer("portal_mxid", portal.MXID). - Msg("Failed to delete user portal row") - } - } - case bridgeconfig.CleanupActionDelete, bridgeconfig.CleanupActionUnbridge: - // return portal instead of deleting here to allow sorting by depth - return portal, nil - } - return nil, nil -} - -func (ul *UserLogin) getLogoutAction(ctx context.Context, up *database.UserPortal, badCredentials bool) (*Portal, bridgeconfig.CleanupAction, string, error) { - portal, err := ul.Bridge.GetExistingPortalByKey(ctx, up.Portal) - if err != nil { - return nil, bridgeconfig.CleanupActionNull, "", fmt.Errorf("failed to get full portal: %w", err) - } else if portal == nil || portal.MXID == "" { - return nil, bridgeconfig.CleanupActionNull, "portal not found", nil - } - actionsSet := ul.Bridge.Config.CleanupOnLogout.Manual - if badCredentials { - actionsSet = ul.Bridge.Config.CleanupOnLogout.BadCredentials - } - if portal.Receiver != "" { - return portal, actionsSet.Private, "portal has receiver", nil - } - otherUPs, err := ul.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) - if err != nil { - return portal, bridgeconfig.CleanupActionNull, "", fmt.Errorf("failed to get other logins in portal: %w", err) - } - hasOtherUsers := false - for _, otherUP := range otherUPs { - if otherUP.LoginID == ul.ID { - continue - } - if otherUP.UserMXID == ul.UserMXID { - otherUL := ul.Bridge.GetCachedUserLoginByID(otherUP.LoginID) - if otherUL != nil && otherUL.Client.IsLoggedIn() { - return portal, bridgeconfig.CleanupActionNull, "user has another login in portal", nil - } - } else { - hasOtherUsers = true - } - } - if portal.RelayLoginID != "" { - return portal, actionsSet.Relayed, "portal has relay login", nil - } else if hasOtherUsers { - return portal, actionsSet.SharedHasUsers, "portal has logins of other users", nil - } - return portal, actionsSet.SharedNoUsers, "portal doesn't have logins of other users", nil -} - -func (ul *UserLogin) MarkAsPreferredIn(ctx context.Context, portal *Portal) error { - return ul.Bridge.DB.UserPortal.MarkAsPreferred(ctx, ul.UserLogin, portal.PortalKey) -} - -var _ status.BridgeStateFiller = (*UserLogin)(nil) - -func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeState { - state.UserID = ul.UserMXID - state.RemoteID = ul.ID - state.RemoteName = ul.RemoteName - state.RemoteProfile = ul.RemoteProfile - if space := ul.SpaceRoom; space != "" { - if state.Info == nil { - state.Info = make(map[string]any) - } - state.Info["personal_filtering_space"] = space - } - filler, ok := ul.Client.(status.BridgeStateFiller) - if ok { - return filler.FillBridgeState(state) - } - return state -} - -func (ul *UserLogin) Disconnect() { - ul.DisconnectWithTimeout(0) -} - -func (ul *UserLogin) DisconnectWithTimeout(timeout time.Duration) { - ul.disconnectOnce.Do(func() { - ul.disconnectInternal(timeout) - }) -} - -func (ul *UserLogin) disconnectInternal(timeout time.Duration) { - ul.BridgeState.StopUnknownErrorReconnect() - disconnected := make(chan struct{}) - go func() { - ul.Client.Disconnect() - close(disconnected) - }() - - var timeoutC <-chan time.Time - if timeout > 0 { - timeoutC = time.After(timeout) - } - for { - select { - case <-disconnected: - return - case <-time.After(2 * time.Second): - ul.Log.Warn().Msg("Client disconnection taking long") - case <-timeoutC: - ul.Log.Error().Msg("Client disconnection timed out") - return - } - } -} - -func (ul *UserLogin) recreateClient(ctx context.Context) error { - oldClient := ul.Client - err := ul.Bridge.Network.LoadUserLogin(ctx, ul) - if err != nil { - return err - } - if ul.Client == oldClient { - zerolog.Ctx(ctx).Warn().Msg("LoadUserLogin didn't update client") - } else { - zerolog.Ctx(ctx).Debug().Msg("Recreated user login client") - } - ul.disconnectOnce = sync.Once{} - return nil -} diff --git a/mautrix-patched/client.go b/mautrix-patched/client.go deleted file mode 100644 index ba13c70b..00000000 --- a/mautrix-patched/client.go +++ /dev/null @@ -1,2999 +0,0 @@ -// Package mautrix implements the Matrix Client-Server API. -// -// Specification can be found at https://spec.matrix.org/v1.2/client-server-api/ -package mautrix - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "runtime" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exsync" - "go.mau.fi/util/ptr" - "go.mau.fi/util/random" - "go.mau.fi/util/retryafter" - "golang.org/x/exp/maps" - - "maunium.net/go/mautrix/crypto/backup" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" -) - -type CryptoHelper interface { - Encrypt(context.Context, id.RoomID, event.Type, any) (*event.EncryptedEventContent, error) - Decrypt(context.Context, *event.Event) (*event.Event, error) - WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool - RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) - Init(context.Context) error -} - -type VerificationHelper interface { - // Init initializes the helper. This should be called before any other - // methods. - Init(context.Context) error - - // StartVerification starts an interactive verification flow with the given - // user via a to-device event. - StartVerification(ctx context.Context, to id.UserID) (id.VerificationTransactionID, error) - // StartInRoomVerification starts an interactive verification flow with the - // given user in the given room. - StartInRoomVerification(ctx context.Context, roomID id.RoomID, to id.UserID) (id.VerificationTransactionID, error) - - // AcceptVerification accepts a verification request. - AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error - // DismissVerification dismisses a verification request. This will not send - // a cancellation to the other device. This method should only be called - // *before* the request has been accepted and will error otherwise. - DismissVerification(ctx context.Context, txnID id.VerificationTransactionID) error - // CancelVerification cancels a verification request. This method should - // only be called *after* the request has been accepted, although it will - // not error if called beforehand. - CancelVerification(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) error - - // HandleScannedQRData handles the data from a QR code scan. - HandleScannedQRData(ctx context.Context, data []byte) error - // ConfirmQRCodeScanned confirms that our QR code has been scanned. - ConfirmQRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) error - - // StartSAS starts a SAS verification flow. - StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error - // ConfirmSAS indicates that the user has confirmed that the SAS matches - // SAS shown on the other user's device. - ConfirmSAS(ctx context.Context, txnID id.VerificationTransactionID) error -} - -// Client represents a Matrix client. -type Client struct { - HomeserverURL *url.URL // The base homeserver URL - UserID id.UserID // The user ID of the client. Used for forming HTTP paths which use the client's user ID. - DeviceID id.DeviceID // The device ID of the client. - AccessToken string // The access_token for the client. - UserAgent string // The value for the User-Agent header - Client *http.Client // The underlying HTTP client which will be used to make HTTP requests. - Syncer Syncer // The thing which can process /sync responses - Store SyncStore // The thing which can store tokens/ids - StateStore StateStore - Crypto CryptoHelper - Verification VerificationHelper - SpecVersions *RespVersions - ExternalClient *http.Client // The HTTP client used for external (not matrix) media HTTP requests. - - Log zerolog.Logger - - RequestHook func(req *http.Request) - ResponseHook func(req *http.Request, resp *http.Response, err error, duration time.Duration) - - RequestRetryTrigger *exsync.Event - - SyncPresence event.Presence - SyncTraceLog bool - - StreamSyncMinAge time.Duration - - // Number of times that mautrix will retry any HTTP request - // if the request fails entirely or returns a HTTP gateway error (502-504) - DefaultHTTPRetries int - // Amount of time to wait between HTTP retries, defaults to 4 seconds - DefaultHTTPBackoff time.Duration - // Maximum time to wait between HTTP retries, defaults to 10 minutes. - // This applies to both the exponential backoff from gateway/network errors and to 429 errors. - MaxHTTPBackoff time.Duration - // Set to true to disable automatically sleeping on 429 errors. - IgnoreRateLimit bool - - ResponseSizeLimit int64 - - txnID int32 - - // Should the ?user_id= query parameter be set in requests? - // See https://spec.matrix.org/v1.6/application-service-api/#identity-assertion - SetAppServiceUserID bool - // Should the org.matrix.msc3202.device_id query parameter be set in requests? - // See https://github.com/matrix-org/matrix-spec-proposals/pull/3202 - SetAppServiceDeviceID bool - - syncingID uint32 // Identifies the current Sync. Only one Sync can be active at any given time. -} - -type ClientWellKnown struct { - Homeserver HomeserverInfo `json:"m.homeserver"` - IdentityServer IdentityServerInfo `json:"m.identity_server"` -} - -type HomeserverInfo struct { - BaseURL string `json:"base_url"` -} - -type IdentityServerInfo struct { - BaseURL string `json:"base_url"` -} - -// DiscoverClientAPI resolves the client API URL from a Matrix server name. -// Use ParseUserID to extract the server name from a user ID. -// https://spec.matrix.org/v1.2/client-server-api/#server-discovery -func DiscoverClientAPI(ctx context.Context, serverName string) (*ClientWellKnown, error) { - return DiscoverClientAPIWithClient(ctx, &http.Client{Timeout: 30 * time.Second}, serverName) -} - -const WellKnownMaxSize = 64 * 1024 - -func DiscoverClientAPIWithClient(ctx context.Context, client *http.Client, serverName string) (*ClientWellKnown, error) { - wellKnownURL := url.URL{ - Scheme: "https", - Host: serverName, - Path: "/.well-known/matrix/client", - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL.String(), nil) - if err != nil { - return nil, err - } - - if runtime.GOOS != "js" { - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", DefaultUserAgent+" (.well-known fetcher)") - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, nil - } else if resp.ContentLength > WellKnownMaxSize { - return nil, errors.New(".well-known response too large") - } - - data, err := io.ReadAll(io.LimitReader(resp.Body, WellKnownMaxSize)) - if err != nil { - return nil, err - } else if len(data) >= WellKnownMaxSize { - return nil, errors.New(".well-known response too large") - } - - var wellKnown ClientWellKnown - err = json.Unmarshal(data, &wellKnown) - if err != nil { - return nil, errors.New(".well-known response not JSON") - } - - return &wellKnown, nil -} - -// SetCredentials sets the user ID and access token on this client instance. -// -// Deprecated: use the StoreCredentials field in ReqLogin instead. -func (cli *Client) SetCredentials(userID id.UserID, accessToken string) { - cli.AccessToken = accessToken - cli.UserID = userID -} - -// ClearCredentials removes the user ID and access token on this client instance. -func (cli *Client) ClearCredentials() { - cli.AccessToken = "" - cli.UserID = "" - cli.DeviceID = "" -} - -// Sync starts syncing with the provided Homeserver. If Sync() is called twice then the first sync will be stopped and the -// error will be nil. -// -// This function will block until a fatal /sync error occurs, so it should almost always be started as a new goroutine. -// Fatal sync errors can be caused by: -// - The failure to create a filter. -// - Client.Syncer.OnFailedSync returning an error in response to a failed sync. -// - Client.Syncer.ProcessResponse returning an error. -// -// If you wish to continue retrying in spite of these fatal errors, call Sync() again. -func (cli *Client) Sync() error { - return cli.SyncWithContext(context.Background()) -} - -func (cli *Client) SyncWithContext(ctx context.Context) error { - // Mark the client as syncing. - // We will keep syncing until the syncing state changes. Either because - // Sync is called or StopSync is called. - syncingID := cli.incrementSyncingID() - nextBatch, err := cli.Store.LoadNextBatch(ctx, cli.UserID) - if err != nil { - return err - } - filterID, err := cli.Store.LoadFilterID(ctx, cli.UserID) - if err != nil { - return err - } - - if filterID == "" { - filterJSON := cli.Syncer.GetFilterJSON(cli.UserID) - resFilter, err := cli.CreateFilter(ctx, filterJSON) - if err != nil { - return err - } - filterID = resFilter.FilterID - if err := cli.Store.SaveFilterID(ctx, cli.UserID, filterID); err != nil { - return err - } - } - lastSuccessfulSync := time.Now().Add(-cli.StreamSyncMinAge - 1*time.Hour) - // Always do first sync with 0 timeout - isFailing := true - for { - streamResp := false - if cli.StreamSyncMinAge > 0 && time.Since(lastSuccessfulSync) > cli.StreamSyncMinAge { - cli.Log.Debug().Msg("Last sync is old, will stream next response") - streamResp = true - } - timeout := 30000 - if isFailing || nextBatch == "" { - timeout = 0 - } - resSync, err := cli.FullSyncRequest(ctx, ReqSync{ - Timeout: timeout, - Since: nextBatch, - FilterID: filterID, - FullState: false, - SetPresence: cli.SyncPresence, - StreamResponse: streamResp, - }) - if err != nil { - isFailing = true - if ctx.Err() != nil { - return ctx.Err() - } - duration, err2 := cli.Syncer.OnFailedSync(resSync, err) - if err2 != nil { - return err2 - } - if duration <= 0 { - continue - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(duration): - continue - } - } - isFailing = false - lastSuccessfulSync = time.Now() - - // Check that the syncing state hasn't changed - // Either because we've stopped syncing or another sync has been started. - // We discard the response from our sync. - if cli.getSyncingID() != syncingID { - return nil - } - - // Save the token now *before* processing it. This means it's possible - // to not process some events, but it means that we won't get constantly stuck processing - // a malformed/buggy event which keeps making us panic. - err = cli.Store.SaveNextBatch(ctx, cli.UserID, resSync.NextBatch) - if err != nil { - return err - } - if err = cli.Syncer.ProcessResponse(ctx, resSync, nextBatch); err != nil { - return err - } - - nextBatch = resSync.NextBatch - } -} - -func (cli *Client) incrementSyncingID() uint32 { - return atomic.AddUint32(&cli.syncingID, 1) -} - -func (cli *Client) getSyncingID() uint32 { - return atomic.LoadUint32(&cli.syncingID) -} - -// StopSync stops the ongoing sync started by Sync. -func (cli *Client) StopSync() { - // Advance the syncing state so that any running Syncs will terminate. - cli.incrementSyncingID() -} - -type contextKey int - -const ( - LogBodyContextKey contextKey = iota - LogRequestIDContextKey - MaxAttemptsContextKey - SyncTokenContextKey -) - -func (cli *Client) RequestStart(req *http.Request) { - if cli != nil && cli.RequestHook != nil { - cli.RequestHook(req) - } -} - -// WithMaxRetries updates the context to set the maximum number of retries for any HTTP requests made with the context. -// -// 0 means the request will only be attempted once and will not be retried. -// Negative values will remove the override and fallback to the defaults. -func WithMaxRetries(ctx context.Context, maxRetries int) context.Context { - return context.WithValue(ctx, MaxAttemptsContextKey, maxRetries+1) -} - -func (cli *Client) LogRequestDone(req *http.Request, resp *http.Response, err error, handlerErr error, contentLength int, duration time.Duration) { - if cli == nil { - return - } - var evt *zerolog.Event - if errors.Is(err, context.Canceled) { - evt = zerolog.Ctx(req.Context()).Warn() - } else if err != nil { - evt = zerolog.Ctx(req.Context()).Err(err) - } else if handlerErr != nil { - evt = zerolog.Ctx(req.Context()).Warn(). - AnErr("body_parse_err", handlerErr) - } else if cli.SyncTraceLog && strings.HasSuffix(req.URL.Path, "/_matrix/client/v3/sync") { - evt = zerolog.Ctx(req.Context()).Trace() - } else { - evt = zerolog.Ctx(req.Context()).Debug() - } - evt = evt. - Str("method", req.Method). - Str("url", req.URL.String()). - Dur("duration", duration) - if cli.ResponseHook != nil { - cli.ResponseHook(req, resp, err, duration) - } - if resp != nil { - mime := resp.Header.Get("Content-Type") - length := resp.ContentLength - if length == -1 && contentLength > 0 { - length = int64(contentLength) - } - evt = evt.Int("status_code", resp.StatusCode). - Int64("response_length", length). - Str("response_mime", mime) - if serverRequestID := resp.Header.Get("X-Beeper-Request-ID"); serverRequestID != "" { - evt.Str("beeper_request_id", serverRequestID) - } - } - if body := req.Context().Value(LogBodyContextKey); body != nil { - switch typedLogBody := body.(type) { - case json.RawMessage: - evt.RawJSON("req_body", typedLogBody) - case string: - evt.Str("req_body", typedLogBody) - default: - panic(fmt.Errorf("invalid type for LogBodyContextKey: %T", body)) - } - } - if errors.Is(err, context.Canceled) { - evt.Msg("Request canceled") - } else if err != nil { - evt.Msg("Request failed") - } else if handlerErr != nil { - evt.Msg("Request parsing failed") - } else { - evt.Msg("Request completed") - } -} - -func (cli *Client) MakeRequest(ctx context.Context, method string, httpURL string, reqBody any, resBody any) ([]byte, error) { - return cli.MakeFullRequest(ctx, FullRequest{Method: method, URL: httpURL, RequestJSON: reqBody, ResponseJSON: resBody}) -} - -type ClientResponseHandler = func(req *http.Request, res *http.Response, responseJSON any, sizeLimit int64) ([]byte, error) - -type FullRequest struct { - Method string - URL string - Headers http.Header - RequestJSON interface{} - RequestBytes []byte - RequestBody io.Reader - RequestLength int64 - ResponseJSON interface{} - MaxAttempts int - BackoffDuration time.Duration - SensitiveContent bool - Handler ClientResponseHandler - DontReadResponse bool - ResponseSizeLimit int64 - Logger *zerolog.Logger - Client *http.Client -} - -var requestID int32 -var logSensitiveContent = os.Getenv("MAUTRIX_LOG_SENSITIVE_CONTENT") == "yes" - -func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, error) { - reqID := atomic.AddInt32(&requestID, 1) - logger := zerolog.Ctx(ctx) - if logger.GetLevel() == zerolog.Disabled || logger == zerolog.DefaultContextLogger { - logger = params.Logger - } - ctx = logger.With(). - Int32("req_id", reqID). - Logger().WithContext(ctx) - - var logBody any - var reqBody io.Reader - var reqLen int64 - if params.RequestJSON != nil { - jsonStr, err := json.Marshal(params.RequestJSON) - if err != nil { - return nil, HTTPError{ - Message: "failed to marshal JSON", - WrappedError: err, - } - } - if params.SensitiveContent && !logSensitiveContent { - logBody = "" - } else if len(jsonStr) > 32768 { - logBody = fmt.Sprintf("", len(jsonStr)) - } else { - logBody = json.RawMessage(jsonStr) - } - reqBody = bytes.NewReader(jsonStr) - reqLen = int64(len(jsonStr)) - } else if params.RequestBytes != nil { - logBody = fmt.Sprintf("<%d bytes>", len(params.RequestBytes)) - reqBody = bytes.NewReader(params.RequestBytes) - reqLen = int64(len(params.RequestBytes)) - } else if params.RequestBody != nil { - logBody = "" - reqLen = -1 - if params.RequestLength > 0 { - logBody = fmt.Sprintf("<%d bytes>", params.RequestLength) - reqLen = params.RequestLength - } else if params.RequestLength == 0 { - zerolog.Ctx(ctx).Warn(). - Msg("RequestBody passed without specifying request length") - } - reqBody = params.RequestBody - if rsc, ok := params.RequestBody.(io.ReadSeekCloser); ok { - // Prevent HTTP from closing the request body, it might be needed for retries - reqBody = nopCloseSeeker{rsc} - } - } else if params.Method != http.MethodGet && params.Method != http.MethodHead { - params.RequestJSON = struct{}{} - logBody = json.RawMessage("{}") - reqBody = bytes.NewReader([]byte("{}")) - reqLen = 2 - } - ctx = context.WithValue(ctx, LogBodyContextKey, logBody) - ctx = context.WithValue(ctx, LogRequestIDContextKey, int(reqID)) - req, err := http.NewRequestWithContext(ctx, params.Method, params.URL, reqBody) - if err != nil { - return nil, HTTPError{ - Message: "failed to create request", - WrappedError: err, - } - } - if params.Headers != nil { - req.Header = params.Headers - } - if params.RequestJSON != nil { - req.Header.Set("Content-Type", "application/json") - } - req.ContentLength = reqLen - return req, nil -} - -func (cli *Client) MakeFullRequest(ctx context.Context, params FullRequest) ([]byte, error) { - data, _, err := cli.MakeFullRequestWithResp(ctx, params) - return data, err -} - -func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullRequest) ([]byte, *http.Response, error) { - if cli == nil { - return nil, nil, ErrClientIsNil - } - if cli.HomeserverURL == nil || cli.HomeserverURL.Scheme == "" { - return nil, nil, ErrClientHasNoHomeserver - } - if params.MaxAttempts == 0 { - maxAttempts, ok := ctx.Value(MaxAttemptsContextKey).(int) - if ok && maxAttempts > 0 { - params.MaxAttempts = maxAttempts - } else { - params.MaxAttempts = 1 + cli.DefaultHTTPRetries - } - } - if params.BackoffDuration == 0 { - if cli.DefaultHTTPBackoff == 0 { - params.BackoffDuration = 4 * time.Second - } else { - params.BackoffDuration = cli.DefaultHTTPBackoff - } - } - if params.Logger == nil { - params.Logger = &cli.Log - } - req, err := params.compileRequest(ctx) - if err != nil { - return nil, nil, err - } - if params.Handler == nil { - if params.DontReadResponse { - params.Handler = noopHandleResponse - } else { - params.Handler = handleNormalResponse - } - } - if cli.UserAgent != "" { - req.Header.Set("User-Agent", cli.UserAgent) - } - if len(cli.AccessToken) > 0 { - req.Header.Set("Authorization", "Bearer "+cli.AccessToken) - } - if params.ResponseSizeLimit == 0 { - params.ResponseSizeLimit = cli.ResponseSizeLimit - } - if params.ResponseSizeLimit == 0 { - params.ResponseSizeLimit = DefaultResponseSizeLimit - } - if params.Client == nil { - params.Client = cli.Client - } - return cli.executeCompiledRequest( - req, - params.MaxAttempts-1, - params.BackoffDuration, - params.ResponseJSON, - params.Handler, - params.DontReadResponse, - params.ResponseSizeLimit, - params.Client, - ) -} - -func (cli *Client) cliOrContextLog(ctx context.Context) *zerolog.Logger { - log := zerolog.Ctx(ctx) - if log.GetLevel() == zerolog.Disabled || log == zerolog.DefaultContextLogger { - return &cli.Log - } - return log -} - -func (cli *Client) doRetry( - req *http.Request, - cause error, - retries int, - backoff time.Duration, - responseJSON any, - handler ClientResponseHandler, - dontReadResponse bool, - sizeLimit int64, - client *http.Client, -) ([]byte, *http.Response, error) { - log := zerolog.Ctx(req.Context()) - if req.Body != nil { - var err error - if req.GetBody != nil { - req.Body, err = req.GetBody() - if err != nil { - log.Warn().Err(err).Msg("Failed to get new body to retry request") - return nil, nil, cause - } - } else if bodySeeker, ok := req.Body.(io.ReadSeeker); ok { - _, err = bodySeeker.Seek(0, io.SeekStart) - if err != nil { - log.Warn().Err(err).Msg("Failed to seek to beginning of request body") - return nil, nil, cause - } - } else { - log.Warn().Msg("Failed to get new body to retry request: GetBody is nil and Body is not an io.ReadSeeker") - return nil, nil, cause - } - } - maxBackoff := cli.MaxHTTPBackoff - if maxBackoff <= 0 { - maxBackoff = 10 * time.Minute - } - backoff = min(backoff, maxBackoff) - log.Warn().Err(cause). - Str("method", req.Method). - Str("url", req.URL.String()). - Int("retry_in_seconds", int(backoff.Seconds())). - Msg("Request failed, retrying") - - // if this was due to our RequestRetryTrigger then just retry immediately - // the req.Context() will still be live, otherwise do a normal backoff - if !errors.Is(cause, ErrContextCancelRetry) { - select { - case <-time.After(backoff): - case <-req.Context().Done(): - return nil, nil, req.Context().Err() - } - } - return cli.executeCompiledRequest(req, retries-1, backoff*2, responseJSON, handler, dontReadResponse, sizeLimit, client) -} - -func readResponseBody(req *http.Request, res *http.Response, limit int64) ([]byte, error) { - if res.ContentLength > limit { - return nil, HTTPError{ - Request: req, - Response: res, - - Message: "not reading response", - WrappedError: fmt.Errorf("%w (%.2f MiB)", ErrResponseTooLong, float64(res.ContentLength)/1024/1024), - } - } - contents, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) - if err == nil && len(contents) > int(limit) { - err = ErrBodyReadReachedLimit - } - if err != nil { - return nil, HTTPError{ - Request: req, - Response: res, - - Message: "failed to read response body", - WrappedError: err, - } - } - return contents, nil -} - -func closeTemp(log *zerolog.Logger, file *os.File) { - _ = file.Close() - err := os.Remove(file.Name()) - if err != nil { - log.Warn().Err(err).Str("file_name", file.Name()).Msg("Failed to remove response temp file") - } -} - -func streamResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { - log := zerolog.Ctx(req.Context()) - file, err := os.CreateTemp("", "mautrix-response-") - if err != nil { - log.Warn().Err(err).Msg("Failed to create temporary file for streaming response") - _, err = handleNormalResponse(req, res, responseJSON, limit) - return nil, err - } - defer closeTemp(log, file) - var n int64 - if n, err = io.Copy(file, io.LimitReader(res.Body, limit+1)); err != nil { - return nil, fmt.Errorf("failed to copy response to file: %w", err) - } else if n > limit { - return nil, ErrBodyReadReachedLimit - } else if _, err = file.Seek(0, 0); err != nil { - return nil, fmt.Errorf("failed to seek to beginning of response file: %w", err) - } else if err = json.NewDecoder(file).Decode(responseJSON); err != nil { - return nil, fmt.Errorf("failed to unmarshal response body: %w", err) - } else { - return nil, nil - } -} - -func noopHandleResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { - return nil, nil -} - -func handleNormalResponse(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { - if contents, err := readResponseBody(req, res, limit); err != nil { - return nil, err - } else if responseJSON == nil { - return contents, nil - } else if err = json.Unmarshal(contents, &responseJSON); err != nil { - return nil, HTTPError{ - Request: req, - Response: res, - - Message: "failed to unmarshal response body", - ResponseBody: string(contents), - WrappedError: err, - } - } else { - return contents, nil - } -} - -const ErrorResponseSizeLimit = 512 * 1024 - -var DefaultResponseSizeLimit int64 = 512 * 1024 * 1024 - -func ParseErrorResponse(req *http.Request, res *http.Response) ([]byte, error) { - defer res.Body.Close() - contents, err := readResponseBody(req, res, ErrorResponseSizeLimit) - if err != nil { - return contents, err - } - - respErr := &RespError{ - StatusCode: res.StatusCode, - } - if _ = json.Unmarshal(contents, respErr); respErr.ErrCode == "" { - respErr = nil - } - - return contents, HTTPError{ - Request: req, - Response: res, - RespError: respErr, - } -} - -func (cli *Client) prepareRequestAttempt(req *http.Request) (*http.Request, func()) { - // if there's no retry trigger, nothing to do - if cli.RequestRetryTrigger == nil { - return req, nil - } - - attemptCtx, cancel := context.WithCancelCause(req.Context()) - - // Register as a waiter synchronously so we're waiting by the time this method returns, avoid - // race on trigger notify and the goroutine below. - resetCh := cli.RequestRetryTrigger.GetChan() - - go func() { - select { - case <-resetCh: - cancel(ErrContextCancelRetry) - case <-attemptCtx.Done(): - } - }() - - return req.WithContext(attemptCtx), sync.OnceFunc(func() { - cancel(context.Canceled) - }) -} - -type cleanupReadCloser struct { - io.ReadCloser - cleanup func() -} - -type cleanupReadCloserWriterTo struct { - io.ReadCloser - cleanup func() -} - -func (crc cleanupReadCloser) Close() error { - err := crc.ReadCloser.Close() - if crc.cleanup != nil { - crc.cleanup() - } - return err -} - -func (crc cleanupReadCloserWriterTo) Close() error { - err := crc.ReadCloser.Close() - if crc.cleanup != nil { - crc.cleanup() - } - return err -} - -func (crc cleanupReadCloserWriterTo) WriteTo(w io.Writer) (int64, error) { - return crc.ReadCloser.(io.WriterTo).WriteTo(w) -} - -func maybeWrapRespBody(rc io.ReadCloser, cleanup func()) io.ReadCloser { - if cleanup == nil { - return rc - } - if _, ok := rc.(io.WriterTo); ok { - return cleanupReadCloserWriterTo{ - ReadCloser: rc, - cleanup: cleanup, - } - } - return cleanupReadCloser{ - ReadCloser: rc, - cleanup: cleanup, - } -} - -func (cli *Client) executeCompiledRequest( - req *http.Request, - retries int, - backoff time.Duration, - responseJSON any, - handler ClientResponseHandler, - dontReadResponse bool, - sizeLimit int64, - client *http.Client, -) ([]byte, *http.Response, error) { - attemptReq, cleanup := cli.prepareRequestAttempt(req) - cli.RequestStart(attemptReq) - startTime := time.Now() - res, err := client.Do(attemptReq) - duration := time.Since(startTime) - if res != nil { - // Cleanup the child attempt context once the body is closed - res.Body = maybeWrapRespBody(res.Body, cleanup) - if !dontReadResponse { - defer res.Body.Close() - } - } - if err != nil { - // cleanup child attempt context on error - if cleanup != nil { - cleanup() - } - - // Either error is *not* canceled or the underlying cause of cancellation explicitly asks to retry - attemptCause := context.Cause(attemptReq.Context()) - retryCause := err - if errors.Is(attemptCause, ErrContextCancelRetry) { - retryCause = attemptCause - } - canRetry := !errors.Is(err, context.Canceled) || - errors.Is(attemptCause, ErrContextCancelRetry) - if retries > 0 && canRetry { - return cli.doRetry( - req, retryCause, retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, - ) - } - err = HTTPError{ - Request: attemptReq, - Response: res, - - Message: "request error", - WrappedError: err, - } - cli.LogRequestDone(attemptReq, res, err, nil, 0, duration) - return nil, res, err - } - - if retries > 0 && retryafter.Should(res.StatusCode, !cli.IgnoreRateLimit) { - backoff = retryafter.Parse(res.Header.Get("Retry-After"), backoff) - return cli.doRetry( - req, fmt.Errorf("HTTP %d", res.StatusCode), retries, backoff, responseJSON, handler, dontReadResponse, sizeLimit, client, - ) - } - - var body []byte - if res.StatusCode < 200 || res.StatusCode >= 300 { - body, err = ParseErrorResponse(attemptReq, res) - cli.LogRequestDone(attemptReq, res, nil, nil, len(body), duration) - } else { - body, err = handler(attemptReq, res, responseJSON, sizeLimit) - cli.LogRequestDone(attemptReq, res, nil, err, len(body), duration) - } - return body, res, err -} - -// Whoami gets the user ID of the current user. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3accountwhoami -func (cli *Client) Whoami(ctx context.Context) (resp *RespWhoami, err error) { - urlPath := cli.BuildClientURL("v3", "account", "whoami") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// CreateFilter makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridfilter -func (cli *Client) CreateFilter(ctx context.Context, filter *Filter) (resp *RespCreateFilter, err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "filter") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, filter, &resp) - return -} - -// SyncRequest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync -func (cli *Client) SyncRequest(ctx context.Context, timeout int, since, filterID string, fullState bool, setPresence event.Presence) (resp *RespSync, err error) { - return cli.FullSyncRequest(ctx, ReqSync{ - Timeout: timeout, - Since: since, - FilterID: filterID, - FullState: fullState, - SetPresence: setPresence, - }) -} - -type ReqSync struct { - Timeout int - Since string - FilterID string - FullState bool - SetPresence event.Presence - StreamResponse bool - UseStateAfter bool - BeeperStreaming bool - Client *http.Client -} - -func (req *ReqSync) BuildQuery() map[string]string { - query := map[string]string{ - "timeout": strconv.Itoa(req.Timeout), - } - if req.Since != "" { - query["since"] = req.Since - } - if req.FilterID != "" { - query["filter"] = req.FilterID - } - if req.SetPresence != "" { - query["set_presence"] = string(req.SetPresence) - } - if req.FullState { - query["full_state"] = "true" - } - if req.UseStateAfter { - query["use_state_after"] = "true" - } - if req.BeeperStreaming { - query["com.beeper.streaming"] = "true" - } - return query -} - -// FullSyncRequest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync -func (cli *Client) FullSyncRequest(ctx context.Context, req ReqSync) (resp *RespSync, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "sync"}, req.BuildQuery()) - fullReq := FullRequest{ - Method: http.MethodGet, - URL: urlPath, - ResponseJSON: &resp, - Client: req.Client, - // We don't want automatic retries for SyncRequest, the Sync() wrapper handles those. - MaxAttempts: 1, - } - if req.StreamResponse { - fullReq.Handler = streamResponse - } - start := time.Now() - _, err = cli.MakeFullRequest(ctx, fullReq) - duration := time.Since(start) - timeout := time.Duration(req.Timeout) * time.Millisecond - buffer := 10 * time.Second - if req.Since == "" { - buffer = 1 * time.Minute - } - if err == nil && duration > timeout+buffer { - cli.cliOrContextLog(ctx).Warn(). - Str("since", req.Since). - Dur("duration", duration). - Dur("timeout", timeout). - Msg("Sync request took unusually long") - } - return -} - -// RegisterAvailable checks if a username is valid and available for registration on the server. -// -// See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv3registeravailable for more details -// -// This will always return an error if the username isn't available, so checking the actual response struct is generally -// not necessary. It is still returned for future-proofing. For a simple availability check, just check that the returned -// error is nil. `errors.Is` can be used to find the exact reason why a username isn't available: -// -// _, err := cli.RegisterAvailable("cat") -// if errors.Is(err, mautrix.MUserInUse) { -// // Username is taken -// } else if errors.Is(err, mautrix.MInvalidUsername) { -// // Username is not valid -// } else if errors.Is(err, mautrix.MExclusive) { -// // Username is reserved for an appservice -// } else if errors.Is(err, mautrix.MLimitExceeded) { -// // Too many requests -// } else if err != nil { -// // Unknown error -// } else { -// // Username is available -// } -func (cli *Client) RegisterAvailable(ctx context.Context, username string) (resp *RespRegisterAvailable, err error) { - u := cli.BuildURLWithQuery(ClientURLPath{"v3", "register", "available"}, map[string]string{"username": username}) - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) - if err == nil && !resp.Available { - err = fmt.Errorf(`request returned OK status without "available": true`) - } - return -} - -func (cli *Client) register(ctx context.Context, url string, req *ReqRegister[any]) (resp *RespRegister, uiaResp *RespUserInteractive, err error) { - var bodyBytes []byte - bodyBytes, err = cli.MakeFullRequest(ctx, FullRequest{ - Method: http.MethodPost, - URL: url, - RequestJSON: req, - SensitiveContent: len(req.Password) > 0, - }) - if err != nil { - httpErr, ok := err.(HTTPError) - // if response has a 401 status, but doesn't have the errcode field, it's probably a UIA response. - if ok && httpErr.IsStatus(http.StatusUnauthorized) && httpErr.RespError == nil { - err = json.Unmarshal(bodyBytes, &uiaResp) - } - } else { - // body should be RespRegister - err = json.Unmarshal(bodyBytes, &resp) - } - return -} - -// Register makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register -// -// Registers with kind=user. For kind=guest, see RegisterGuest. -func (cli *Client) Register(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) { - u := cli.BuildClientURL("v3", "register") - return cli.register(ctx, u, req) -} - -// RegisterGuest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register -// with kind=guest. -// -// For kind=user, see Register. -func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) { - query := map[string]string{ - "kind": "guest", - } - u := cli.BuildURLWithQuery(ClientURLPath{"v3", "register"}, query) - return cli.register(ctx, u, req) -} - -// RegisterDummy performs m.login.dummy registration according to https://spec.matrix.org/v1.2/client-server-api/#dummy-auth -// -// Only a username and password need to be provided on the ReqRegister struct. Most local/developer homeservers will allow registration -// this way. If the homeserver does not, an error is returned. -// -// This does not set credentials on the client instance. See SetCredentials() instead. -// -// res, err := cli.RegisterDummy(&mautrix.ReqRegister{ -// Username: "alice", -// Password: "wonderland", -// }) -// if err != nil { -// panic(err) -// } -// token := res.AccessToken -func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister[any]) (*RespRegister, error) { - _, uia, err := cli.Register(ctx, req) - if err != nil && uia == nil { - return nil, err - } else if uia == nil { - return nil, errors.New("server did not return user-interactive auth flows") - } else if !uia.HasSingleStageFlow(AuthTypeDummy) { - return nil, errors.New("server does not support m.login.dummy") - } - req.Auth = BaseAuthData{Type: AuthTypeDummy, Session: uia.Session} - res, _, err := cli.Register(ctx, req) - if err != nil { - return nil, err - } - return res, nil -} - -// GetLoginFlows fetches the login flows that the homeserver supports using https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3login -func (cli *Client) GetLoginFlows(ctx context.Context) (resp *RespLoginFlows, err error) { - urlPath := cli.BuildClientURL("v3", "login") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// Login a user to the homeserver according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login -func (cli *Client) Login(ctx context.Context, req *ReqLogin) (resp *RespLogin, err error) { - _, err = cli.MakeFullRequest(ctx, FullRequest{ - Method: http.MethodPost, - URL: cli.BuildClientURL("v3", "login"), - RequestJSON: req, - ResponseJSON: &resp, - SensitiveContent: len(req.Password) > 0 || len(req.Token) > 0, - }) - if req.StoreCredentials && err == nil { - cli.DeviceID = resp.DeviceID - cli.AccessToken = resp.AccessToken - cli.UserID = resp.UserID - - cli.Log.Debug(). - Str("user_id", cli.UserID.String()). - Str("device_id", cli.DeviceID.String()). - Msg("Stored credentials after login") - } - if req.StoreHomeserverURL && err == nil && resp.WellKnown != nil && len(resp.WellKnown.Homeserver.BaseURL) > 0 { - var urlErr error - cli.HomeserverURL, urlErr = url.Parse(resp.WellKnown.Homeserver.BaseURL) - if urlErr != nil { - cli.Log.Warn(). - Err(urlErr). - Str("homeserver_url", resp.WellKnown.Homeserver.BaseURL). - Msg("Failed to parse homeserver URL in login response") - } else { - cli.Log.Debug(). - Str("homeserver_url", cli.HomeserverURL.String()). - Msg("Updated homeserver URL after login") - } - } - return -} - -// Create a device for an appservice user using MSC4190. -func (cli *Client) CreateDeviceMSC4190(ctx context.Context, deviceID id.DeviceID, initialDisplayName string) error { - if len(deviceID) == 0 { - deviceID = id.DeviceID(strings.ToUpper(random.String(10))) - } - _, err := cli.MakeRequest(ctx, http.MethodPut, cli.BuildClientURL("v3", "devices", deviceID), &ReqPutDevice{ - DisplayName: initialDisplayName, - }, nil) - if err != nil { - return err - } - cli.DeviceID = deviceID - cli.SetAppServiceDeviceID = true - return nil -} - -// Logout the current user. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logout -// This does not clear the credentials from the client instance. See ClearCredentials() instead. -func (cli *Client) Logout(ctx context.Context) (resp *RespLogout, err error) { - urlPath := cli.BuildClientURL("v3", "logout") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp) - return -} - -// LogoutAll logs out all the devices of the current user. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logoutall -// This does not clear the credentials from the client instance. See ClearCredentials() instead. -func (cli *Client) LogoutAll(ctx context.Context) (resp *RespLogout, err error) { - urlPath := cli.BuildClientURL("v3", "logout", "all") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp) - return -} - -// Versions returns the list of supported Matrix versions on this homeserver. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientversions -func (cli *Client) Versions(ctx context.Context) (resp *RespVersions, err error) { - urlPath := cli.BuildClientURL("versions") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - if resp != nil { - cli.SpecVersions = resp - } - return -} - -// Capabilities returns capabilities on this homeserver. See https://spec.matrix.org/v1.3/client-server-api/#capabilities-negotiation -func (cli *Client) Capabilities(ctx context.Context) (resp *RespCapabilities, err error) { - urlPath := cli.BuildClientURL("v3", "capabilities") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// JoinRoom joins the client to a room ID or alias. See https://spec.matrix.org/v1.13/client-server-api/#post_matrixclientv3joinroomidoralias -// -// The last parameter contains optional extra fields and can be left nil. -func (cli *Client) JoinRoom(ctx context.Context, roomIDorAlias string, req *ReqJoinRoom) (resp *RespJoinRoom, err error) { - if req == nil { - req = &ReqJoinRoom{} - } - urlPath := cli.BuildURLWithFullQuery(ClientURLPath{"v3", "join", roomIDorAlias}, func(q url.Values) { - if len(req.Via) > 0 { - q["via"] = req.Via - } - }) - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) - if err != nil { - err = fmt.Errorf("failed to update state store: %w", err) - } - } - return -} - -// KnockRoom requests to join a room ID or alias. See https://spec.matrix.org/v1.13/client-server-api/#post_matrixclientv3knockroomidoralias -// -// The last parameter contains optional extra fields and can be left nil. -func (cli *Client) KnockRoom(ctx context.Context, roomIDorAlias string, req *ReqKnockRoom) (resp *RespKnockRoom, err error) { - if req == nil { - req = &ReqKnockRoom{} - } - urlPath := cli.BuildURLWithFullQuery(ClientURLPath{"v3", "knock", roomIDorAlias}, func(q url.Values) { - if len(req.Via) > 0 { - q["via"] = req.Via - } - }) - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipKnock) - if err != nil { - err = fmt.Errorf("failed to update state store: %w", err) - } - } - return -} - -// JoinRoomByID joins the client to a room ID. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidjoin -// -// Unlike JoinRoom, this method can only be used to join rooms that the server already knows about. -// It's mostly intended for bridges and other things where it's already certain that the server is in the room. -func (cli *Client) JoinRoomByID(ctx context.Context, roomID id.RoomID) (resp *RespJoinRoom, err error) { - _, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildClientURL("v3", "rooms", roomID, "join"), nil, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) - if err != nil { - err = fmt.Errorf("failed to update state store: %w", err) - } - } - return -} - -func (cli *Client) GetProfile(ctx context.Context, mxid id.UserID) (resp *RespUserProfile, err error) { - urlPath := cli.BuildClientURL("v3", "profile", mxid) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) SearchUserDirectory(ctx context.Context, query string, limit int) (resp *RespSearchUserDirectory, err error) { - urlPath := cli.BuildClientURL("v3", "user_directory", "search") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqSearchUserDirectory{ - SearchTerm: query, - Limit: limit, - }, &resp) - return -} - -func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, extras ...ReqMutualRooms) (resp *RespMutualRooms, err error) { - supportsStable := cli.SpecVersions.Supports(FeatureStableMutualRooms) - supportsUnstable := cli.SpecVersions.Supports(FeatureUnstableMutualRooms) - if cli.SpecVersions != nil && !supportsUnstable && !supportsStable { - err = fmt.Errorf("server does not support fetching mutual rooms") - return - } - query := map[string]string{ - "user_id": otherUserID.String(), - } - if len(extras) > 0 { - query["from"] = extras[0].From - } - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "mutual_rooms"}, query) - if !supportsStable && supportsUnstable { - urlPath = cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query) - } - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) GetRoomSummary(ctx context.Context, roomIDOrAlias string, via ...string) (resp *RespRoomSummary, err error) { - urlPath := ClientURLPath{"unstable", "im.nheko.summary", "summary", roomIDOrAlias} - if cli.SpecVersions.ContainsGreaterOrEqual(SpecV115) { - urlPath = ClientURLPath{"v1", "room_summary", roomIDOrAlias} - } - // TODO add version check after one is added to MSC3266 - fullURL := cli.BuildURLWithFullQuery(urlPath, func(q url.Values) { - if len(via) > 0 { - q["via"] = via - } - }) - _, err = cli.MakeRequest(ctx, http.MethodGet, fullURL, nil, &resp) - return -} - -// GetDisplayName returns the display name of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname -func (cli *Client) GetDisplayName(ctx context.Context, mxid id.UserID) (resp *RespUserDisplayName, err error) { - err = cli.GetProfileField(ctx, mxid, "displayname", &resp) - return -} - -// GetOwnDisplayName returns the user's display name. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname -func (cli *Client) GetOwnDisplayName(ctx context.Context) (resp *RespUserDisplayName, err error) { - return cli.GetDisplayName(ctx, cli.UserID) -} - -// SetDisplayName sets the user's profile display name. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseriddisplayname -func (cli *Client) SetDisplayName(ctx context.Context, displayName string) (err error) { - return cli.SetProfileField(ctx, "displayname", displayName) -} - -// SetProfileField sets an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname -func (cli *Client) SetProfileField(ctx context.Context, key string, value any) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) - if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { - urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) - } - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, map[string]any{ - key: value, - }, nil) - return -} - -// DeleteProfileField deletes an arbitrary profile field. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3profileuseridkeyname -func (cli *Client) DeleteProfileField(ctx context.Context, key string) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, key) - if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { - urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) - } - _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) - return -} - -// GetProfileField gets an arbitrary profile field and parses the response into the given struct. See https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv3profileuseridkeyname -func (cli *Client) GetProfileField(ctx context.Context, userID id.UserID, key string, into any) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", userID, key) - if key != "displayname" && key != "avatar_url" && !cli.SpecVersions.Supports(FeatureArbitraryProfileFields) && cli.SpecVersions.Supports(FeatureUnstableProfileFields) { - urlPath = cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID, key) - } - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, into) - return -} - -// GetAvatarURL gets the avatar URL of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url -func (cli *Client) GetAvatarURL(ctx context.Context, mxid id.UserID) (url id.ContentURI, err error) { - s := struct { - AvatarURL id.ContentURI `json:"avatar_url"` - }{} - err = cli.GetProfileField(ctx, mxid, "avatar_url", &s) - url = s.AvatarURL - return -} - -// GetOwnAvatarURL gets the user's avatar URL. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url -func (cli *Client) GetOwnAvatarURL(ctx context.Context) (url id.ContentURI, err error) { - return cli.GetAvatarURL(ctx, cli.UserID) -} - -// SetAvatarURL sets the user's avatar URL. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseridavatar_url -func (cli *Client) SetAvatarURL(ctx context.Context, url id.ContentURI) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, "avatar_url") - s := struct { - AvatarURL string `json:"avatar_url"` - }{url.String()} - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &s, nil) - if err != nil { - return err - } - - return nil -} - -// BeeperUpdateProfile sets custom fields in the user's profile. -func (cli *Client) BeeperUpdateProfile(ctx context.Context, data any) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", cli.UserID) - _, err = cli.MakeRequest(ctx, http.MethodPatch, urlPath, data, nil) - return -} - -// UnstableOverwriteProfile replaces the user's entire profile -func (cli *Client) UnstableOverwriteProfile(ctx context.Context, data any) (err error) { - urlPath := cli.BuildClientURL("v3", "profile", cli.UserID) - if cli.SpecVersions.Supports(FeatureUnstableReplaceProfile) && !cli.SpecVersions.Supports(FeatureStableReplaceProfile) { - urlPath = cli.BuildClientURL("unstable", "com.beeper.msc4437", "profile", cli.UserID) - } - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) - return -} - -// GetAccountData gets the user's account data of this type. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3useruseridaccount_datatype -func (cli *Client) GetAccountData(ctx context.Context, name string, output interface{}) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "account_data", name) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, output) - return -} - -// SetAccountData sets the user's account data of this type. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridaccount_datatype -func (cli *Client) SetAccountData(ctx context.Context, name string, data interface{}) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "account_data", name) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) - if err != nil { - return err - } - - return nil -} - -// GetRoomAccountData gets the user's account data of this type in a specific room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridaccount_datatype -func (cli *Client) GetRoomAccountData(ctx context.Context, roomID id.RoomID, name string, output interface{}) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "account_data", name) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, output) - return -} - -// SetRoomAccountData sets the user's account data of this type in a specific room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridroomsroomidaccount_datatype -func (cli *Client) SetRoomAccountData(ctx context.Context, roomID id.RoomID, name string, data interface{}) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "account_data", name) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) - if err != nil { - return err - } - - return nil -} - -// SendMessageEvent sends a message event into a room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid -// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. -func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { - var req ReqSendEvent - if len(extra) > 0 { - req = extra[0] - } - - var txnID string - if len(req.TransactionID) > 0 { - txnID = req.TransactionID - } else { - txnID = cli.TxnID() - } - - queryParams := map[string]string{} - if req.Timestamp > 0 { - queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) - } - if req.MeowEventID != "" { - queryParams["fi.mau.event_id"] = req.MeowEventID.String() - } - if req.UnstableDelay > 0 { - queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) - } - if req.UnstableStickyDuration > 0 { - queryParams["org.matrix.msc4354.sticky_duration_ms"] = strconv.FormatInt(req.UnstableStickyDuration.Milliseconds(), 10) - } - - if !req.DontEncrypt && cli != nil && cli.Crypto != nil && eventType != event.EventReaction && eventType != event.EventEncrypted { - var isEncrypted bool - isEncrypted, err = cli.StateStore.IsEncrypted(ctx, roomID) - if err != nil { - err = fmt.Errorf("failed to check if room is encrypted: %w", err) - return - } - if isEncrypted { - if contentJSON, err = cli.Crypto.Encrypt(ctx, roomID, eventType, contentJSON); err != nil { - err = fmt.Errorf("failed to encrypt event: %w", err) - return - } - eventType = event.EventEncrypted - } - } - - urlData := ClientURLPath{"v3", "rooms", roomID, "send", eventType.String(), txnID} - urlPath := cli.BuildURLWithQuery(urlData, queryParams) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp) - return -} - -// SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey -// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. -func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) { - var req ReqSendEvent - if len(extra) > 0 { - req = extra[0] - } - - queryParams := map[string]string{} - if req.MeowEventID != "" { - queryParams["fi.mau.event_id"] = req.MeowEventID.String() - } - if req.TransactionID != "" { - queryParams["fi.mau.transaction_id"] = req.TransactionID - } - if req.UnstableDelay > 0 { - queryParams["org.matrix.msc4140.delay"] = strconv.FormatInt(req.UnstableDelay.Milliseconds(), 10) - } - if req.UnstableStickyDuration > 0 { - queryParams["org.matrix.msc4354.sticky_duration_ms"] = strconv.FormatInt(req.UnstableStickyDuration.Milliseconds(), 10) - } - if req.Timestamp > 0 { - queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10) - } - - urlData := ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey} - urlPath := cli.BuildURLWithQuery(urlData, queryParams) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp) - if err == nil && cli.StateStore != nil && req.UnstableDelay == 0 { - cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, contentJSON) - } - return -} - -// SendMassagedStateEvent sends a state event into a room with a custom timestamp. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey -// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal. -// -// Deprecated: SendStateEvent accepts a timestamp via ReqSendEvent and should be used instead. -func (cli *Client) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (resp *RespSendEvent, err error) { - resp, err = cli.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, ReqSendEvent{ - Timestamp: ts, - }) - return -} - -func (cli *Client) DelayedEvents(ctx context.Context, req *ReqDelayedEvents) (resp *RespDelayedEvents, err error) { - query := map[string]string{} - if req.DelayID != "" { - query["delay_id"] = string(req.DelayID) - } - if req.Status != "" { - query["status"] = string(req.Status) - } - if req.NextBatch != "" { - query["next_batch"] = req.NextBatch - } - - urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "org.matrix.msc4140", "delayed_events"}, query) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, req, &resp) - - // Migration: merge old keys with new ones - if resp != nil { - resp.Scheduled = append(resp.Scheduled, resp.DelayedEvents...) - resp.DelayedEvents = nil - resp.Finalised = append(resp.Finalised, resp.FinalisedEvents...) - resp.FinalisedEvents = nil - } - - return -} - -func (cli *Client) UpdateDelayedEvent(ctx context.Context, req *ReqUpdateDelayedEvent) (resp *RespUpdateDelayedEvent, err error) { - urlPath := cli.BuildClientURL("unstable", "org.matrix.msc4140", "delayed_events", req.DelayID) - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -// SendText sends an m.room.message event into the given room with a msgtype of m.text -// See https://spec.matrix.org/v1.2/client-server-api/#mtext -func (cli *Client) SendText(ctx context.Context, roomID id.RoomID, text string) (*RespSendEvent, error) { - return cli.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - }) -} - -// SendNotice sends an m.room.message event into the given room with a msgtype of m.notice -// See https://spec.matrix.org/v1.2/client-server-api/#mnotice -func (cli *Client) SendNotice(ctx context.Context, roomID id.RoomID, text string) (*RespSendEvent, error) { - return cli.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: text, - }) -} - -func (cli *Client) SendReaction(ctx context.Context, roomID id.RoomID, eventID id.EventID, reaction string) (*RespSendEvent, error) { - return cli.SendMessageEvent(ctx, roomID, event.EventReaction, &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - EventID: eventID, - Type: event.RelAnnotation, - Key: reaction, - }, - }) -} - -// RedactEvent redacts the given event. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidredacteventidtxnid -func (cli *Client) RedactEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, extra ...ReqRedact) (resp *RespSendEvent, err error) { - req := ReqRedact{} - if len(extra) > 0 { - req = extra[0] - } - if req.Extra == nil { - req.Extra = make(map[string]interface{}) - } - if len(req.Reason) > 0 { - req.Extra["reason"] = req.Reason - } - var txnID string - if len(req.TxnID) > 0 { - txnID = req.TxnID - } else { - txnID = cli.TxnID() - } - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "redact", eventID, txnID) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req.Extra, &resp) - return -} - -func (cli *Client) UnstableRedactUserEvents(ctx context.Context, roomID id.RoomID, userID id.UserID, req *ReqRedactUser) (resp *RespRedactUserEvents, err error) { - if req == nil { - req = &ReqRedactUser{} - } - query := map[string]string{} - if req.Limit > 0 { - query["limit"] = strconv.Itoa(req.Limit) - } - urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "org.matrix.msc4194", "rooms", roomID, "redact", "user", userID}, query) - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -// CreateRoom creates a new Matrix room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom -// -// resp, err := cli.CreateRoom(&mautrix.ReqCreateRoom{ -// Preset: "public_chat", -// }) -// fmt.Println("Room:", resp.RoomID) -func (cli *Client) CreateRoom(ctx context.Context, req *ReqCreateRoom) (resp *RespCreateRoom, err error) { - urlPath := cli.BuildClientURL("v3", "createRoom") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - if err == nil && cli.StateStore != nil { - storeErr := cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) - if storeErr != nil { - cli.cliOrContextLog(ctx).Warn().Err(storeErr). - Stringer("creator_user_id", cli.UserID). - Msg("Failed to update creator membership in state store after creating room") - } - for _, evt := range req.InitialState { - evt.RoomID = resp.RoomID - if evt.StateKey == nil { - evt.StateKey = ptr.Ptr("") - } - UpdateStateStore(ctx, cli.StateStore, evt) - } - inviteMembership := event.MembershipInvite - if req.BeeperAutoJoinInvites { - inviteMembership = event.MembershipJoin - } - for _, invitee := range req.Invite { - storeErr = cli.StateStore.SetMembership(ctx, resp.RoomID, invitee, inviteMembership) - if storeErr != nil { - cli.cliOrContextLog(ctx).Warn().Err(storeErr). - Stringer("invitee_user_id", invitee). - Msg("Failed to update membership in state store after creating room") - } - } - } - return -} - -// LeaveRoom leaves the given room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidleave -func (cli *Client) LeaveRoom(ctx context.Context, roomID id.RoomID, optionalReq ...*ReqLeave) (resp *RespLeaveRoom, err error) { - req := &ReqLeave{} - if len(optionalReq) == 1 { - req = optionalReq[0] - } else if len(optionalReq) > 1 { - panic("invalid number of arguments to LeaveRoom") - } - u := cli.BuildClientURL("v3", "rooms", roomID, "leave") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, roomID, cli.UserID, event.MembershipLeave) - if err != nil { - err = fmt.Errorf("failed to update membership in state store: %w", err) - } - } - return -} - -// ForgetRoom forgets a room entirely. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidforget -func (cli *Client) ForgetRoom(ctx context.Context, roomID id.RoomID) (resp *RespForgetRoom, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "forget") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, struct{}{}, &resp) - return -} - -// InviteUser invites a user to a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite -func (cli *Client) InviteUser(ctx context.Context, roomID id.RoomID, req *ReqInviteUser) (resp *RespInviteUser, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "invite") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipInvite) - if err != nil { - err = fmt.Errorf("failed to update membership in state store: %w", err) - } - } - return -} - -// InviteUserByThirdParty invites a third-party identifier to a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite-1 -func (cli *Client) InviteUserByThirdParty(ctx context.Context, roomID id.RoomID, req *ReqInvite3PID) (resp *RespInviteUser, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "invite") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - return -} - -// KickUser kicks a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick -func (cli *Client) KickUser(ctx context.Context, roomID id.RoomID, req *ReqKickUser) (resp *RespKickUser, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "kick") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipLeave) - if err != nil { - err = fmt.Errorf("failed to update membership in state store: %w", err) - } - } - return -} - -// BanUser bans a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban -func (cli *Client) BanUser(ctx context.Context, roomID id.RoomID, req *ReqBanUser) (resp *RespBanUser, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "ban") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipBan) - if err != nil { - err = fmt.Errorf("failed to update membership in state store: %w", err) - } - } - return -} - -// UnbanUser unbans a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban -func (cli *Client) UnbanUser(ctx context.Context, roomID id.RoomID, req *ReqUnbanUser) (resp *RespUnbanUser, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "unban") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - if err == nil && cli.StateStore != nil { - err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipLeave) - if err != nil { - err = fmt.Errorf("failed to update membership in state store: %w", err) - } - } - return -} - -// UserTyping sets the typing status of the user. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid -func (cli *Client) UserTyping(ctx context.Context, roomID id.RoomID, typing bool, timeout time.Duration) (resp *RespTyping, err error) { - req := ReqTyping{Typing: typing, Timeout: timeout.Milliseconds()} - u := cli.BuildClientURL("v3", "rooms", roomID, "typing", cli.UserID) - _, err = cli.MakeRequest(ctx, http.MethodPut, u, req, &resp) - return -} - -// GetPresence gets the presence of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus -func (cli *Client) GetPresence(ctx context.Context, userID id.UserID) (resp *RespPresence, err error) { - resp = new(RespPresence) - u := cli.BuildClientURL("v3", "presence", userID, "status") - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, resp) - return -} - -// GetOwnPresence gets the user's presence. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus -func (cli *Client) GetOwnPresence(ctx context.Context) (resp *RespPresence, err error) { - return cli.GetPresence(ctx, cli.UserID) -} - -func (cli *Client) SetPresence(ctx context.Context, presence ReqPresence) (err error) { - u := cli.BuildClientURL("v3", "presence", cli.UserID, "status") - _, err = cli.MakeRequest(ctx, http.MethodPut, u, presence, nil) - return -} - -func (cli *Client) updateStoreWithOutgoingEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) { - if cli == nil || cli.StateStore == nil { - return - } - fakeEvt := &event.Event{ - StateKey: &stateKey, - Type: eventType, - RoomID: roomID, - } - var err error - fakeEvt.Content.VeryRaw, err = json.Marshal(contentJSON) - if err != nil { - cli.Log.Warn().Err(err).Msg("Failed to marshal state event content to update state store") - return - } - err = json.Unmarshal(fakeEvt.Content.VeryRaw, &fakeEvt.Content.Raw) - if err != nil { - cli.Log.Warn().Err(err).Msg("Failed to unmarshal state event content to update state store") - return - } - err = fakeEvt.Content.ParseRaw(fakeEvt.Type) - if err != nil { - switch fakeEvt.Type { - case event.StateMember, event.StatePowerLevels, event.StateEncryption: - cli.Log.Warn().Err(err).Msg("Failed to parse state event content to update state store") - default: - cli.Log.Debug().Err(err).Msg("Failed to parse state event content to update state store") - } - return - } - UpdateStateStore(ctx, cli.StateStore, fakeEvt) -} - -// StateEvent gets the content of a single state event in a room. -// It will attempt to JSON unmarshal into the given "outContent" struct with the HTTP response body, or return an error. -// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstateeventtypestatekey -func (cli *Client) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) (err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "state", eventType.String(), stateKey) - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, outContent) - if err == nil && cli.StateStore != nil { - cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, outContent) - } - return -} - -// FullStateEvent gets a single state event in a room. Unlike [StateEvent], this gets the entire event -// (including details like the sender and timestamp). -// This requires the server to support the ?format=event query parameter, which is currently missing from the spec. -// See https://github.com/matrix-org/matrix-spec/issues/1047 for more info -func (cli *Client) FullStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (evt *event.Event, err error) { - u := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey}, map[string]string{ - "format": "event", - }) - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &evt) - if evt != nil { - evt.Type.Class = event.StateEventType - _ = evt.Content.ParseRaw(evt.Type) - if evt.RoomID == "" { - evt.RoomID = roomID - } - } - if err == nil && cli.StateStore != nil { - UpdateStateStore(ctx, cli.StateStore, evt) - } - return -} - -// parseRoomStateArray parses a JSON array as a stream and stores the events inside it in a room state map. -func parseRoomStateArray(req *http.Request, res *http.Response, responseJSON any, limit int64) ([]byte, error) { - if res.ContentLength > limit { - return nil, HTTPError{ - Request: req, - Response: res, - - Message: "not reading response", - WrappedError: fmt.Errorf("%w (%.2f MiB)", ErrResponseTooLong, float64(res.ContentLength)/1024/1024), - } - } - response := make(RoomStateMap) - responsePtr := responseJSON.(*map[event.Type]map[string]*event.Event) - *responsePtr = response - dec := json.NewDecoder(io.LimitReader(res.Body, limit)) - - arrayStart, err := dec.Token() - if err != nil { - return nil, err - } else if arrayStart != json.Delim('[') { - return nil, fmt.Errorf("expected array start, got %+v", arrayStart) - } - - for i := 1; dec.More(); i++ { - var evt *event.Event - err = dec.Decode(&evt) - if err != nil { - return nil, fmt.Errorf("failed to parse state array item #%d: %v", i, err) - } - evt.Type.Class = event.StateEventType - _ = evt.Content.ParseRaw(evt.Type) - subMap, ok := response[evt.Type] - if !ok { - subMap = make(map[string]*event.Event) - response[evt.Type] = subMap - } - subMap[*evt.StateKey] = evt - } - - arrayEnd, err := dec.Token() - if err != nil { - return nil, err - } else if arrayEnd != json.Delim(']') { - return nil, fmt.Errorf("expected array end, got %+v", arrayStart) - } - return nil, nil -} - -type RoomStateMap = map[event.Type]map[string]*event.Event - -// State gets all state in a room. -// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstate -func (cli *Client) State(ctx context.Context, roomID id.RoomID) (stateMap RoomStateMap, err error) { - _, err = cli.MakeFullRequest(ctx, FullRequest{ - Method: http.MethodGet, - URL: cli.BuildClientURL("v3", "rooms", roomID, "state"), - ResponseJSON: &stateMap, - Handler: parseRoomStateArray, - }) - if stateMap != nil { - pls, ok := stateMap[event.StatePowerLevels][""] - if ok { - pls.Content.AsPowerLevels().CreateEvent = stateMap[event.StateCreate][""] - } - } - if err == nil && cli.StateStore != nil { - for evtType, evts := range stateMap { - if evtType == event.StateMember { - continue - } - for _, evt := range evts { - if evt.RoomID == "" { - evt.RoomID = roomID - } - UpdateStateStore(ctx, cli.StateStore, evt) - } - } - updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, maps.Values(stateMap[event.StateMember])) - if updateErr != nil { - cli.cliOrContextLog(ctx).Warn().Err(updateErr). - Stringer("room_id", roomID). - Msg("Failed to update members in state store after fetching members") - } - } - return -} - -// StateAsArray gets all the state in a room as an array. It does not update the state store. -// Use State to get the events as a map and also update the state store. -func (cli *Client) StateAsArray(ctx context.Context, roomID id.RoomID) (state []*event.Event, err error) { - _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildClientURL("v3", "rooms", roomID, "state"), nil, &state) - if err == nil { - for _, evt := range state { - evt.Type.Class = event.StateEventType - } - } - return -} - -// GetMediaConfig fetches the configuration of the content repository, such as upload limitations. -func (cli *Client) GetMediaConfig(ctx context.Context) (resp *RespMediaConfig, err error) { - _, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildClientURL("v1", "media", "config"), nil, &resp) - return -} - -func (cli *Client) RequestOpenIDToken(ctx context.Context) (resp *RespOpenIDToken, err error) { - _, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildClientURL("v3", "user", cli.UserID, "openid", "request_token"), nil, &resp) - return -} - -// UploadLink uploads an HTTP URL and then returns an MXC URI. -func (cli *Client) UploadLink(ctx context.Context, link string) (*RespMediaUpload, error) { - if cli == nil { - return nil, ErrClientIsNil - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil) - if err != nil { - return nil, err - } - - res, err := cli.Client.Do(req) - if res != nil { - defer res.Body.Close() - } - if err != nil { - return nil, err - } - return cli.Upload(ctx, res.Body, res.Header.Get("Content-Type"), res.ContentLength) -} - -func (cli *Client) Download(ctx context.Context, mxcURL id.ContentURI) (*http.Response, error) { - if mxcURL.IsEmpty() { - return nil, fmt.Errorf("empty mxc uri provided to Download") - } - _, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{ - Method: http.MethodGet, - URL: cli.BuildClientURL("v1", "media", "download", mxcURL.Homeserver, mxcURL.FileID), - DontReadResponse: true, - }) - return resp, err -} - -type DownloadThumbnailExtra struct { - Method string - Animated bool -} - -func (cli *Client) DownloadThumbnail(ctx context.Context, mxcURL id.ContentURI, height, width int, extras ...DownloadThumbnailExtra) (*http.Response, error) { - if mxcURL.IsEmpty() { - return nil, fmt.Errorf("empty mxc uri provided to DownloadThumbnail") - } - if len(extras) > 1 { - panic(fmt.Errorf("invalid number of arguments to DownloadThumbnail: %d", len(extras))) - } - var extra DownloadThumbnailExtra - if len(extras) == 1 { - extra = extras[0] - } - path := ClientURLPath{"v1", "media", "thumbnail", mxcURL.Homeserver, mxcURL.FileID} - query := map[string]string{ - "height": strconv.Itoa(height), - "width": strconv.Itoa(width), - } - if extra.Method != "" { - query["method"] = extra.Method - } - if extra.Animated { - query["animated"] = "true" - } - _, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{ - Method: http.MethodGet, - URL: cli.BuildURLWithQuery(path, query), - DontReadResponse: true, - }) - return resp, err -} - -func (cli *Client) DownloadBytes(ctx context.Context, mxcURL id.ContentURI) ([]byte, error) { - resp, err := cli.Download(ctx, mxcURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - return io.ReadAll(resp.Body) -} - -type ReqCreateMXC struct { - BeeperUniqueID string - BeeperRoomID id.RoomID -} - -// CreateMXC creates a blank Matrix content URI to allow uploading the content asynchronously later. -// -// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create -func (cli *Client) CreateMXC(ctx context.Context, extra ...ReqCreateMXC) (*RespCreateMXC, error) { - var m RespCreateMXC - query := map[string]string{} - if len(extra) > 0 { - if extra[0].BeeperUniqueID != "" { - query["com.beeper.unique_id"] = extra[0].BeeperUniqueID - } - if extra[0].BeeperRoomID != "" { - query["com.beeper.room_id"] = string(extra[0].BeeperRoomID) - } - } - createURL := cli.BuildURLWithQuery(MediaURLPath{"v1", "create"}, query) - _, err := cli.MakeRequest(ctx, http.MethodPost, createURL, nil, &m) - return &m, err -} - -// UploadAsync creates a blank content URI with CreateMXC, starts uploading the data in the background -// and returns the created MXC immediately. -// -// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create -// and https://spec.matrix.org/v1.7/client-server-api/#put_matrixmediav3uploadservernamemediaid -func (cli *Client) UploadAsync(ctx context.Context, req ReqUploadMedia) (*RespCreateMXC, error) { - resp, err := cli.CreateMXC(ctx) - if err != nil { - req.DoneCallback() - return nil, err - } - req.MXC = resp.ContentURI - req.UnstableUploadURL = resp.UnstableUploadURL - if req.AsyncContext == nil { - req.AsyncContext = cli.cliOrContextLog(ctx).WithContext(context.Background()) - } - go func() { - _, err = cli.UploadMedia(req.AsyncContext, req) - if err != nil { - zerolog.Ctx(req.AsyncContext).Err(err). - Stringer("mxc", req.MXC). - Msg("Async upload of media failed") - } - }() - return resp, nil -} - -func (cli *Client) UploadBytes(ctx context.Context, data []byte, contentType string) (*RespMediaUpload, error) { - return cli.UploadBytesWithName(ctx, data, contentType, "") -} - -func (cli *Client) UploadBytesWithName(ctx context.Context, data []byte, contentType, fileName string) (*RespMediaUpload, error) { - return cli.UploadMedia(ctx, ReqUploadMedia{ - ContentBytes: data, - ContentType: contentType, - FileName: fileName, - }) -} - -// Upload uploads the given data to the content repository and returns an MXC URI. -// -// Deprecated: UploadMedia should be used instead. -func (cli *Client) Upload(ctx context.Context, content io.Reader, contentType string, contentLength int64) (*RespMediaUpload, error) { - return cli.UploadMedia(ctx, ReqUploadMedia{ - Content: content, - ContentLength: contentLength, - ContentType: contentType, - }) -} - -type ReqUploadMedia struct { - ContentBytes []byte - Content io.Reader - ContentLength int64 - ContentType string - FileName string - - AsyncContext context.Context - DoneCallback func() - - // MXC specifies an existing MXC URI which doesn't have content yet to upload into. - // See https://spec.matrix.org/unstable/client-server-api/#put_matrixmediav3uploadservernamemediaid - MXC id.ContentURI - - // UnstableUploadURL specifies the URL to upload the content to. MXC must also be set. - // see https://github.com/matrix-org/matrix-spec-proposals/pull/3870 for more info - UnstableUploadURL string -} - -func (cli *Client) tryUploadMediaToURL(ctx context.Context, url, contentType string, content io.Reader, contentLength int64) (*http.Response, error) { - cli.Log.Debug(). - Str("url", url). - Int64("content_length", contentLength). - Msg("Uploading media to external URL") - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, content) - if err != nil { - return nil, err - } - req.ContentLength = contentLength - req.Header.Set("Content-Type", contentType) - if cli.UserAgent != "" { - req.Header.Set("User-Agent", cli.UserAgent+" (external media uploader)") - } - - if cli.ExternalClient != nil { - return cli.ExternalClient.Do(req) - } else { - return http.DefaultClient.Do(req) - } -} - -func (cli *Client) uploadMediaToURL(ctx context.Context, data ReqUploadMedia) (*RespMediaUpload, error) { - retries := cli.DefaultHTTPRetries - reader := data.Content - if data.ContentBytes != nil { - data.ContentLength = int64(len(data.ContentBytes)) - reader = bytes.NewReader(data.ContentBytes) - } else if rsc, ok := reader.(io.ReadSeekCloser); ok { - // Prevent HTTP from closing the request body, it might be needed for retries - reader = nopCloseSeeker{rsc} - } - readerSeeker, canSeek := reader.(io.ReadSeeker) - if !canSeek { - retries = 0 - } - for { - resp, err := cli.tryUploadMediaToURL(ctx, data.UnstableUploadURL, data.ContentType, reader, data.ContentLength) - if err == nil { - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - // Everything is fine - break - } - err = fmt.Errorf("HTTP %d", resp.StatusCode) - } else if errors.Is(err, context.Canceled) { - cli.Log.Warn().Str("url", data.UnstableUploadURL).Msg("External media upload canceled") - return nil, err - } - if retries <= 0 { - cli.Log.Warn().Str("url", data.UnstableUploadURL).Err(err). - Msg("Error uploading media to external URL, not retrying") - return nil, err - } - // TODO change to exponential like normal retries? - backoff := time.Second * time.Duration(cli.DefaultHTTPRetries-retries) - cli.Log.Warn().Err(err). - Str("url", data.UnstableUploadURL). - Int("retry_in_seconds", int(backoff.Seconds())). - Msg("Error uploading media to external URL, retrying") - select { - case <-time.After(backoff): - case <-ctx.Done(): - return nil, ctx.Err() - } - retries-- - _, err = readerSeeker.Seek(0, io.SeekStart) - if err != nil { - return nil, fmt.Errorf("failed to seek back to start of reader: %w", err) - } - } - - query := map[string]string{} - if len(data.FileName) > 0 { - query["filename"] = data.FileName - } - - notifyURL := cli.BuildURLWithQuery(MediaURLPath{"unstable", "com.beeper.msc3870", "upload", data.MXC.Homeserver, data.MXC.FileID, "complete"}, query) - - var m *RespMediaUpload - _, err := cli.MakeRequest(ctx, http.MethodPost, notifyURL, nil, &m) - if err != nil { - return nil, err - } - - return m, nil -} - -type nopCloseSeeker struct { - io.ReadSeeker -} - -func (nopCloseSeeker) Close() error { - return nil -} - -// UploadMedia uploads the given data to the content repository and returns an MXC URI. -// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav3upload -func (cli *Client) UploadMedia(ctx context.Context, data ReqUploadMedia) (*RespMediaUpload, error) { - if data.DoneCallback != nil { - defer data.DoneCallback() - } - if cli == nil { - return nil, ErrClientIsNil - } - if data.UnstableUploadURL != "" { - if data.MXC.IsEmpty() { - return nil, errors.New("MXC must also be set when uploading to external URL") - } - return cli.uploadMediaToURL(ctx, data) - } - u, _ := url.Parse(cli.BuildURL(MediaURLPath{"v3", "upload"})) - method := http.MethodPost - if !data.MXC.IsEmpty() { - u, _ = url.Parse(cli.BuildURL(MediaURLPath{"v3", "upload", data.MXC.Homeserver, data.MXC.FileID})) - method = http.MethodPut - } - if len(data.FileName) > 0 { - q := u.Query() - q.Set("filename", data.FileName) - u.RawQuery = q.Encode() - } - - var headers http.Header - if len(data.ContentType) > 0 { - headers = http.Header{"Content-Type": []string{data.ContentType}} - } - - var m RespMediaUpload - _, err := cli.MakeFullRequest(ctx, FullRequest{ - Method: method, - URL: u.String(), - Headers: headers, - RequestBytes: data.ContentBytes, - RequestBody: data.Content, - RequestLength: data.ContentLength, - ResponseJSON: &m, - }) - return &m, err -} - -// GetURLPreview asks the homeserver to fetch a preview for a given URL. -// -// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixmediav3preview_url -func (cli *Client) GetURLPreview(ctx context.Context, url string) (*RespPreviewURL, error) { - reqURL := cli.BuildURLWithQuery(ClientURLPath{"v1", "media", "preview_url"}, map[string]string{ - "url": url, - }) - var output RespPreviewURL - _, err := cli.MakeRequest(ctx, http.MethodGet, reqURL, nil, &output) - return &output, err -} - -// JoinedMembers returns a map of joined room members. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidjoined_members -// -// In general, usage of this API is discouraged in favour of /sync, as calling this API can race with incoming membership changes. -// This API is primarily designed for application services which may want to efficiently look up joined members in a room. -func (cli *Client) JoinedMembers(ctx context.Context, roomID id.RoomID) (resp *RespJoinedMembers, err error) { - u := cli.BuildClientURL("v3", "rooms", roomID, "joined_members") - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) - if err == nil && cli.StateStore != nil { - fakeEvents := make([]*event.Event, len(resp.Joined)) - i := 0 - for userID, member := range resp.Joined { - fakeEvents[i] = &event.Event{ - StateKey: ptr.Ptr(userID.String()), - Type: event.StateMember, - RoomID: roomID, - Content: event.Content{Parsed: &event.MemberEventContent{ - Membership: event.MembershipJoin, - AvatarURL: id.ContentURIString(member.AvatarURL), - Displayname: member.DisplayName, - }}, - } - i++ - } - updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, fakeEvents, event.MembershipJoin) - if updateErr != nil { - cli.cliOrContextLog(ctx).Warn().Err(updateErr). - Stringer("room_id", roomID). - Msg("Failed to update members in state store after fetching joined members") - } - } - return -} - -func (cli *Client) Members(ctx context.Context, roomID id.RoomID, req ...ReqMembers) (resp *RespMembers, err error) { - var extra ReqMembers - if len(req) > 0 { - extra = req[0] - } - query := map[string]string{} - if len(extra.At) > 0 { - query["at"] = extra.At - } - if len(extra.Membership) > 0 { - query["membership"] = string(extra.Membership) - } - if len(extra.NotMembership) > 0 { - query["not_membership"] = string(extra.NotMembership) - } - u := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "members"}, query) - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) - if err == nil { - for _, evt := range resp.Chunk { - _ = evt.Content.ParseRaw(evt.Type) - } - } - if err == nil && cli.StateStore != nil { - var onlyMemberships []event.Membership - if extra.Membership != "" { - onlyMemberships = []event.Membership{extra.Membership} - } else if extra.NotMembership != "" { - onlyMemberships = []event.Membership{event.MembershipJoin, event.MembershipLeave, event.MembershipInvite, event.MembershipBan, event.MembershipKnock} - onlyMemberships = slices.DeleteFunc(onlyMemberships, func(m event.Membership) bool { - return m == extra.NotMembership - }) - } - updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, resp.Chunk, onlyMemberships...) - if updateErr != nil { - cli.cliOrContextLog(ctx).Warn().Err(updateErr). - Stringer("room_id", roomID). - Msg("Failed to update members in state store after fetching members") - } - } - return -} - -// JoinedRooms returns a list of rooms which the client is joined to. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3joined_rooms -// -// In general, usage of this API is discouraged in favour of /sync, as calling this API can race with incoming membership changes. -// This API is primarily designed for application services which may want to efficiently look up joined rooms. -func (cli *Client) JoinedRooms(ctx context.Context) (resp *RespJoinedRooms, err error) { - u := cli.BuildClientURL("v3", "joined_rooms") - _, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp) - return -} - -func (cli *Client) PublicRooms(ctx context.Context, req *ReqPublicRooms) (resp *RespPublicRooms, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "publicRooms"}, req.Query()) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// Hierarchy returns a list of rooms that are in the room's hierarchy. See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy -// -// The hierarchy API is provided to walk the space tree and discover the rooms with their aesthetic details. works in a depth-first manner: -// when it encounters another space as a child it recurses into that space before returning non-space children. -// -// The second function parameter specifies query parameters to limit the response. No query parameters will be added if it's nil. -func (cli *Client) Hierarchy(ctx context.Context, roomID id.RoomID, req *ReqHierarchy) (resp *RespHierarchy, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "rooms", roomID, "hierarchy"}, req.Query()) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// Messages returns a list of message and state events for a room. It uses -// pagination query parameters to paginate history in the room. -// See https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3roomsroomidmessages -func (cli *Client) Messages(ctx context.Context, roomID id.RoomID, from, to string, dir Direction, filter *FilterPart, limit int) (resp *RespMessages, err error) { - query := map[string]string{ - "dir": string(dir), - } - if filter != nil { - filterJSON, err := json.Marshal(filter) - if err != nil { - return nil, err - } - query["filter"] = string(filterJSON) - } - if from != "" { - query["from"] = from - } - if to != "" { - query["to"] = to - } - if limit != 0 { - query["limit"] = strconv.Itoa(limit) - } - - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "messages"}, query) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// TimestampToEvent finds the ID of the event closest to the given timestamp. -// -// See https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv1roomsroomidtimestamp_to_event -func (cli *Client) TimestampToEvent(ctx context.Context, roomID id.RoomID, timestamp time.Time, dir Direction) (resp *RespTimestampToEvent, err error) { - query := map[string]string{ - "ts": strconv.FormatInt(timestamp.UnixMilli(), 10), - "dir": string(dir), - } - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "rooms", roomID, "timestamp_to_event"}, query) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// Context returns a number of events that happened just before and after the -// specified event. It use pagination query parameters to paginate history in -// the room. -// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidcontexteventid -func (cli *Client) Context(ctx context.Context, roomID id.RoomID, eventID id.EventID, filter *FilterPart, limit int) (resp *RespContext, err error) { - query := map[string]string{} - if filter != nil { - filterJSON, err := json.Marshal(filter) - if err != nil { - return nil, err - } - query["filter"] = string(filterJSON) - } - if limit != 0 { - query["limit"] = strconv.Itoa(limit) - } - - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "context", eventID}, query) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) Search(ctx context.Context, req *ReqSearch) (*RespSearch, error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "search"}, req.Query()) - wrappedReq := &ReqSearchWrapper{SearchCategories: ReqSearchCategoryWrapper{RoomEvents: req}} - var wrappedResp RespSearchWrapper - _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, wrappedReq, &wrappedResp) - return wrappedResp.SearchCategories.RoomEvents, err -} - -func (cli *Client) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (resp *event.Event, err error) { - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "event", eventID) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) GetUnredactedEventContent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (resp *event.Event, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "event", eventID}, map[string]string{ - "fi.mau.msc2815.include_unredacted_content": "true", - }) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) GetRelations(ctx context.Context, roomID id.RoomID, eventID id.EventID, req *ReqGetRelations) (resp *RespGetRelations, err error) { - urlPath := cli.BuildURLWithQuery(append(ClientURLPath{"v1", "rooms", roomID, "relations", eventID}, req.PathSuffix()...), req.Query()) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID) (err error) { - return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, nil) -} - -// MarkReadWithContent sends a read receipt including custom data. -// -// Deprecated: Use SendReceipt instead. -func (cli *Client) MarkReadWithContent(ctx context.Context, roomID id.RoomID, eventID id.EventID, content interface{}) (err error) { - return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, content) -} - -// SendReceipt sends a receipt, usually specifically a read receipt. -// -// Passing nil as the content is safe, the library will automatically replace it with an empty JSON object. -// To mark a message in a specific thread as read, use pass a ReqSendReceipt as the content. -func (cli *Client) SendReceipt(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType, content interface{}) (err error) { - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "receipt", receiptType, eventID) - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, nil) - return -} - -func (cli *Client) SetReadMarkers(ctx context.Context, roomID id.RoomID, content interface{}) (err error) { - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "read_markers") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, nil) - return -} - -func (cli *Client) SetBeeperInboxState(ctx context.Context, roomID id.RoomID, content *ReqSetBeeperInboxState) (err error) { - urlPath := cli.BuildClientURL("unstable", "com.beeper.inbox", "user", cli.UserID, "rooms", roomID, "inbox_state") - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, content, nil) - return -} - -func (cli *Client) AddTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag, order float64) error { - return cli.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{ - Order: json.Number(strconv.FormatFloat(order, 'e', -1, 64)), - }) -} - -func (cli *Client) AddTagWithCustomData(ctx context.Context, roomID id.RoomID, tag event.RoomTag, data any) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) - return -} - -func (cli *Client) GetTags(ctx context.Context, roomID id.RoomID) (tags event.TagEventContent, err error) { - err = cli.GetTagsWithCustomData(ctx, roomID, &tags) - return -} - -func (cli *Client) GetTagsWithCustomData(ctx context.Context, roomID id.RoomID, resp any) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) RemoveTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag) (err error) { - urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag) - _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) - return -} - -// Deprecated: Synapse may not handle setting m.tag directly properly, so you should use the Add/RemoveTag methods instead. -func (cli *Client) SetTags(ctx context.Context, roomID id.RoomID, tags event.Tags) (err error) { - return cli.SetRoomAccountData(ctx, roomID, "m.tag", map[string]event.Tags{ - "tags": tags, - }) -} - -// TurnServer returns turn server details and credentials for the client to use when initiating calls. -// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3voipturnserver -func (cli *Client) TurnServer(ctx context.Context) (resp *RespTurnServer, err error) { - urlPath := cli.BuildClientURL("v3", "voip", "turnServer") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) CreateAlias(ctx context.Context, alias id.RoomAlias, roomID id.RoomID) (resp *RespAliasCreate, err error) { - urlPath := cli.BuildClientURL("v3", "directory", "room", alias) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqAliasCreate{RoomID: roomID}, &resp) - return -} - -func (cli *Client) ResolveAlias(ctx context.Context, alias id.RoomAlias) (resp *RespAliasResolve, err error) { - urlPath := cli.BuildClientURL("v3", "directory", "room", alias) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) DeleteAlias(ctx context.Context, alias id.RoomAlias) (resp *RespAliasDelete, err error) { - urlPath := cli.BuildClientURL("v3", "directory", "room", alias) - _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) - return -} - -func (cli *Client) GetAliases(ctx context.Context, roomID id.RoomID) (resp *RespAliasList, err error) { - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "aliases") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) UploadKeys(ctx context.Context, req *ReqUploadKeys) (resp *RespUploadKeys, err error) { - urlPath := cli.BuildClientURL("v3", "keys", "upload") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -func (cli *Client) QueryKeys(ctx context.Context, req *ReqQueryKeys) (resp *RespQueryKeys, err error) { - urlPath := cli.BuildClientURL("v3", "keys", "query") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -func (cli *Client) ClaimKeys(ctx context.Context, req *ReqClaimKeys) (resp *RespClaimKeys, err error) { - urlPath := cli.BuildClientURL("v3", "keys", "claim") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -func (cli *Client) GetKeyChanges(ctx context.Context, from, to string) (resp *RespKeyChanges, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "keys", "changes"}, map[string]string{ - "from": from, - "to": to, - }) - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp) - return -} - -// GetKeyBackup retrieves the keys from the backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeys -func (cli *Client) GetKeyBackup(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeys[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// PutKeysInBackup stores several keys in the backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeys -func (cli *Client) PutKeysInBackup(ctx context.Context, version id.KeyBackupVersion, req *ReqKeyBackup) (resp *RespRoomKeysUpdate, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) - return -} - -// DeleteKeyBackup deletes all keys from the backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeys -func (cli *Client) DeleteKeyBackup(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeysUpdate, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) - return -} - -// GetKeyBackupForRoom retrieves the keys from the backup for the given room. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeysroomid -func (cli *Client) GetKeyBackupForRoom( - ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, -) (resp *RespRoomKeyBackup[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// PutKeysInBackupForRoom stores several keys in the backup for the given room. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeysroomid -func (cli *Client) PutKeysInBackupForRoom(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, req *ReqRoomKeyBackup) (resp *RespRoomKeysUpdate, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) - return -} - -// DeleteKeysFromBackupForRoom deletes all the keys in the backup for the given -// room. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeysroomid -func (cli *Client) DeleteKeysFromBackupForRoom(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID) (resp *RespRoomKeysUpdate, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) - return -} - -// GetKeyBackupForRoomAndSession retrieves a key from the backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeysroomidsessionid -func (cli *Client) GetKeyBackupForRoomAndSession( - ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, -) (resp *RespKeyBackupData[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// PutKeysInBackupForRoomAndSession stores a key in the backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeysroomidsessionid -func (cli *Client) PutKeysInBackupForRoomAndSession(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, req *ReqKeyBackupData) (resp *RespRoomKeysUpdate, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) - return -} - -// DeleteKeysInBackupForRoomAndSession deletes a key from the backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeysroomidsessionid -func (cli *Client) DeleteKeysInBackupForRoomAndSession(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID) (resp *RespRoomKeysUpdate, err error) { - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{ - "version": string(version), - }) - _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp) - return -} - -// GetKeyBackupLatestVersion returns information about the latest backup version. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keysversion -func (cli *Client) GetKeyBackupLatestVersion(ctx context.Context) (resp *RespRoomKeysVersion[backup.MegolmAuthData], err error) { - urlPath := cli.BuildClientURL("v3", "room_keys", "version") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// CreateKeyBackupVersion creates a new key backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#post_matrixclientv3room_keysversion -func (cli *Client) CreateKeyBackupVersion(ctx context.Context, req *ReqRoomKeysVersionCreate[backup.MegolmAuthData]) (resp *RespRoomKeysVersionCreate, err error) { - urlPath := cli.BuildClientURL("v3", "room_keys", "version") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -// GetKeyBackupVersion returns information about an existing key backup. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keysversionversion -func (cli *Client) GetKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeysVersion[backup.MegolmAuthData], err error) { - urlPath := cli.BuildClientURL("v3", "room_keys", "version", version) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -// UpdateKeyBackupVersion updates information about an existing key backup. Only -// the auth_data can be modified. -// -// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keysversionversion -func (cli *Client) UpdateKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion, req *ReqRoomKeysVersionUpdate[backup.MegolmAuthData]) error { - urlPath := cli.BuildClientURL("v3", "room_keys", "version", version) - _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, nil, nil) - return err -} - -// DeleteKeyBackupVersion deletes an existing key backup. Both the information -// about the backup, as well as all key data related to the backup will be -// deleted. -// -// See: https://spec.matrix.org/v1.1/client-server-api/#delete_matrixclientv3room_keysversionversion -func (cli *Client) DeleteKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) error { - urlPath := cli.BuildClientURL("v3", "room_keys", "version", version) - _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) - return err -} - -func (cli *Client) SendToDevice(ctx context.Context, eventType event.Type, req *ReqSendToDevice) (resp *RespSendToDevice, err error) { - urlPath := cli.BuildClientURL("v3", "sendToDevice", eventType.String(), cli.TxnID()) - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp) - return -} - -func (cli *Client) GetDevicesInfo(ctx context.Context) (resp *RespDevicesInfo, err error) { - urlPath := cli.BuildClientURL("v3", "devices") - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) GetDeviceInfo(ctx context.Context, deviceID id.DeviceID) (resp *RespDeviceInfo, err error) { - urlPath := cli.BuildClientURL("v3", "devices", deviceID) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) SetDeviceInfo(ctx context.Context, deviceID id.DeviceID, req *ReqDeviceInfo) error { - urlPath := cli.BuildClientURL("v3", "devices", deviceID) - _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, req, nil) - return err -} - -func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice[any]) error { - urlPath := cli.BuildClientURL("v3", "devices", deviceID) - _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil) - return err -} - -func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices[any]) error { - urlPath := cli.BuildClientURL("v3", "delete_devices") - _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, req, nil) - return err -} - -type UIACallback = func(*RespUserInteractive) interface{} - -// UploadCrossSigningKeys uploads the given cross-signing keys to the server. -// Because the endpoint requires user-interactive authentication a callback must be provided that, -// given the UI auth parameters, produces the required result (or nil to end the flow). -func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq[any], uiaCallback UIACallback) error { - content, err := cli.MakeFullRequest(ctx, FullRequest{ - Method: http.MethodPost, - URL: cli.BuildClientURL("v3", "keys", "device_signing", "upload"), - RequestJSON: keys, - SensitiveContent: keys.Auth != nil, - }) - if respErr, ok := err.(HTTPError); ok && respErr.IsStatus(http.StatusUnauthorized) && uiaCallback != nil { - // try again with UI auth - var uiAuthResp RespUserInteractive - if err := json.Unmarshal(content, &uiAuthResp); err != nil { - return fmt.Errorf("failed to decode UIA response: %w", err) - } - auth := uiaCallback(&uiAuthResp) - if auth != nil { - keys.Auth = auth - return cli.UploadCrossSigningKeys(ctx, keys, nil) - } - } - return err -} - -func (cli *Client) UploadSignatures(ctx context.Context, req *ReqUploadSignatures) (resp *RespUploadSignatures, err error) { - urlPath := cli.BuildClientURL("v3", "keys", "signatures", "upload") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -// GetPushRules returns the push notification rules for the global scope. -func (cli *Client) GetPushRules(ctx context.Context) (*pushrules.PushRuleset, error) { - return cli.GetScopedPushRules(ctx, "global") -} - -// GetScopedPushRules returns the push notification rules for the given scope. -func (cli *Client) GetScopedPushRules(ctx context.Context, scope string) (resp *pushrules.PushRuleset, err error) { - u, _ := url.Parse(cli.BuildClientURL("v3", "pushrules", scope)) - // client.BuildURL returns the URL without a trailing slash, but the pushrules endpoint requires the slash. - u.Path += "/" - _, err = cli.MakeRequest(ctx, http.MethodGet, u.String(), nil, &resp) - return -} - -func (cli *Client) GetPushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string) (resp *pushrules.PushRule, err error) { - urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - if resp != nil { - resp.Type = kind - } - return -} - -func (cli *Client) DeletePushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string) error { - urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID) - _, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) - return err -} - -func (cli *Client) SetPushRuleEnabled(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, enabled bool) error { - urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID, "enabled") - _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, map[string]any{ - "enabled": enabled, - }, nil) - return err -} - -func (cli *Client) PutPushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, req *ReqPutPushRule) error { - query := make(map[string]string) - if len(req.After) > 0 { - query["after"] = req.After - } - if len(req.Before) > 0 { - query["before"] = req.Before - } - urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "pushrules", scope, kind, ruleID}, query) - _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, req, nil) - return err -} - -func (cli *Client) PutPushRuleActions(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, actions []*pushrules.PushAction) error { - urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID, "actions") - _, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqPutPushRule{ - Actions: actions, - }, nil) - return err -} - -func (cli *Client) ReportEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, reason string) error { - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "report", eventID) - _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqReport{Reason: reason, Score: -100}, nil) - return err -} - -func (cli *Client) ReportRoom(ctx context.Context, roomID id.RoomID, reason string) error { - urlPath := cli.BuildClientURL("v3", "rooms", roomID, "report") - _, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqReport{Reason: reason, Score: -100}, nil) - return err -} - -// AdminWhoIs fetches session information belonging to a specific user. Typically requires being a server admin. -// -// https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid -func (cli *Client) AdminWhoIs(ctx context.Context, userID id.UserID) (resp RespWhoIs, err error) { - urlPath := cli.BuildClientURL("v3", "admin", "whois", userID) - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return -} - -func (cli *Client) makeMSC4323URL(action string, target id.UserID) string { - if cli.SpecVersions.Supports(FeatureUnstableAccountModeration) { - return cli.BuildClientURL("unstable", "uk.timedout.msc4323", "admin", action, target) - } else if cli.SpecVersions.Supports(FeatureStableAccountModeration) { - return cli.BuildClientURL("v1", "admin", action, target) - } - return "" -} - -// GetSuspendedStatus uses MSC4323 to check if a user is suspended. -func (cli *Client) GetSuspendedStatus(ctx context.Context, userID id.UserID) (res *RespSuspended, err error) { - urlPath := cli.makeMSC4323URL("suspend", userID) - if urlPath == "" { - return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") - } - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) - return -} - -// GetLockStatus uses MSC4323 to check if a user is locked. -func (cli *Client) GetLockStatus(ctx context.Context, userID id.UserID) (res *RespLocked, err error) { - urlPath := cli.makeMSC4323URL("lock", userID) - if urlPath == "" { - return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") - } - _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, res) - return -} - -// SetSuspendedStatus uses MSC4323 to set whether a user account is suspended. -func (cli *Client) SetSuspendedStatus(ctx context.Context, userID id.UserID, suspended bool) (res *RespSuspended, err error) { - urlPath := cli.makeMSC4323URL("suspend", userID) - if urlPath == "" { - return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") - } - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqSuspend{Suspended: suspended}, res) - return -} - -// SetLockStatus uses MSC4323 to set whether a user account is locked. -func (cli *Client) SetLockStatus(ctx context.Context, userID id.UserID, locked bool) (res *RespLocked, err error) { - urlPath := cli.makeMSC4323URL("lock", userID) - if urlPath == "" { - return nil, MUnrecognized.WithMessage("Homeserver does not advertise MSC4323 support") - } - _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqLocked{Locked: locked}, res) - return -} - -func (cli *Client) AppservicePing(ctx context.Context, id, txnID string) (resp *RespAppservicePing, err error) { - _, err = cli.MakeFullRequest(ctx, FullRequest{ - Method: http.MethodPost, - URL: cli.BuildClientURL("v1", "appservice", id, "ping"), - RequestJSON: &ReqAppservicePing{TxnID: txnID}, - ResponseJSON: &resp, - // This endpoint intentionally returns 50x, so don't retry - MaxAttempts: 1, - }) - return -} - -func (cli *Client) BeeperBatchSend(ctx context.Context, roomID id.RoomID, req *ReqBeeperBatchSend) (resp *RespBeeperBatchSend, err error) { - u := cli.BuildClientURL("unstable", "com.beeper.backfill", "rooms", roomID, "batch_send") - _, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp) - return -} - -func (cli *Client) BeeperMergeRooms(ctx context.Context, req *ReqBeeperMergeRoom) (resp *RespBeeperMergeRoom, err error) { - urlPath := cli.BuildClientURL("unstable", "com.beeper.chatmerging", "merge") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} - -func (cli *Client) BeeperSplitRoom(ctx context.Context, req *ReqBeeperSplitRoom) (resp *RespBeeperSplitRoom, err error) { - urlPath := cli.BuildClientURL("unstable", "com.beeper.chatmerging", "rooms", req.RoomID, "split") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp) - return -} -func (cli *Client) BeeperDeleteRoom(ctx context.Context, roomID id.RoomID) (err error) { - urlPath := cli.BuildClientURL("unstable", "com.beeper.yeet", "rooms", roomID, "delete") - _, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, nil) - return -} - -// TxnID returns the next transaction ID. -func (cli *Client) TxnID() string { - if cli == nil { - return "client is nil" - } - txnID := atomic.AddInt32(&cli.txnID, 1) - return fmt.Sprintf("mautrix-go_%d_%d", time.Now().UnixNano(), txnID) -} - -// NewClient creates a new Matrix Client ready for syncing -func NewClient(homeserverURL string, userID id.UserID, accessToken string) (*Client, error) { - hsURL, err := ParseAndNormalizeBaseURL(homeserverURL) - if err != nil { - return nil, err - } - return &Client{ - AccessToken: accessToken, - UserAgent: DefaultUserAgent, - HomeserverURL: hsURL, - UserID: userID, - Client: &http.Client{Timeout: 180 * time.Second}, - Syncer: NewDefaultSyncer(), - Log: zerolog.Nop(), - // By default, use an in-memory store which will never save filter ids / next batch tokens to disk. - // The client will work with this storer: it just won't remember across restarts. - // In practice, a database backend should be used. - Store: NewMemorySyncStore(), - }, nil -} diff --git a/mautrix-patched/client_retry_test.go b/mautrix-patched/client_retry_test.go deleted file mode 100644 index b753e43b..00000000 --- a/mautrix-patched/client_retry_test.go +++ /dev/null @@ -1,491 +0,0 @@ -package mautrix - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "net/url" - "sync/atomic" - "testing" - "time" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" - "go.mau.fi/util/exsync" -) - -func newTestClient(t *testing.T, serverURL string) *Client { - t.Helper() - parsedURL, err := url.Parse(serverURL) - require.NoError(t, err) - return &Client{ - HomeserverURL: parsedURL, - Client: http.DefaultClient, - Log: zerolog.New(io.Discard), - DefaultHTTPRetries: 1, - DefaultHTTPBackoff: 200 * time.Millisecond, - RequestRetryTrigger: exsync.NewEvent(), - } -} - -func TestRequestRetryTriggerRetriesActiveAttempt(t *testing.T) { - requestStarted := make(chan struct{}) - var attempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch attempts.Add(1) { - case 1: - close(requestStarted) - <-r.Context().Done() - case 2: - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - default: - t.Fatalf("unexpected extra request attempt %d", attempts.Load()) - } - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - var response struct { - OK bool `json:"ok"` - } - errCh := make(chan error, 1) - go func() { - _, err := client.MakeRequest(context.Background(), http.MethodGet, server.URL, nil, &response) - errCh <- err - }() - - select { - case <-requestStarted: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for initial request attempt") - } - - resetAt := time.Now() - client.RequestRetryTrigger.Notify() - - select { - case err := <-errCh: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for retried request to finish") - } - - require.True(t, response.OK) - require.EqualValues(t, 2, attempts.Load()) - require.Less(t, time.Since(resetAt), 150*time.Millisecond) -} - -func TestRequestRetryTriggerUsesNormalRetryBudget(t *testing.T) { - requestStarted := make(chan struct{}) - var attempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch attempts.Add(1) { - case 1: - close(requestStarted) - <-r.Context().Done() - default: - t.Fatalf("unexpected extra request attempt %d", attempts.Load()) - } - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - client.DefaultHTTPRetries = 0 - - errCh := make(chan error, 1) - go func() { - _, err := client.MakeRequest(context.Background(), http.MethodGet, server.URL, nil, nil) - errCh <- err - }() - - select { - case <-requestStarted: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for request start") - } - - client.RequestRetryTrigger.Notify() - - select { - case err := <-errCh: - require.Error(t, err) - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for canceled request to finish") - } - - require.EqualValues(t, 1, attempts.Load()) -} - -func TestCallerCancellationDoesNotRetry(t *testing.T) { - requestStarted := make(chan struct{}) - var attempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - close(requestStarted) - <-r.Context().Done() - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - ctx, cancel := context.WithCancel(context.Background()) - errCh := make(chan error, 1) - go func() { - _, err := client.MakeRequest(ctx, http.MethodGet, server.URL, nil, nil) - errCh <- err - }() - - select { - case <-requestStarted: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for request start") - } - - cancel() - - select { - case err := <-errCh: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for canceled request to finish") - } - - require.EqualValues(t, 1, attempts.Load()) -} - -func TestRequestRetryTriggerDoesNotInterruptBackoff(t *testing.T) { - firstAttemptDone := make(chan time.Time, 1) - secondAttemptStarted := make(chan time.Time, 1) - var attempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch attempts.Add(1) { - case 1: - w.WriteHeader(http.StatusBadGateway) - firstAttemptDone <- time.Now() - case 2: - secondAttemptStarted <- time.Now() - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{}`)) - default: - t.Fatalf("unexpected extra request attempt %d", attempts.Load()) - } - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - client.DefaultHTTPBackoff = 250 * time.Millisecond - errCh := make(chan error, 1) - go func() { - _, err := client.MakeRequest(context.Background(), http.MethodGet, server.URL, nil, nil) - errCh <- err - }() - - var firstAt time.Time - select { - case firstAt = <-firstAttemptDone: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for first attempt to fail") - } - - time.Sleep(50 * time.Millisecond) - client.RequestRetryTrigger.Notify() - - var secondAt time.Time - select { - case secondAt = <-secondAttemptStarted: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for retried request") - } - - select { - case err := <-errCh: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for request completion") - } - - require.GreaterOrEqual(t, secondAt.Sub(firstAt), 200*time.Millisecond) - require.EqualValues(t, 2, attempts.Load()) -} - -func TestRequestRetryTriggerCancelsStreamingBody(t *testing.T) { - streamStarted := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("hello")) - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - close(streamStarted) - <-r.Context().Done() - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ - Method: http.MethodGet, - URL: server.URL, - DontReadResponse: true, - }) - require.NoError(t, err) - require.NotNil(t, resp) - defer resp.Body.Close() - - select { - case <-streamStarted: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for stream start") - } - - buf := make([]byte, 5) - n, err := io.ReadFull(resp.Body, buf) - require.NoError(t, err) - require.Equal(t, 5, n) - require.Equal(t, "hello", string(buf)) - - client.RequestRetryTrigger.Notify() - - _, err = resp.Body.Read(make([]byte, 1)) - require.Error(t, err) -} - -func TestDontReadResponseCleanupRunsOnBodyClose(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("hello")) - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - attemptCtxCh := make(chan context.Context, 1) - client.RequestHook = func(req *http.Request) { - select { - case attemptCtxCh <- req.Context(): - default: - } - } - - _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ - Method: http.MethodGet, - URL: server.URL, - DontReadResponse: true, - }) - require.NoError(t, err) - require.NotNil(t, resp) - - var attemptCtx context.Context - select { - case attemptCtx = <-attemptCtxCh: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for attempt context") - } - - select { - case <-attemptCtx.Done(): - t.Fatal("attempt context canceled before body close") - case <-time.After(100 * time.Millisecond): - } - - require.NoError(t, resp.Body.Close()) - - select { - case <-attemptCtx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for attempt context cleanup after body close") - } - require.ErrorIs(t, context.Cause(attemptCtx), context.Canceled) -} - -func TestRedirectErrorCleansUpAttemptContext(t *testing.T) { - var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/final" { - w.WriteHeader(http.StatusOK) - return - } - http.Redirect(w, r, server.URL+"/final", http.StatusFound) - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - httpClient := server.Client() - httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return errors.New("stop redirect") - } - client.Client = httpClient - - attemptCtxCh := make(chan context.Context, 1) - client.RequestHook = func(req *http.Request) { - select { - case attemptCtxCh <- req.Context(): - default: - } - } - - _, _, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ - Method: http.MethodGet, - URL: server.URL, - }) - require.Error(t, err) - - var attemptCtx context.Context - select { - case attemptCtx = <-attemptCtxCh: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for attempt context") - } - - select { - case <-attemptCtx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for attempt context cleanup after redirect error") - } - require.ErrorIs(t, context.Cause(attemptCtx), context.Canceled) -} - -type readSeekCloser struct { - *bytes.Reader -} - -func (r readSeekCloser) Close() error { - return nil -} - -type testRoundTripper func(*http.Request) (*http.Response, error) - -func (trt testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return trt(req) -} - -type writerToReadCloser struct { - *bytes.Reader -} - -func (wrc *writerToReadCloser) Close() error { - return nil -} - -func (wrc *writerToReadCloser) WriteTo(w io.Writer) (int64, error) { - return io.Copy(w, wrc.Reader) -} - -func TestRequestRetryTriggerReplaysRequestBody(t *testing.T) { - requestStarted := make(chan struct{}) - bodyBytes := []byte("hello retry body") - var attempts atomic.Int32 - receivedBodies := make(chan []byte, 2) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - receivedBodies <- body - - switch attempts.Add(1) { - case 1: - close(requestStarted) - <-r.Context().Done() - case 2: - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - default: - t.Fatalf("unexpected extra request attempt %d", attempts.Load()) - } - })) - t.Cleanup(server.Close) - - client := newTestClient(t, server.URL) - var response struct { - OK bool `json:"ok"` - } - errCh := make(chan error, 1) - go func() { - _, err := client.MakeFullRequest(context.Background(), FullRequest{ - Method: http.MethodPost, - URL: server.URL, - RequestBody: readSeekCloser{bytes.NewReader(bodyBytes)}, - RequestLength: int64(len(bodyBytes)), - ResponseJSON: &response, - }) - errCh <- err - }() - - select { - case <-requestStarted: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for initial request attempt") - } - - client.RequestRetryTrigger.Notify() - - select { - case err := <-errCh: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for retried request to finish") - } - - require.True(t, response.OK) - require.EqualValues(t, 2, attempts.Load()) - require.Equal(t, bodyBytes, <-receivedBodies) - require.Equal(t, bodyBytes, <-receivedBodies) -} - -func TestDontReadResponseCleanupWrapperPreservesWriterTo(t *testing.T) { - body := &writerToReadCloser{Reader: bytes.NewReader([]byte("hello writer-to"))} - client := newTestClient(t, "https://example.com") - client.Client = &http.Client{ - Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, - Body: body, - }, nil - }), - } - - _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ - Method: http.MethodGet, - URL: "https://example.com", - DontReadResponse: true, - }) - require.NoError(t, err) - require.NotNil(t, resp) - - writerTo, ok := resp.Body.(io.WriterTo) - require.True(t, ok) - - var copied bytes.Buffer - _, err = writerTo.WriteTo(&copied) - require.NoError(t, err) - require.Equal(t, "hello writer-to", copied.String()) - require.NoError(t, resp.Body.Close()) -} - -func TestDontReadResponseWithoutRetryTriggerDoesNotWrapBody(t *testing.T) { - body := &writerToReadCloser{Reader: bytes.NewReader([]byte("hello raw body"))} - client := newTestClient(t, "https://example.com") - client.RequestRetryTrigger = nil - client.Client = &http.Client{ - Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, - Body: body, - }, nil - }), - } - - _, resp, err := client.MakeFullRequestWithResp(context.Background(), FullRequest{ - Method: http.MethodGet, - URL: "https://example.com", - DontReadResponse: true, - }) - require.NoError(t, err) - require.NotNil(t, resp) - require.Same(t, body, resp.Body) - require.NoError(t, resp.Body.Close()) -} diff --git a/mautrix-patched/commands/container.go b/mautrix-patched/commands/container.go deleted file mode 100644 index 9b909b75..00000000 --- a/mautrix-patched/commands/container.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "fmt" - "slices" - "strings" - "sync" - - "go.mau.fi/util/exmaps" - - "maunium.net/go/mautrix/event/cmdschema" -) - -type CommandContainer[MetaType any] struct { - commands map[string]*Handler[MetaType] - aliases map[string]string - lock sync.RWMutex - parent *Handler[MetaType] -} - -func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] { - return &CommandContainer[MetaType]{ - commands: make(map[string]*Handler[MetaType]), - aliases: make(map[string]string), - } -} - -func (cont *CommandContainer[MetaType]) AllSpecs() []*cmdschema.EventContent { - data := make(exmaps.Set[*Handler[MetaType]]) - cont.collectHandlers(data) - specs := make([]*cmdschema.EventContent, 0, data.Size()) - for handler := range data.Iter() { - if handler.Parameters != nil { - specs = append(specs, handler.Spec()) - } - } - return specs -} - -func (cont *CommandContainer[MetaType]) collectHandlers(into exmaps.Set[*Handler[MetaType]]) { - cont.lock.RLock() - defer cont.lock.RUnlock() - for _, handler := range cont.commands { - into.Add(handler) - if handler.subcommandContainer != nil { - handler.subcommandContainer.collectHandlers(into) - } - } -} - -// Register registers the given command handlers. -func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) { - if cont == nil { - return - } - cont.lock.Lock() - defer cont.lock.Unlock() - for i, handler := range handlers { - if handler == nil { - panic(fmt.Errorf("handler #%d is nil", i+1)) - } - cont.registerOne(handler) - } -} - -func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType]) { - if strings.ToLower(handler.Name) != handler.Name { - panic(fmt.Errorf("command %q is not lowercase", handler.Name)) - } else if val, alreadyExists := cont.commands[handler.Name]; alreadyExists && val != handler { - panic(fmt.Errorf("tried to register command %q, but it's already registered", handler.Name)) - } else if aliasTarget, alreadyExists := cont.aliases[handler.Name]; alreadyExists { - panic(fmt.Errorf("tried to register command %q, but it's already registered as an alias for %q", handler.Name, aliasTarget)) - } - if !slices.Contains(handler.parents, cont.parent) { - handler.parents = append(handler.parents, cont.parent) - handler.nestedNameCache = nil - } - cont.commands[handler.Name] = handler - for _, alias := range handler.Aliases { - if strings.ToLower(alias) != alias { - panic(fmt.Errorf("alias %q is not lowercase", alias)) - } else if val, alreadyExists := cont.aliases[alias]; alreadyExists && val != handler.Name { - panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered for %q", alias, handler.Name, cont.aliases[alias])) - } else if _, alreadyExists = cont.commands[alias]; alreadyExists { - panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered as a command", alias, handler.Name)) - } - cont.aliases[alias] = handler.Name - } - handler.initSubcommandContainer() -} - -func (cont *CommandContainer[MetaType]) Unregister(handlers ...*Handler[MetaType]) { - if cont == nil { - return - } - cont.lock.Lock() - defer cont.lock.Unlock() - for _, handler := range handlers { - cont.unregisterOne(handler) - } -} - -func (cont *CommandContainer[MetaType]) unregisterOne(handler *Handler[MetaType]) { - delete(cont.commands, handler.Name) - for _, alias := range handler.Aliases { - if cont.aliases[alias] == handler.Name { - delete(cont.aliases, alias) - } - } -} - -func (cont *CommandContainer[MetaType]) GetHandler(name string) *Handler[MetaType] { - if cont == nil { - return nil - } - cont.lock.RLock() - defer cont.lock.RUnlock() - alias, ok := cont.aliases[name] - if ok { - name = alias - } - handler, ok := cont.commands[name] - if !ok { - handler = cont.commands[UnknownCommandName] - } - return handler -} diff --git a/mautrix-patched/commands/event.go b/mautrix-patched/commands/event.go deleted file mode 100644 index 76d6c9f0..00000000 --- a/mautrix-patched/commands/event.go +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" -) - -// Event contains the data of a single command event. -// It also provides some helper methods for responding to the command. -type Event[MetaType any] struct { - *event.Event - // RawInput is the entire message before splitting into command and arguments. - RawInput string - // ParentCommands is the chain of commands leading up to this command. - // This is only set if the command is a subcommand. - ParentCommands []string - ParentHandlers []*Handler[MetaType] - // Command is the lowercased first word of the message. - Command string - // Args are the rest of the message split by whitespace ([strings.Fields]). - Args []string - // RawArgs is the same as args, but without the splitting by whitespace. - RawArgs string - - StructuredArgs json.RawMessage - - Ctx context.Context - Log *zerolog.Logger - Proc *Processor[MetaType] - Handler *Handler[MetaType] - Meta MetaType - - redactedBy id.EventID -} - -var IDHTMLParser = &format.HTMLParser{ - PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { - if len(mxid) == 0 { - return displayname - } - if eventID != "" { - return fmt.Sprintf("https://matrix.to/#/%s/%s", mxid, eventID) - } - return mxid - }, - ItalicConverter: func(s string, c format.Context) string { - return fmt.Sprintf("*%s*", s) - }, - Newline: "\n", -} - -// ParseEvent parses a message into a command event struct. -func (proc *Processor[MetaType]) ParseEvent(ctx context.Context, evt *event.Event) *Event[MetaType] { - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok || content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" { - return nil - } - text := content.Body - if content.Format == event.FormatHTML { - text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) - } - if content.MSC4391BotCommand != nil { - if !content.Mentions.Has(proc.Client.UserID) || len(content.Mentions.UserIDs) != 1 { - return nil - } - wrapped := StructuredCommandToEvent[MetaType](ctx, evt, content.MSC4391BotCommand) - wrapped.RawInput = text - return wrapped - } - if len(text) == 0 { - return nil - } - return RawTextToEvent[MetaType](ctx, evt, text) -} - -func StructuredCommandToEvent[MetaType any](ctx context.Context, evt *event.Event, content *event.MSC4391BotCommandInput) *Event[MetaType] { - commandParts := strings.Split(content.Command, " ") - return &Event[MetaType]{ - Event: evt, - // Fake a command and args to let the subcommand finder in Process work. - Command: commandParts[0], - Args: commandParts[1:], - Ctx: ctx, - Log: zerolog.Ctx(ctx), - - StructuredArgs: content.Arguments, - } -} - -func RawTextToEvent[MetaType any](ctx context.Context, evt *event.Event, text string) *Event[MetaType] { - parts := strings.Fields(text) - if len(parts) == 0 { - parts = []string{""} - } - return &Event[MetaType]{ - Event: evt, - RawInput: text, - Command: strings.ToLower(parts[0]), - Args: parts[1:], - RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "), - Log: zerolog.Ctx(ctx), - Ctx: ctx, - } -} - -type ReplyOpts struct { - AllowHTML bool - AllowMarkdown bool - Reply bool - Thread bool - SendAsText bool - Edit id.EventID - OverrideMentions *event.Mentions - Extra map[string]any -} - -func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID { - if len(args) > 0 { - msg = fmt.Sprintf(msg, args...) - } - return evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true}) -} - -func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID { - content := format.RenderMarkdown(msg, opts.AllowMarkdown, opts.AllowHTML) - if opts.Thread { - content.SetThread(evt.Event) - } - if opts.Reply { - content.SetReply(evt.Event) - } - if !opts.SendAsText { - content.MsgType = event.MsgNotice - } - if opts.Edit != "" { - content.SetEdit(opts.Edit) - } - if opts.OverrideMentions != nil { - content.Mentions = opts.OverrideMentions - } - var wrapped any = &content - if opts.Extra != nil { - wrapped = &event.Content{ - Parsed: &content, - Raw: opts.Extra, - } - } - resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, wrapped) - if err != nil { - zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply") - return "" - } - return resp.EventID -} - -func (evt *Event[MetaType]) React(emoji string) id.EventID { - resp, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji) - if err != nil { - zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reaction") - return "" - } - return resp.EventID -} - -func (evt *Event[MetaType]) Redact() id.EventID { - if evt.redactedBy != "" { - return evt.redactedBy - } - resp, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID) - if err != nil { - zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command") - return "" - } - evt.redactedBy = resp.EventID - return resp.EventID -} - -func (evt *Event[MetaType]) MarkRead() { - err := evt.Proc.Client.MarkRead(evt.Ctx, evt.RoomID, evt.ID) - if err != nil { - zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send read receipt") - } -} - -// ShiftArg removes the first argument from the Args list and RawArgs data and returns it. -// RawInput will not be modified. -func (evt *Event[MetaType]) ShiftArg() string { - if len(evt.Args) == 0 { - return "" - } - firstArg := evt.Args[0] - evt.RawArgs = strings.TrimLeft(strings.TrimPrefix(evt.RawArgs, evt.Args[0]), " ") - evt.Args = evt.Args[1:] - return firstArg -} - -// UnshiftArg reverses ShiftArg by adding the given value to the beginning of the Args list and RawArgs data. -func (evt *Event[MetaType]) UnshiftArg(arg string) { - evt.RawArgs = arg + " " + evt.RawArgs - evt.Args = append([]string{arg}, evt.Args...) -} - -func (evt *Event[MetaType]) ParseArgs(into any) error { - return json.Unmarshal(evt.StructuredArgs, into) -} - -func ParseArgs[T, MetaType any](evt *Event[MetaType]) (into T, err error) { - err = evt.ParseArgs(&into) - return -} - -func WithParsedArgs[T, MetaType any](fn func(*Event[MetaType], T)) func(*Event[MetaType]) { - return func(evt *Event[MetaType]) { - parsed, err := ParseArgs[T, MetaType](evt) - if err != nil { - evt.Log.Debug().Err(err).Msg("Failed to parse structured args into struct") - // TODO better error, usage info? deduplicate with Process - evt.Reply("Failed to parse arguments: %v", err) - return - } - fn(evt, parsed) - } -} diff --git a/mautrix-patched/commands/handler.go b/mautrix-patched/commands/handler.go deleted file mode 100644 index 56f27f06..00000000 --- a/mautrix-patched/commands/handler.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "strings" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/event/cmdschema" -) - -type Handler[MetaType any] struct { - // Func is the function that is called when the command is executed. - Func func(ce *Event[MetaType]) - - // Name is the primary name of the command. It must be lowercase. - Name string - // Aliases are alternative names for the command. They must be lowercase. - Aliases []string - // Subcommands are subcommands of this command. - Subcommands []*Handler[MetaType] - // PreFunc is a function that is called before checking subcommands. - // It can be used to have parameters between subcommands (e.g. `!rooms `). - // Event.ShiftArg will likely be useful for implementing such parameters. - PreFunc func(ce *Event[MetaType]) - - // Description is a short description of the command. - Description *event.ExtensibleTextContainer - // Parameters is a description of structured command parameters. - // If set, the StructuredArgs field of Event will be populated. - Parameters []*cmdschema.Parameter - TailParam string - - parents []*Handler[MetaType] - nestedNameCache []string - subcommandContainer *CommandContainer[MetaType] -} - -func (h *Handler[MetaType]) NestedNames() []string { - if h.nestedNameCache != nil { - return h.nestedNameCache - } - nestedNames := make([]string, 0, (1+len(h.Aliases))*len(h.parents)) - for _, parent := range h.parents { - if parent == nil { - nestedNames = append(nestedNames, h.Name) - nestedNames = append(nestedNames, h.Aliases...) - } else { - for _, parentName := range parent.NestedNames() { - nestedNames = append(nestedNames, parentName+" "+h.Name) - for _, alias := range h.Aliases { - nestedNames = append(nestedNames, parentName+" "+alias) - } - } - } - } - h.nestedNameCache = nestedNames - return nestedNames -} - -func (h *Handler[MetaType]) Spec() *cmdschema.EventContent { - names := h.NestedNames() - return &cmdschema.EventContent{ - Command: names[0], - Aliases: names[1:], - Parameters: h.Parameters, - Description: h.Description, - TailParam: h.TailParam, - } -} - -func (h *Handler[MetaType]) CopyFrom(other *Handler[MetaType]) { - if h.Parameters == nil { - h.Parameters = other.Parameters - h.TailParam = other.TailParam - } - h.Func = other.Func -} - -func (h *Handler[MetaType]) initSubcommandContainer() { - if len(h.Subcommands) > 0 { - h.subcommandContainer = NewCommandContainer[MetaType]() - h.subcommandContainer.parent = h - h.subcommandContainer.Register(h.Subcommands...) - } else { - h.subcommandContainer = nil - } -} - -func MakeUnknownCommandHandler[MetaType any](prefix string) *Handler[MetaType] { - return &Handler[MetaType]{ - Name: UnknownCommandName, - Func: func(ce *Event[MetaType]) { - if len(ce.ParentCommands) == 0 { - ce.Reply("Unknown command `%s%s`", prefix, ce.Command) - } else { - ce.Reply("Unknown subcommand `%s%s %s`", prefix, strings.Join(ce.ParentCommands, " "), ce.Command) - } - }, - } -} diff --git a/mautrix-patched/commands/prevalidate.go b/mautrix-patched/commands/prevalidate.go deleted file mode 100644 index facca4da..00000000 --- a/mautrix-patched/commands/prevalidate.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "strings" -) - -// A PreValidator contains a function that takes an Event and returns true if the event should be processed further. -// -// The [PreValidator] field in [Processor] is called before the handler of the command is checked. -// It can be used to modify the command or arguments, or to skip the command entirely. -// -// The primary use case is removing a static command prefix, such as requiring all commands start with `!`. -type PreValidator[MetaType any] interface { - Validate(*Event[MetaType]) bool -} - -// FuncPreValidator is a simple function that implements the PreValidator interface. -type FuncPreValidator[MetaType any] func(*Event[MetaType]) bool - -func (f FuncPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { - return f(ce) -} - -// AllPreValidator can be used to combine multiple PreValidators, such that -// all of them must return true for the command to be processed further. -type AllPreValidator[MetaType any] []PreValidator[MetaType] - -func (f AllPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { - for _, validator := range f { - if !validator.Validate(ce) { - return false - } - } - return true -} - -// AnyPreValidator can be used to combine multiple PreValidators, such that -// at least one of them must return true for the command to be processed further. -type AnyPreValidator[MetaType any] []PreValidator[MetaType] - -func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool { - for _, validator := range f { - if validator.Validate(ce) { - return true - } - } - return false -} - -// ValidatePrefixCommand checks that the first word in the input is exactly the given string, -// and if so, removes it from the command and sets the command to the next word. -// -// For example, `ValidateCommandPrefix("!mybot")` would only allow commands in the form `!mybot foo`, -// where `foo` would be used to look up the command handler. -func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] { - return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { - if ce.Command == prefix && len(ce.Args) > 0 { - ce.Command = strings.ToLower(ce.ShiftArg()) - return true - } - return false - }) -} - -// ValidatePrefixSubstring checks that the command starts with the given prefix, -// and if so, removes it from the command. -// -// For example, `ValidatePrefixSubstring("!")` would only allow commands in the form `!foo`, -// where `foo` would be used to look up the command handler. -func ValidatePrefixSubstring[MetaType any](prefix string) PreValidator[MetaType] { - return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool { - if strings.HasPrefix(ce.Command, prefix) { - ce.Command = ce.Command[len(prefix):] - return true - } - return false - }) -} diff --git a/mautrix-patched/commands/processor.go b/mautrix-patched/commands/processor.go deleted file mode 100644 index 80f6745d..00000000 --- a/mautrix-patched/commands/processor.go +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "runtime/debug" - "strings" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" -) - -// Processor implements boilerplate code for splitting messages into a command and arguments, -// and finding the appropriate handler for the command. -type Processor[MetaType any] struct { - *CommandContainer[MetaType] - - Client *mautrix.Client - LogArgs bool - PreValidator PreValidator[MetaType] - Meta MetaType - - ReactionCommandPrefix string -} - -// UnknownCommandName is the name of the fallback handler which is used if no other handler is found. -// If even the unknown command handler is not found, the command is ignored. -const UnknownCommandName = "__unknown-command__" - -func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] { - proc := &Processor[MetaType]{ - CommandContainer: NewCommandContainer[MetaType](), - Client: cli, - PreValidator: ValidatePrefixSubstring[MetaType]("!"), - } - proc.Register(MakeUnknownCommandHandler[MetaType]("!")) - return proc -} - -func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) { - log := zerolog.Ctx(ctx).With(). - Stringer("sender", evt.Sender). - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.ID). - Logger() - defer func() { - panicErr := recover() - if panicErr != nil { - logEvt := log.Error(). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()) - if realErr, ok := panicErr.(error); ok { - logEvt = logEvt.Err(realErr) - } else { - logEvt = logEvt.Any(zerolog.ErrorFieldName, panicErr) - } - logEvt.Msg("Panic in command handler") - _, err := proc.Client.SendReaction(ctx, evt.RoomID, evt.ID, "💥") - if err != nil { - log.Err(err).Msg("Failed to send reaction after panic") - } - } - }() - var parsed *Event[MetaType] - switch evt.Type { - case event.EventReaction: - parsed = proc.ParseReaction(ctx, evt) - case event.EventMessage: - parsed = proc.ParseEvent(ctx, evt) - } - if parsed == nil || (!proc.PreValidator.Validate(parsed) && parsed.StructuredArgs == nil) { - return - } - parsed.Proc = proc - parsed.Meta = proc.Meta - parsed.Ctx = ctx - - handler := proc.GetHandler(parsed.Command) - if handler == nil { - return - } - parsed.Handler = handler - if handler.PreFunc != nil { - handler.PreFunc(parsed) - } - handlerChain := zerolog.Arr() - handlerChain.Str(handler.Name) - for handler.subcommandContainer != nil && len(parsed.Args) > 0 { - subHandler := handler.subcommandContainer.GetHandler(strings.ToLower(parsed.Args[0])) - if subHandler != nil { - parsed.ParentCommands = append(parsed.ParentCommands, parsed.Command) - parsed.ParentHandlers = append(parsed.ParentHandlers, handler) - handler = subHandler - handlerChain.Str(subHandler.Name) - parsed.Command = strings.ToLower(parsed.ShiftArg()) - parsed.Handler = subHandler - if subHandler.PreFunc != nil { - subHandler.PreFunc(parsed) - } - } else { - break - } - } - if parsed.StructuredArgs != nil && len(parsed.Args) > 0 { - // TODO allow unknown command handlers to be called? - // The client sent MSC4391 data, but the target command wasn't found - log.Debug().Msg("Didn't find handler for MSC4391 command") - return - } - - logWith := log.With(). - Str("command", parsed.Command). - Array("handler", handlerChain) - if len(parsed.ParentCommands) > 0 { - logWith = logWith.Strs("parent_commands", parsed.ParentCommands) - } - if proc.LogArgs { - logWith = logWith.Strs("args", parsed.Args) - if parsed.StructuredArgs != nil { - logWith = logWith.RawJSON("structured_args", parsed.StructuredArgs) - } - } - log = logWith.Logger() - parsed.Ctx = log.WithContext(ctx) - parsed.Log = &log - - if handler.Parameters != nil && parsed.StructuredArgs == nil { - // The handler wants structured parameters, but the client didn't send MSC4391 data - var err error - parsed.StructuredArgs, err = handler.Spec().ParseArguments(parsed.RawArgs) - if err != nil { - log.Debug().Err(err).Msg("Failed to parse structured arguments") - // TODO better error, usage info? deduplicate with WithParsedArgs - parsed.Reply("Failed to parse arguments: %v", err) - return - } - if proc.LogArgs { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.RawJSON("structured_args", parsed.StructuredArgs) - }) - } - } - - log.Debug().Msg("Processing command") - handler.Func(parsed) -} diff --git a/mautrix-patched/commands/reactions.go b/mautrix-patched/commands/reactions.go deleted file mode 100644 index 0d316219..00000000 --- a/mautrix-patched/commands/reactions.go +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package commands - -import ( - "context" - "encoding/json" - "strings" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" -) - -const ReactionCommandsKey = "fi.mau.reaction_commands" -const ReactionMultiUseKey = "fi.mau.reaction_multi_use" - -type ReactionCommandData struct { - Command string `json:"command"` - Args any `json:"args,omitempty"` -} - -func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] { - content, ok := evt.Content.Parsed.(*event.ReactionEventContent) - if !ok { - return nil - } - evtID := content.RelatesTo.EventID - if evtID == "" || !strings.HasPrefix(content.RelatesTo.Key, proc.ReactionCommandPrefix) { - return nil - } - targetEvt, err := proc.Client.GetEvent(ctx, evt.RoomID, evtID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", evtID).Msg("Failed to get target event for reaction") - return nil - } else if targetEvt.Sender != proc.Client.UserID || targetEvt.Unsigned.RedactedBecause != nil { - return nil - } - if targetEvt.Type == event.EventEncrypted { - if proc.Client.Crypto == nil { - zerolog.Ctx(ctx).Warn(). - Stringer("target_event_id", evtID). - Msg("Received reaction to encrypted event, but don't have crypto helper in client") - return nil - } - _ = targetEvt.Content.ParseRaw(targetEvt.Type) - targetEvt, err = proc.Client.Crypto.Decrypt(ctx, targetEvt) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("target_event_id", evtID). - Msg("Failed to decrypt target event for reaction") - return nil - } - } - reactionCommands, ok := targetEvt.Content.Raw[ReactionCommandsKey].(map[string]any) - if !ok { - zerolog.Ctx(ctx).Trace(). - Stringer("target_event_id", evtID). - Msg("Reaction target event doesn't have commands key") - return nil - } - isMultiUse, _ := targetEvt.Content.Raw[ReactionMultiUseKey].(bool) - rawCmd, ok := reactionCommands[content.RelatesTo.Key] - if !ok { - zerolog.Ctx(ctx).Debug(). - Stringer("target_event_id", evtID). - Str("reaction_key", content.RelatesTo.Key). - Msg("Reaction command not found in target event") - return nil - } - var wrappedEvt *Event[MetaType] - switch typedCmd := rawCmd.(type) { - case string: - wrappedEvt = RawTextToEvent[MetaType](ctx, evt, typedCmd) - case map[string]any: - var input event.MSC4391BotCommandInput - if marshaled, err := json.Marshal(typedCmd); err != nil { - - } else if err = json.Unmarshal(marshaled, &input); err != nil { - - } else { - wrappedEvt = StructuredCommandToEvent[MetaType](ctx, evt, &input) - } - } - if wrappedEvt == nil { - zerolog.Ctx(ctx).Debug(). - Stringer("target_event_id", evtID). - Str("reaction_key", content.RelatesTo.Key). - Msg("Reaction command data is invalid") - return nil - } - wrappedEvt.Proc = proc - wrappedEvt.Redact() - if !isMultiUse { - DeleteAllReactions(ctx, proc.Client, evt) - } - if wrappedEvt.Command == "" { - return nil - } - return wrappedEvt -} - -func DeleteAllReactionsCommandFunc[MetaType any](ce *Event[MetaType]) { - DeleteAllReactions(ce.Ctx, ce.Proc.Client, ce.Event) -} - -func DeleteAllReactions(ctx context.Context, client *mautrix.Client, evt *event.Event) { - rel, ok := evt.Content.Parsed.(event.Relatable) - if !ok { - return - } - relation := rel.OptionalGetRelatesTo() - if relation == nil { - return - } - targetEvt := relation.GetReplyTo() - if targetEvt == "" { - targetEvt = relation.GetAnnotationID() - } - if targetEvt == "" { - return - } - relations, err := client.GetRelations(ctx, evt.RoomID, targetEvt, &mautrix.ReqGetRelations{ - RelationType: event.RelAnnotation, - EventType: event.EventReaction, - Limit: 20, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get reactions to delete") - return - } - for _, relEvt := range relations.Chunk { - _, err = client.RedactEvent(ctx, relEvt.RoomID, relEvt.ID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("event_id", relEvt.ID).Msg("Failed to redact reaction event") - } - } -} diff --git a/mautrix-patched/crypto/account.go b/mautrix-patched/crypto/account.go deleted file mode 100644 index cc037edb..00000000 --- a/mautrix-patched/crypto/account.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "fmt" - - "github.com/tidwall/sjson" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/id" -) - -type OlmAccount struct { - Internal olm.Account - signingKey id.SigningKey - identityKey id.IdentityKey - Shared bool - KeyBackupVersion id.KeyBackupVersion -} - -func NewOlmAccount() *OlmAccount { - account, err := olm.NewAccount() - if err != nil { - panic(err) - } - return &OlmAccount{ - Internal: account, - } -} - -func (account *OlmAccount) Keys() (id.SigningKey, id.IdentityKey) { - if len(account.signingKey) == 0 || len(account.identityKey) == 0 { - var err error - account.signingKey, account.identityKey, err = account.Internal.IdentityKeys() - if err != nil { - panic(err) - } - } - return account.signingKey, account.identityKey -} - -func (account *OlmAccount) SigningKey() id.SigningKey { - if len(account.signingKey) == 0 { - var err error - account.signingKey, account.identityKey, err = account.Internal.IdentityKeys() - if err != nil { - panic(err) - } - } - return account.signingKey -} - -func (account *OlmAccount) IdentityKey() id.IdentityKey { - if len(account.identityKey) == 0 { - var err error - account.signingKey, account.identityKey, err = account.Internal.IdentityKeys() - if err != nil { - panic(err) - } - } - return account.identityKey -} - -// SignJSON signs the given JSON object following the Matrix specification: -// https://matrix.org/docs/spec/appendices#signing-json -func (account *OlmAccount) SignJSON(obj any) (string, error) { - objJSON, err := canonicaljson.Marshal(obj) - if err != nil { - return "", err - } - objJSON, _ = sjson.DeleteBytes(objJSON, "unsigned") - objJSON, _ = sjson.DeleteBytes(objJSON, "signatures") - // This is probably not necessary - err = canonicaljson.Canonicalize(&objJSON) - if err != nil { - return "", fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) - } - signed, err := account.Internal.Sign(objJSON) - return string(signed), err -} - -func (account *OlmAccount) getInitialKeys(userID id.UserID, deviceID id.DeviceID) *mautrix.DeviceKeys { - deviceKeys := &mautrix.DeviceKeys{ - UserID: userID, - DeviceID: deviceID, - Algorithms: []id.Algorithm{id.AlgorithmMegolmV1, id.AlgorithmOlmV1}, - Keys: map[id.DeviceKeyID]string{ - id.NewDeviceKeyID(id.KeyAlgorithmCurve25519, deviceID): string(account.IdentityKey()), - id.NewDeviceKeyID(id.KeyAlgorithmEd25519, deviceID): string(account.SigningKey()), - }, - } - - signature, err := account.SignJSON(deviceKeys) - if err != nil { - panic(err) - } - - deviceKeys.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, deviceID.String(), signature) - return deviceKeys -} - -func (account *OlmAccount) getOneTimeKeys(userID id.UserID, deviceID id.DeviceID, currentOTKCount int) map[id.KeyID]mautrix.OneTimeKey { - newCount := int(account.Internal.MaxNumberOfOneTimeKeys()/2) - currentOTKCount - if newCount > 0 { - account.Internal.GenOneTimeKeys(uint(newCount)) - } - oneTimeKeys := make(map[id.KeyID]mautrix.OneTimeKey) - internalKeys, err := account.Internal.OneTimeKeys() - if err != nil { - panic(err) - } - for keyID, key := range internalKeys { - key := mautrix.OneTimeKey{Key: key} - signature, _ := account.SignJSON(key) - key.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, deviceID.String(), signature) - key.IsSigned = true - oneTimeKeys[id.NewKeyID(id.KeyAlgorithmSignedCurve25519, keyID)] = key - } - return oneTimeKeys -} diff --git a/mautrix-patched/crypto/aescbc/aes_cbc.go b/mautrix-patched/crypto/aescbc/aes_cbc.go deleted file mode 100644 index e0381635..00000000 --- a/mautrix-patched/crypto/aescbc/aes_cbc.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package aescbc - -import ( - "crypto/aes" - "crypto/cipher" - - "go.mau.fi/util/pkcs7" -) - -// Encrypt encrypts the plaintext with the key and IV. The IV length must be -// equal to the AES block size. -func Encrypt(key, iv, plaintext []byte) ([]byte, error) { - if len(key) == 0 { - return nil, ErrNoKeyProvided - } - if len(iv) != aes.BlockSize { - return nil, ErrIVNotBlockSize - } - // This will copy the input to ensure the CryptBlocks doesn't mutate it later - plaintext = pkcs7.Pad(plaintext, aes.BlockSize) - - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - cipher.NewCBCEncrypter(block, iv).CryptBlocks(plaintext, plaintext) - return plaintext, nil -} - -// Decrypt decrypts the ciphertext with the key and IV. The IV length must be -// equal to the block size. -// -// This function mutates the ciphertext. -func Decrypt(key, iv, ciphertext []byte) ([]byte, error) { - if len(key) == 0 { - return nil, ErrNoKeyProvided - } - if len(iv) != aes.BlockSize { - return nil, ErrIVNotBlockSize - } - - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { - return nil, ErrNotMultipleBlockSize - } - - cipher.NewCBCDecrypter(block, iv).CryptBlocks(ciphertext, ciphertext) - return pkcs7.Unpad(ciphertext) -} diff --git a/mautrix-patched/crypto/aescbc/aes_cbc_test.go b/mautrix-patched/crypto/aescbc/aes_cbc_test.go deleted file mode 100644 index d6611dc9..00000000 --- a/mautrix-patched/crypto/aescbc/aes_cbc_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package aescbc_test - -import ( - "crypto/aes" - "crypto/rand" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/aescbc" -) - -func TestAESCBC(t *testing.T) { - var ciphertext, plaintext []byte - var err error - - // The key length can be 32, 24, 16 bytes (OR in bits: 128, 192 or 256) - key := make([]byte, 32) - _, err = rand.Read(key) - require.NoError(t, err) - iv := make([]byte, aes.BlockSize) - _, err = rand.Read(iv) - require.NoError(t, err) - plaintext = []byte("secret message for testing") - //increase to next block size - for len(plaintext)%8 != 0 { - plaintext = append(plaintext, []byte("-")...) - } - - ciphertext, err = aescbc.Encrypt(key, iv, plaintext) - require.NoError(t, err) - - resultPlainText, err := aescbc.Decrypt(key, iv, ciphertext) - require.NoError(t, err) - - assert.Equal(t, string(resultPlainText), string(plaintext)) -} - -func TestAESCBCCase1(t *testing.T) { - expected := []byte{ - 0xDC, 0x95, 0xC0, 0x78, 0xA2, 0x40, 0x89, 0x89, - 0xAD, 0x48, 0xA2, 0x14, 0x92, 0x84, 0x20, 0x87, - 0xF3, 0xC0, 0x03, 0xDD, 0xC4, 0xA7, 0xB8, 0xA9, - 0x4B, 0xAE, 0xDF, 0xFC, 0x3D, 0x21, 0x4C, 0x38, - } - input := make([]byte, 16) - key := make([]byte, 32) - iv := make([]byte, aes.BlockSize) - encrypted, err := aescbc.Encrypt(key, iv, input) - require.NoError(t, err) - assert.Equal(t, expected, encrypted, "encrypted output does not match expected") - - decrypted, err := aescbc.Decrypt(key, iv, encrypted) - require.NoError(t, err) - assert.Equal(t, input, decrypted, "decrypted output does not match input") -} diff --git a/mautrix-patched/crypto/aescbc/errors.go b/mautrix-patched/crypto/aescbc/errors.go deleted file mode 100644 index f3d2d7ce..00000000 --- a/mautrix-patched/crypto/aescbc/errors.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package aescbc - -import "errors" - -var ( - ErrNoKeyProvided = errors.New("no key") - ErrIVNotBlockSize = errors.New("IV length does not match AES block size") - ErrNotMultipleBlockSize = errors.New("ciphertext length is not a multiple of the AES block size") -) diff --git a/mautrix-patched/crypto/attachment/attachments.go b/mautrix-patched/crypto/attachment/attachments.go deleted file mode 100644 index 727aacbf..00000000 --- a/mautrix-patched/crypto/attachment/attachments.go +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package attachment - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "hash" - "io" - - "maunium.net/go/mautrix/crypto/utils" -) - -var ( - ErrHashMismatch = errors.New("mismatching SHA-256 digest") - ErrUnsupportedVersion = errors.New("unsupported Matrix file encryption version") - ErrUnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm") - ErrInvalidKey = errors.New("failed to decode key") - ErrInvalidInitVector = errors.New("failed to decode initialization vector") - ErrInvalidHash = errors.New("failed to decode SHA-256 hash") - ErrReaderClosed = errors.New("encrypting reader was already closed") -) - -// Deprecated: use variables prefixed with Err -var ( - HashMismatch = ErrHashMismatch - UnsupportedVersion = ErrUnsupportedVersion - UnsupportedAlgorithm = ErrUnsupportedAlgorithm - InvalidKey = ErrInvalidKey - InvalidInitVector = ErrInvalidInitVector - InvalidHash = ErrInvalidHash - ReaderClosed = ErrReaderClosed -) - -var ( - keyBase64Length = base64.RawURLEncoding.EncodedLen(utils.AESCTRKeyLength) - ivBase64Length = base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength) - hashBase64Length = base64.RawStdEncoding.EncodedLen(utils.SHAHashLength) -) - -type JSONWebKey struct { - Key string `json:"k"` - Algorithm string `json:"alg"` - Extractable bool `json:"ext"` - KeyType string `json:"kty"` - KeyOps []string `json:"key_ops"` -} - -type EncryptedFileHashes struct { - SHA256 string `json:"sha256"` -} - -type decodedKeys struct { - key [utils.AESCTRKeyLength]byte - iv [utils.AESCTRIVLength]byte - - sha256 [utils.SHAHashLength]byte -} - -type EncryptedFile struct { - Key JSONWebKey `json:"key"` - InitVector string `json:"iv"` - Hashes EncryptedFileHashes `json:"hashes"` - Version string `json:"v"` - - decoded *decodedKeys -} - -func NewEncryptedFile() *EncryptedFile { - key, iv := utils.GenAttachmentA256CTR() - return &EncryptedFile{ - Key: JSONWebKey{ - Key: base64.RawURLEncoding.EncodeToString(key[:]), - Algorithm: "A256CTR", - Extractable: true, - KeyType: "oct", - KeyOps: []string{"encrypt", "decrypt"}, - }, - InitVector: base64.RawStdEncoding.EncodeToString(iv[:]), - Version: "v2", - - decoded: &decodedKeys{key: key, iv: iv}, - } -} - -func (ef *EncryptedFile) decodeKeys(includeHash bool) error { - if ef.decoded != nil { - return nil - } else if len(ef.Key.Key) != keyBase64Length { - return ErrInvalidKey - } else if len(ef.InitVector) != ivBase64Length { - return ErrInvalidInitVector - } else if includeHash && len(ef.Hashes.SHA256) != hashBase64Length { - return ErrInvalidHash - } - ef.decoded = &decodedKeys{} - _, err := base64.RawURLEncoding.Decode(ef.decoded.key[:], []byte(ef.Key.Key)) - if err != nil { - return ErrInvalidKey - } - _, err = base64.RawStdEncoding.Decode(ef.decoded.iv[:], []byte(ef.InitVector)) - if err != nil { - return ErrInvalidInitVector - } - if includeHash { - _, err = base64.RawStdEncoding.Decode(ef.decoded.sha256[:], []byte(ef.Hashes.SHA256)) - if err != nil { - return ErrInvalidHash - } - } - return nil -} - -// Encrypt encrypts the given data, updates the SHA256 hash in the EncryptedFile struct and returns the ciphertext. -// -// Deprecated: this makes a copy for the ciphertext, which means 2x memory usage. EncryptInPlace is recommended. -func (ef *EncryptedFile) Encrypt(plaintext []byte) []byte { - ciphertext := make([]byte, len(plaintext)) - copy(ciphertext, plaintext) - ef.EncryptInPlace(ciphertext) - return ciphertext -} - -// EncryptInPlace encrypts the given data in-place (i.e. the provided data is overridden with the ciphertext) -// and updates the SHA256 hash in the EncryptedFile struct. -func (ef *EncryptedFile) EncryptInPlace(data []byte) { - ef.decodeKeys(false) - utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) - checksum := sha256.Sum256(data) - ef.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(checksum[:]) -} - -type ReadWriterAt interface { - io.WriterAt - io.Reader -} - -// EncryptFile encrypts the given file in-place and updates the SHA256 hash in the EncryptedFile struct. -func (ef *EncryptedFile) EncryptFile(file ReadWriterAt) error { - err := ef.decodeKeys(false) - if err != nil { - return err - } - block, _ := aes.NewCipher(ef.decoded.key[:]) - stream := cipher.NewCTR(block, ef.decoded.iv[:]) - hasher := sha256.New() - buf := make([]byte, 32*1024) - var writePtr int64 - var n int - for { - n, err = file.Read(buf) - if err != nil && !errors.Is(err, io.EOF) { - return err - } - if n == 0 { - break - } - stream.XORKeyStream(buf[:n], buf[:n]) - _, err = file.WriteAt(buf[:n], writePtr) - if err != nil { - return err - } - writePtr += int64(n) - hasher.Write(buf[:n]) - } - ef.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(hasher.Sum(nil)) - return nil -} - -type encryptingReader struct { - stream cipher.Stream - hash hash.Hash - source io.Reader - file *EncryptedFile - closed bool - - isDecrypting bool -} - -var _ io.ReadSeekCloser = (*encryptingReader)(nil) - -func (r *encryptingReader) Seek(offset int64, whence int) (int64, error) { - if r.closed { - return 0, ErrReaderClosed - } - if offset != 0 || whence != io.SeekStart { - return 0, fmt.Errorf("attachments.EncryptStream: only seeking to the beginning is supported") - } - seeker, ok := r.source.(io.ReadSeeker) - if !ok { - return 0, fmt.Errorf("attachments.EncryptStream: source reader (%T) is not an io.ReadSeeker", r.source) - } - n, err := seeker.Seek(offset, whence) - if err != nil { - return 0, err - } - block, _ := aes.NewCipher(r.file.decoded.key[:]) - r.stream = cipher.NewCTR(block, r.file.decoded.iv[:]) - r.hash.Reset() - return n, nil -} - -func (r *encryptingReader) Read(dst []byte) (n int, err error) { - if r.closed { - return 0, ErrReaderClosed - } else if r.isDecrypting && r.file.decoded == nil { - if err = r.file.PrepareForDecryption(); err != nil { - return - } - } - n, err = r.source.Read(dst) - if r.isDecrypting { - r.hash.Write(dst[:n]) - } - r.stream.XORKeyStream(dst[:n], dst[:n]) - if !r.isDecrypting { - r.hash.Write(dst[:n]) - } - return -} - -func (r *encryptingReader) Close() (err error) { - closer, ok := r.source.(io.ReadCloser) - if ok { - err = closer.Close() - } - if r.isDecrypting { - if !hmac.Equal(r.hash.Sum(nil), r.file.decoded.sha256[:]) { - return ErrHashMismatch - } - } else { - r.file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(r.hash.Sum(nil)) - } - r.closed = true - return -} - -// EncryptStream wraps the given io.Reader in order to encrypt the data. -// -// The Close() method of the returned io.ReadCloser must be called for the SHA256 hash -// in the EncryptedFile struct to be updated. The metadata is not valid before the hash -// is filled. -func (ef *EncryptedFile) EncryptStream(reader io.Reader) io.ReadSeekCloser { - ef.decodeKeys(false) - block, _ := aes.NewCipher(ef.decoded.key[:]) - return &encryptingReader{ - stream: cipher.NewCTR(block, ef.decoded.iv[:]), - hash: sha256.New(), - source: reader, - file: ef, - } -} - -// Decrypt decrypts the given data and returns the plaintext. -// -// Deprecated: this makes a copy for the plaintext data, which means 2x memory usage. DecryptInPlace is recommended. -func (ef *EncryptedFile) Decrypt(ciphertext []byte) ([]byte, error) { - plaintext := make([]byte, len(ciphertext)) - copy(plaintext, ciphertext) - return plaintext, ef.DecryptInPlace(plaintext) -} - -// PrepareForDecryption checks that the version and algorithm are supported and decodes the base64 keys -// -// DecryptStream will call this with the first Read() call if this hasn't been called manually. -// -// DecryptInPlace will always call this automatically, so calling this manually is not necessary when using that function. -func (ef *EncryptedFile) PrepareForDecryption() error { - if ef.Version != "v2" { - return ErrUnsupportedVersion - } else if ef.Key.Algorithm != "A256CTR" { - return ErrUnsupportedAlgorithm - } else if err := ef.decodeKeys(true); err != nil { - return err - } - return nil -} - -// DecryptInPlace decrypts the given data in-place (i.e. the provided data is overridden with the plaintext). -func (ef *EncryptedFile) DecryptInPlace(data []byte) error { - if err := ef.PrepareForDecryption(); err != nil { - return err - } - dataHash := sha256.Sum256(data) - if !hmac.Equal(ef.decoded.sha256[:], dataHash[:]) { - return ErrHashMismatch - } - utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv) - return nil -} - -// DecryptStream wraps the given io.Reader in order to decrypt the data. -// -// The first Read call will check the algorithm and decode keys, so it might return an error before actually reading anything. -// If you want to validate the file before opening the stream, call PrepareForDecryption manually and check for errors. -// -// The Close call will validate the hash and return an error if it doesn't match. -// In this case, the written data should be considered compromised and should not be used further. -func (ef *EncryptedFile) DecryptStream(reader io.Reader) io.ReadSeekCloser { - block, _ := aes.NewCipher(ef.decoded.key[:]) - return &encryptingReader{ - isDecrypting: true, - stream: cipher.NewCTR(block, ef.decoded.iv[:]), - hash: sha256.New(), - source: reader, - file: ef, - } -} diff --git a/mautrix-patched/crypto/attachment/attachments_test.go b/mautrix-patched/crypto/attachment/attachments_test.go deleted file mode 100644 index 9fe929ab..00000000 --- a/mautrix-patched/crypto/attachment/attachments_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package attachment - -import ( - "encoding/base64" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" -) - -const helloWorldCiphertext = ":6\xc7O1yR\x06\xe8\xcf]" -const helloWorldRawFile = `{ - "v": "v2", - "key": { - "kty": "oct", - "alg": "A256CTR", - "ext": true, - "k": "35XNdmWKOpn6UYS82Y83wEY8LagwQZHX2X0kAFW7sdg", - "key_ops": [ - "encrypt", - "decrypt" - ] - }, - "iv": "DOtPz8bC3qgAAAAAAAAAAA", - "hashes": { - "sha256": "rO+040ZhUxbpbmIS9GUuMSen4NPKFxMzqOUJeemM8mk" - } -}` -const random32Bytes = "\x85\xb4\x16/\xcaO\x1d\xe6\x7f\x95\xeb\xdb+g\x11\xb1\x81\x1a\xafY\x00\x1dq!h{\x81F\xaa\xd7A\x00" - -func parseHelloWorld() *EncryptedFile { - file := &EncryptedFile{} - _ = json.Unmarshal([]byte(helloWorldRawFile), file) - return file -} - -func TestDecryptHelloWorld(t *testing.T) { - file := parseHelloWorld() - data := []byte(helloWorldCiphertext) - err := file.DecryptInPlace(data) - assert.NoError(t, err, "failed to decrypt file") - assert.Equal(t, "hello world", string(data), "unexpected decrypt output") -} - -func TestEncryptHelloWorld(t *testing.T) { - file := parseHelloWorld() - data := []byte("hello world") - file.EncryptInPlace(data) - assert.Equal(t, helloWorldCiphertext, string(data), "unexpected encrypt output") -} - -func TestUnsupportedVersion(t *testing.T) { - file := parseHelloWorld() - file.Version = "foo" - err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, ErrUnsupportedVersion) -} - -func TestUnsupportedAlgorithm(t *testing.T) { - file := parseHelloWorld() - file.Key.Algorithm = "bar" - err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, ErrUnsupportedAlgorithm) -} - -func TestHashMismatch(t *testing.T) { - file := parseHelloWorld() - file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString([]byte(random32Bytes)) - err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, ErrHashMismatch) -} - -func TestTooLongHash(t *testing.T) { - file := parseHelloWorld() - file.Hashes.SHA256 = "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVlciBhZGlwaXNjaW5nIGVsaXQuIFNlZCBwb3N1ZXJlIGludGVyZHVtIHNlbS4gUXVpc3F1ZSBsaWd1bGEgZXJvcyB1bGxhbWNvcnBlciBxdWlzLCBsYWNpbmlhIHF1aXMgZmFjaWxpc2lzIHNlZCBzYXBpZW4uCg" - err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, ErrInvalidHash) -} - -func TestTooShortHash(t *testing.T) { - file := parseHelloWorld() - file.Hashes.SHA256 = "5/Gy1JftyyQ" - err := file.DecryptInPlace([]byte(helloWorldCiphertext)) - assert.ErrorIs(t, err, ErrInvalidHash) -} diff --git a/mautrix-patched/crypto/backup/encryptedsessiondata.go b/mautrix-patched/crypto/backup/encryptedsessiondata.go deleted file mode 100644 index dcd5708d..00000000 --- a/mautrix-patched/crypto/backup/encryptedsessiondata.go +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package backup - -import ( - "crypto/ecdh" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/json" - "errors" - - "go.mau.fi/util/jsonbytes" - "golang.org/x/crypto/hkdf" - - "maunium.net/go/mautrix/crypto/aescbc" -) - -var ErrInvalidMAC = errors.New("invalid MAC") - -// EncryptedSessionData is the encrypted session_data field of a key backup as -// defined in [Section 11.12.3.2.2 of the Spec]. -// -// The type parameter T represents the format of the session data contained in -// the encrypted payload. -// -// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 -type EncryptedSessionData[T any] struct { - Ciphertext jsonbytes.UnpaddedBytes `json:"ciphertext"` - Ephemeral EphemeralKey `json:"ephemeral"` - MAC jsonbytes.UnpaddedBytes `json:"mac"` -} - -func calculateEncryptionParameters(sharedSecret []byte) (key, macKey, iv []byte, err error) { - hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, nil) - encryptionParams := make([]byte, 80) - _, err = hkdfReader.Read(encryptionParams) - if err != nil { - return nil, nil, nil, err - } - - return encryptionParams[:32], encryptionParams[32:64], encryptionParams[64:], nil -} - -// calculateCompatMAC calculates the MAC as described in step 5 of according to -// [Section 11.12.3.2.2] of the Spec which was updated in spec version 1.10 to -// reflect what is actually implemented in libolm and Vodozemac. -// -// Libolm implemented the MAC functionality incorrectly. The MAC is computed -// over an empty string rather than the ciphertext. Vodozemac implemented this -// functionality the same way as libolm for compatibility. In version 1.10 of -// the spec, the description of step 5 was updated to reflect the de-facto -// standard of libolm and Vodozemac. -// -// [Section 11.12.3.2.2]: https://spec.matrix.org/v1.11/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 -func calculateCompatMAC(macKey []byte) []byte { - hash := hmac.New(sha256.New, macKey) - return hash.Sum(nil)[:8] -} - -// EncryptSessionData encrypts the given session data with the given recovery -// key as defined in [Section 11.12.3.2.2 of the Spec]. -// -// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 -func EncryptSessionData[T any](backupKey *MegolmBackupKey, sessionData T) (*EncryptedSessionData[T], error) { - return EncryptSessionDataWithPubkey(backupKey.PublicKey(), sessionData) -} - -func EncryptSessionDataWithPubkey[T any](pubkey *ecdh.PublicKey, sessionData T) (*EncryptedSessionData[T], error) { - sessionJSON, err := json.Marshal(sessionData) - if err != nil { - return nil, err - } - - ephemeralKey, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - return nil, err - } - - sharedSecret, err := ephemeralKey.ECDH(pubkey) - if err != nil { - return nil, err - } - - key, macKey, iv, err := calculateEncryptionParameters(sharedSecret) - if err != nil { - return nil, err - } - - ciphertext, err := aescbc.Encrypt(key, iv, sessionJSON) - if err != nil { - return nil, err - } - - return &EncryptedSessionData[T]{ - Ciphertext: ciphertext, - Ephemeral: EphemeralKey{ephemeralKey.PublicKey()}, - MAC: calculateCompatMAC(macKey), - }, nil -} - -// Decrypt decrypts the [EncryptedSessionData] into a *T using the recovery key -// by reversing the process described in [Section 11.12.3.2.2 of the Spec]. -// -// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 -func (esd *EncryptedSessionData[T]) Decrypt(backupKey *MegolmBackupKey) (*T, error) { - sharedSecret, err := backupKey.ECDH(esd.Ephemeral.PublicKey) - if err != nil { - return nil, err - } - - key, macKey, iv, err := calculateEncryptionParameters(sharedSecret) - if err != nil { - return nil, err - } - - // Verify the MAC before decrypting. - if !hmac.Equal(calculateCompatMAC(macKey), esd.MAC) { - return nil, ErrInvalidMAC - } - - plaintext, err := aescbc.Decrypt(key, iv, esd.Ciphertext) - if err != nil { - return nil, err - } - - var sessionData T - err = json.Unmarshal(plaintext, &sessionData) - return &sessionData, err -} diff --git a/mautrix-patched/crypto/backup/encryptedsessiondata_test.go b/mautrix-patched/crypto/backup/encryptedsessiondata_test.go deleted file mode 100644 index 761c4328..00000000 --- a/mautrix-patched/crypto/backup/encryptedsessiondata_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package backup_test - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/backup" - "maunium.net/go/mautrix/id" -) - -func TestEncryptedSessionData_Decrypt(t *testing.T) { - testCases := []struct { - encryptedJSON []byte - expectedJSON string - }{ - { - []byte(` - { - "ciphertext": "hDCjEbyi2uMXt3RBWe9mRdeqhcoraPR84/cq5ll16LIIIICJ8ZLmiWG5IwmGqDFmd3Jw20cNo49b38LH3oBJUl5DG44VdjoI4nlgAzaMSLwMZ7JFGt0Enu1Csfgpvgt1qksTP6QB7YDwITD33iL7ucco1iOl7ABGzhyjCi2iZ3A6Xmx3RsAmHhmU5gJWE6/lIoI6/lh7dZFSfp4RTGfxQ8ToCCIsrgdx1weViv4I4ArXfcrdnaprPzP4cH77Ej1Wg1/bUHtB4C8nOiX+cYnOG29NbTHbtQF14zJpA+2XM2JngiLkss+NQj96PQzgPNhAMEFOLLy5ckY1WvS4sMMeCVzAyt5dwEGDcyxLTC4oJ/RrvLcHCHW0aOygPSlNoMRyDgC0f92+mPQGAmFv4GhfDFXfaauBxBdRAPjXj7Onn2B4UdfwQXGLT3RAihba8i9usOX5hLxqQqvtA3SUuV8hPrzHhpPEeRvx+PgZsXwV+gM7Aw3Mza6hwmILdngJh7NNQTINsCRqff9Ck3Kh7aSOoHsHvz7Ot+T514ObDwWYYCBMmS/6EG4XjSya6R98ggRWGrO9l21YYUvzBTv7OLtMck0Za3151Zqi/5LRKP95QIU", - "ephemeral": "o43y/Mck1DExWdHr0+qbPJbjzO97+RH1mw6phLhYQj0", - "mac": "Mnt8eXwFfjw" - } - `), - ` - { - "algorithm": "m.megolm.v1.aes-sha2", - "sender_key": "JUUfV6vErSATm3rIOU9DML+IX1SlYxnAAS824xhbhC4", - "session_key": "AQAAAABc1O9JP2/HXS22iLN1uScFv2UyL33/L3L0sysPKcovQFI0lwKTuutrVeww2SNOU9b2J62kV/QXEw7+N2I9klrvqqr9kdo1ywqFtZOnp8DlgR2+OhOnUYmj5YmJhmApPle9xnVVwZv57Q0REsmSAovHBLH4Kf3GEHPJ9WXEEnLINT9Gzit9qjIZ1fKKacLtvsZ+hbnTPvP5Df3ENalB+03E", - "sender_claimed_keys": {"ed25519":"R2UJWSfgGr64iPENthl/98WGqBtnNlYuP12d6TEuGo4"}, - "forwarding_curve25519_key_chain": [] - } - `, - }, - { - []byte(` - { - "ciphertext": "vdLkqNTijkM1L7HmbxdZs1EHygC7GFG0wPTAaLqpOCoir3K6tNYbjIJs36vzrwawdmfPxZvA9p/k3bZIhZDP7IivGYe69+4pWiIzrwYkHCidigKXkYD8KxKWvakBquO9vWUssXC05xdkQjHMNJK3zSJgtkbMhoY28i1VUdmIjts4xU0cIT40F52Uyx3iu1UrqywUREEE5vhoSbeWxW3Vo5lqPi6rnyvMGZhVzAOv6re2O7wPWnSp0YJUsPaEj6Q9QpLr8BB9vJ++3kwmP5vxfjJLUsXuNEHWIKP5QyhpmGCgwjNpjnU6VhCqBzqs2M/KKX8zxZMGTIRidc3gx2i8KtDwRHRzh3FsSJEaC0sfCfGijpH5g9Pa+2P6b1GxvGQ4TF5X6ayLiV6FyNilpZ4z3kYsy63fP06uinHkX0TUClMQgLLmn0BAiOxKWtLNSLxgFdSYFm5oU/rpOBXWQKbzQ3cvlJZxBtxnaAhJnt3+t/3pJahlKAOxrQbKZAPL/KbO4nF9dsHpMkfMs25pVLDoHLKEXSBhagEFDbPKL5Uv55kca1C1XGrx+8fYUDBRQtYSLBSbAtF3UMv+hIMdRnmyQntwOy2hKRRs2UxnIlExk0Q", - "ephemeral": "24PxRUfQDyYNZcTq0HT8pS3Gq+zkfsAcXHFJ3nZ56W4", - "mac": "T7xq9qHm4Js" - } - `), - ` - { - "algorithm": "m.megolm.v1.aes-sha2", - "sender_key": "JUUfV6vErSATm3rIOU9DML+IX1SlYxnAAS824xhbhC4", - "session_key": "AQAAAAB6cP1PrdPeIG/B0ZRHNUc65ujvIzOxKhW1HN25efyZFaq9xsLvCngm4WO56gEuUhS16E4m0pAa9B/KyRz3AnSOVcHYh1bYxm9qf6zU5PFm255n6FR2lGN0vrgUM7Xu2GNUDCWoNI4m4QsiBor9eCj2ZJRay75dZ4nkhNf3GxBKOkhzPCreKabLxVsseGGIkq8rf01b0CWIcp5ISQISLdza", - "sender_claimed_keys": {"ed25519":"R2UJWSfgGr64iPENthl/98WGqBtnNlYuP12d6TEuGo4"}, - "forwarding_curve25519_key_chain": [] - } - `, - }, - } - - keyBytes, err := base64.RawStdEncoding.DecodeString("ReSMMZeRtDSdrwXzu2OvN0B73KUXkYPt3kaYfFIkw10") - require.NoError(t, err) - backupKey, err := backup.MegolmBackupKeyFromBytes(keyBytes) - require.NoError(t, err) - - for i, tc := range testCases { - t.Run(fmt.Sprintf("test case %d", i+1), func(t *testing.T) { - var esd backup.EncryptedSessionData[backup.MegolmSessionData] - err := json.Unmarshal([]byte(tc.encryptedJSON), &esd) - assert.NoError(t, err) - - sessionData, err := esd.Decrypt(backupKey) - require.NoError(t, err) - - sessionDataJSON, err := json.Marshal(sessionData) - require.NoError(t, err) - assert.JSONEq(t, string(tc.expectedJSON), string(sessionDataJSON)) - }) - } -} - -func TestEncryptedSessionData_Roundtrip(t *testing.T) { - backupKey, err := backup.NewMegolmBackupKey() - require.NoError(t, err) - - sessionData := backup.MegolmSessionData{ - Algorithm: id.AlgorithmMegolmV1, - } - - encrypted, err := backup.EncryptSessionData(backupKey, sessionData) - require.NoError(t, err) - - encryptedJSON, err := json.Marshal(encrypted) - require.NoError(t, err) - - var roundTrippedEncryptedSessionData backup.EncryptedSessionData[backup.MegolmSessionData] - err = json.Unmarshal(encryptedJSON, &roundTrippedEncryptedSessionData) - require.NoError(t, err) - - decrypted, err := roundTrippedEncryptedSessionData.Decrypt(backupKey) - require.NoError(t, err) - - assert.Equal(t, id.AlgorithmMegolmV1, decrypted.Algorithm) -} diff --git a/mautrix-patched/crypto/backup/ephemeralkey.go b/mautrix-patched/crypto/backup/ephemeralkey.go deleted file mode 100644 index e481e7a9..00000000 --- a/mautrix-patched/crypto/backup/ephemeralkey.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package backup - -import ( - "crypto/ecdh" - "encoding/base64" - "encoding/json" -) - -// EphemeralKey is a wrapper around an ECDH X25519 public key that implements -// JSON marshalling and unmarshalling. -type EphemeralKey struct { - *ecdh.PublicKey -} - -func (k *EphemeralKey) MarshalJSON() ([]byte, error) { - if k == nil || k.PublicKey == nil { - return json.Marshal(nil) - } - return json.Marshal(base64.RawStdEncoding.EncodeToString(k.Bytes())) -} - -func (k *EphemeralKey) UnmarshalJSON(data []byte) error { - var keyStr string - err := json.Unmarshal(data, &keyStr) - if err != nil { - return err - } - - keyBytes, err := base64.RawStdEncoding.DecodeString(keyStr) - if err != nil { - return err - } - k.PublicKey, err = ecdh.X25519().NewPublicKey(keyBytes) - return err -} diff --git a/mautrix-patched/crypto/backup/ephemeralkey_test.go b/mautrix-patched/crypto/backup/ephemeralkey_test.go deleted file mode 100644 index 0842f54f..00000000 --- a/mautrix-patched/crypto/backup/ephemeralkey_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package backup_test - -import ( - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/backup" -) - -type testStruct struct { - EphemeralKey *backup.EphemeralKey `json:"ephemeral"` -} - -func TestEphemeralKey_UnmarshalJSON(t *testing.T) { - testCases := []string{ - "o43y/Mck1DExWdHr0+qbPJbjzO97+RH1mw6phLhYQj0", - } - - testJSONTemplate := `{"ephemeral": "%s"}` - - for _, tc := range testCases { - t.Run(tc, func(t *testing.T) { - var test testStruct - jsonInput := fmt.Sprintf(testJSONTemplate, tc) - err := json.Unmarshal([]byte(jsonInput), &test) - require.NoError(t, err) - expected, err := base64.RawStdEncoding.DecodeString(tc) - require.NoError(t, err) - assert.Equal(t, expected, test.EphemeralKey.Bytes()) - }) - } -} - -func TestEphemeralKey_MarshallJSON(t *testing.T) { - key, err := ecdh.X25519().GenerateKey(rand.Reader) - require.NoError(t, err) - - test := &backup.EphemeralKey{key.PublicKey()} - marshalled, err := json.Marshal(test) - require.NoError(t, err) - assert.EqualValues(t, '"', marshalled[0]) - assert.Len(t, marshalled, 45) - assert.EqualValues(t, '"', marshalled[44]) -} diff --git a/mautrix-patched/crypto/backup/megolmbackup.go b/mautrix-patched/crypto/backup/megolmbackup.go deleted file mode 100644 index 71b8e88b..00000000 --- a/mautrix-patched/crypto/backup/megolmbackup.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package backup - -import ( - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/id" -) - -// MegolmAuthData is the auth_data when the key backup is created with -// the [id.KeyBackupAlgorithmMegolmBackupV1] algorithm as defined in -// [Section 11.12.3.2.2 of the Spec]. -// -// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 -type MegolmAuthData struct { - PublicKey id.Ed25519 `json:"public_key"` - Signatures signatures.Signatures `json:"signatures"` -} - -type SenderClaimedKeys struct { - Ed25519 id.Ed25519 `json:"ed25519"` -} - -// MegolmSessionData is the decrypted session_data when the key backup is created -// with the [id.KeyBackupAlgorithmMegolmBackupV1] algorithm as defined in -// [Section 11.12.3.2.2 of the Spec]. -// -// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 -type MegolmSessionData struct { - Algorithm id.Algorithm `json:"algorithm"` - ForwardingKeyChain []string `json:"forwarding_curve25519_key_chain"` - SenderClaimedKeys SenderClaimedKeys `json:"sender_claimed_keys"` - SenderKey id.SenderKey `json:"sender_key"` - SessionKey string `json:"session_key"` -} diff --git a/mautrix-patched/crypto/backup/megolmbackupkey.go b/mautrix-patched/crypto/backup/megolmbackupkey.go deleted file mode 100644 index 8f23d104..00000000 --- a/mautrix-patched/crypto/backup/megolmbackupkey.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package backup - -import ( - "crypto/ecdh" - "crypto/rand" -) - -// MegolmBackupKey is a wrapper around an ECDH X25519 private key that is used -// to decrypt a megolm key backup. -type MegolmBackupKey struct { - *ecdh.PrivateKey -} - -func NewMegolmBackupKey() (*MegolmBackupKey, error) { - key, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - return nil, err - } - return &MegolmBackupKey{key}, nil -} - -func MegolmBackupKeyFromBytes(bytes []byte) (*MegolmBackupKey, error) { - key, err := ecdh.X25519().NewPrivateKey(bytes) - if err != nil { - return nil, err - } - return &MegolmBackupKey{key}, nil -} diff --git a/mautrix-patched/crypto/canonicaljson/README.md b/mautrix-patched/crypto/canonicaljson/README.md deleted file mode 100644 index da9d71ff..00000000 --- a/mautrix-patched/crypto/canonicaljson/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# canonicaljson -This is a Go package to produce Matrix [Canonical JSON](https://matrix.org/docs/spec/appendices#canonical-json). -It is essentially just [json.go](https://github.com/matrix-org/gomatrixserverlib/blob/master/json.go) -from gomatrixserverlib without all the other files that are completely useless for non-server use cases. - -The original project is licensed under the Apache 2.0 license. diff --git a/mautrix-patched/crypto/canonicaljson/json.go b/mautrix-patched/crypto/canonicaljson/json.go deleted file mode 100644 index 1582b6b6..00000000 --- a/mautrix-patched/crypto/canonicaljson/json.go +++ /dev/null @@ -1,281 +0,0 @@ -//go:build !goexperiment.jsonv2 - -/* Copyright 2016-2017 Vector Creations Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package canonicaljson - -import ( - "encoding/binary" - "encoding/json" - "fmt" - "sort" - "unicode/utf8" - - "github.com/tidwall/gjson" -) - -func Canonicalize(input *json.RawMessage) error { - *input = CanonicalJSONAssumeValid(*input) - return nil -} - -func Marshal(v any) (json.RawMessage, error) { - marshaled, err := json.Marshal(v) - if err != nil { - return nil, err - } - return CanonicalJSONAssumeValid(marshaled), nil -} - -// CanonicalJSON re-encodes the JSON in a canonical encoding. The encoding is -// the shortest possible encoding using integer values with sorted object keys. -// https://matrix.org/docs/spec/appendices#canonical-json -// -// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 -func CanonicalJSON(input []byte) ([]byte, error) { - if !gjson.Valid(string(input)) { - return nil, fmt.Errorf("invalid json") - } - - return CanonicalJSONAssumeValid(input), nil -} - -// CanonicalJSONAssumeValid is the same as CanonicalJSON, but assumes the -// input is valid JSON -// -// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 -func CanonicalJSONAssumeValid(input []byte) []byte { - input = CompactJSON(input, make([]byte, 0, len(input))) - return SortJSON(input, make([]byte, 0, len(input))) -} - -// SortJSON reencodes the JSON with the object keys sorted by lexicographically -// by codepoint. The input must be valid JSON. -// -// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 -func SortJSON(input, output []byte) []byte { - result := gjson.ParseBytes(input) - - return sortJSONValue(result, input, output) -} - -// sortJSONValue takes a gjson.Result and sorts it. inputJSON must be the -// raw JSON bytes that gjson.Result points to. -func sortJSONValue(input gjson.Result, inputJSON, output []byte) []byte { - if input.IsArray() { - return sortJSONArray(input, inputJSON, output) - } - - if input.IsObject() { - return sortJSONObject(input, inputJSON, output) - } - - // If its neither an object nor an array then there is no sub structure - // to sort, so just append the raw bytes. - return append(output, input.Raw...) -} - -// sortJSONArray takes a gjson.Result and sorts it, assuming its an array. -// inputJSON must be the raw JSON bytes that gjson.Result points to. -func sortJSONArray(input gjson.Result, inputJSON, output []byte) []byte { - sep := byte('[') - - // Iterate over each value in the array and sort it. - input.ForEach(func(_, value gjson.Result) bool { - output = append(output, sep) - sep = ',' - output = sortJSONValue(value, inputJSON, output) - return true // keep iterating - }) - - if sep == '[' { - // If sep is still '[' then the array was empty and we never wrote the - // initial '[', so we write it now along with the closing ']'. - output = append(output, '[', ']') - } else { - // Otherwise we end the array by writing a single ']' - output = append(output, ']') - } - return output -} - -// sortJSONObject takes a gjson.Result and sorts it, assuming its an object. -// inputJSON must be the raw JSON bytes that gjson.Result points to. -func sortJSONObject(input gjson.Result, inputJSON, output []byte) []byte { - type entry struct { - key string // The parsed key string - rawKey string // The raw, unparsed key JSON string - value gjson.Result - } - - var entries []entry - - // Iterate over each key/value pair and add it to a slice - // that we can sort - input.ForEach(func(key, value gjson.Result) bool { - entries = append(entries, entry{ - key: key.String(), - rawKey: key.Raw, - value: value, - }) - return true // keep iterating - }) - - // Sort the slice based on the *parsed* key - sort.Slice(entries, func(a, b int) bool { - return entries[a].key < entries[b].key - }) - - sep := byte('{') - - for _, entry := range entries { - output = append(output, sep) - sep = ',' - - // Append the raw unparsed JSON key, *not* the parsed key - output = append(output, entry.rawKey...) - output = append(output, ':') - output = sortJSONValue(entry.value, inputJSON, output) - } - if sep == '{' { - // If sep is still '{' then the object was empty and we never wrote the - // initial '{', so we write it now along with the closing '}'. - output = append(output, '{', '}') - } else { - // Otherwise we end the object by writing a single '}' - output = append(output, '}') - } - return output -} - -// CompactJSON makes the encoded JSON as small as possible by removing -// whitespace and unneeded unicode escapes -// -// Deprecated: Use the new Canonicalize or Marshal functions which will transparently migrate to jsonv2 -func CompactJSON(input, output []byte) []byte { - var i int - for i < len(input) { - c := input[i] - i++ - // The valid whitespace characters are all less than or equal to SPACE 0x20. - // The valid non-white characters are all greater than SPACE 0x20. - // So we can check for whitespace by comparing against SPACE 0x20. - if c <= ' ' { - // Skip over whitespace. - continue - } - // Add the non-whitespace character to the output. - output = append(output, c) - if c == '"' { - // We are inside a string. - for i < len(input) { - c = input[i] - i++ - // Check if this is an escape sequence. - if c == '\\' { - escape := input[i] - i++ - if escape == 'u' { - // If this is a unicode escape then we need to handle it specially - output, i = compactUnicodeEscape(input, output, i) - } else if escape == '/' { - // JSON does not require escaping '/', but allows encoders to escape it as a special case. - // Since the escape isn't required we remove it. - output = append(output, escape) - } else { - // All other permitted escapes are single charater escapes that are already in their shortest form. - output = append(output, '\\', escape) - } - } else { - output = append(output, c) - } - if c == '"' { - break - } - } - } - } - return output -} - -// compactUnicodeEscape unpacks a 4 byte unicode escape starting at index. -// If the escape is a surrogate pair then decode the 6 byte \uXXXX escape -// that follows. Returns the output slice and a new input index. -func compactUnicodeEscape(input, output []byte, index int) ([]byte, int) { - const ( - ESCAPES = "uuuuuuuubtnufruuuuuuuuuuuuuuuuuu" - HEX = "0123456789ABCDEF" - ) - // If there aren't enough bytes to decode the hex escape then return. - if len(input)-index < 4 { - return output, len(input) - } - // Decode the 4 hex digits. - c := readHexDigits(input[index:]) - index += 4 - if c < ' ' { - // If the character is less than SPACE 0x20 then it will need escaping. - escape := ESCAPES[c] - output = append(output, '\\', escape) - if escape == 'u' { - output = append(output, '0', '0', byte('0'+(c>>4)), HEX[c&0xF]) - } - } else if c == '\\' || c == '"' { - // Otherwise the character only needs escaping if it is a QUOTE '"' or BACKSLASH '\\'. - output = append(output, '\\', byte(c)) - } else if c < 0xD800 || c >= 0xE000 { - // If the character isn't a surrogate pair then encoded it directly as UTF-8. - var buffer [4]byte - n := utf8.EncodeRune(buffer[:], rune(c)) - output = append(output, buffer[:n]...) - } else { - // Otherwise the escaped character was the first part of a UTF-16 style surrogate pair. - // The next 6 bytes MUST be a '\uXXXX'. - // If there aren't enough bytes to decode the hex escape then return. - if len(input)-index < 6 { - return output, len(input) - } - // Decode the 4 hex digits from the '\uXXXX'. - surrogate := readHexDigits(input[index+2:]) - index += 6 - // Reconstruct the UCS4 codepoint from the surrogates. - codepoint := 0x10000 + (((c & 0x3FF) << 10) | (surrogate & 0x3FF)) - // Encode the charater as UTF-8. - var buffer [4]byte - n := utf8.EncodeRune(buffer[:], rune(codepoint)) - output = append(output, buffer[:n]...) - } - return output, index -} - -// Read 4 hex digits from the input slice. -// Taken from https://github.com/NegativeMjark/indolentjson-rust/blob/8b959791fe2656a88f189c5d60d153be05fe3deb/src/readhex.rs#L21 -func readHexDigits(input []byte) uint32 { - hex := binary.BigEndian.Uint32(input) - // subtract '0' - hex -= 0x30303030 - // strip the higher bits, maps 'a' => 'A' - hex &= 0x1F1F1F1F - mask := hex & 0x10101010 - // subtract 'A' - 10 - '9' - 9 = 7 from the letters. - hex -= mask >> 1 - hex += mask >> 4 - // collect the nibbles - hex |= hex >> 4 - hex &= 0xFF00FF - hex |= hex >> 8 - return hex & 0xFFFF -} diff --git a/mautrix-patched/crypto/canonicaljson/json_test.go b/mautrix-patched/crypto/canonicaljson/json_test.go deleted file mode 100644 index 90ec90eb..00000000 --- a/mautrix-patched/crypto/canonicaljson/json_test.go +++ /dev/null @@ -1,110 +0,0 @@ -//go:build !goexperiment.jsonv2 - -/* Copyright 2016-2017 Vector Creations Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package canonicaljson - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSortJSON(t *testing.T) { - var tests = []struct { - input string - want string - }{ - {"{}", "{}"}, - {`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, - {`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, - {`[true,false,null]`, `[true,false,null]`}, - {`[9007199254740991]`, `[9007199254740991]`}, - {"\t\n[9007199254740991]", `[9007199254740991]`}, - {`[true,false,null]`, `[true,false,null]`}, - {`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, - {`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, - {`[true,false,null]`, `[true,false,null]`}, - {`[9007199254740991]`, `[9007199254740991]`}, - {"\t\n[9007199254740991]", `[9007199254740991]`}, - {`[true,false,null]`, `[true,false,null]`}, - } - for _, test := range tests { - t.Run(test.input, func(t *testing.T) { - got := SortJSON([]byte(test.input), nil) - - // Squash out the whitespace before comparing the JSON in case SortJSON had inserted whitespace. - assert.EqualValues(t, test.want, string(CompactJSON(got, nil))) - }) - } -} - -func testCompactJSON(t *testing.T, input, want string) { - t.Helper() - got := string(CompactJSON([]byte(input), nil)) - assert.EqualValues(t, want, got) -} - -func TestCompactJSON(t *testing.T) { - testCompactJSON(t, "{ }", "{}") - - input := `["\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007"]` - want := input - testCompactJSON(t, input, want) - - input = `["\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F"]` - want = `["\b\t\n\u000B\f\r\u000E\u000F"]` - testCompactJSON(t, input, want) - - input = `["\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017"]` - want = input - testCompactJSON(t, input, want) - - input = `["\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"]` - want = input - testCompactJSON(t, input, want) - - testCompactJSON(t, `["\u0061\u005C\u0042\u0022"]`, `["a\\B\""]`) - testCompactJSON(t, `["\u0120"]`, "[\"\u0120\"]") - testCompactJSON(t, `["\u0FFF"]`, "[\"\u0FFF\"]") - testCompactJSON(t, `["\u1820"]`, "[\"\u1820\"]") - testCompactJSON(t, `["\uFFFF"]`, "[\"\uFFFF\"]") - testCompactJSON(t, `["\uD842\uDC20"]`, "[\"\U00020820\"]") - testCompactJSON(t, `["\uDBFF\uDFFF"]`, "[\"\U0010FFFF\"]") - - testCompactJSON(t, `["\"\\\/"]`, `["\"\\/"]`) -} - -func TestReadHex(t *testing.T) { - tests := []struct { - input string - want uint32 - }{ - - {"0123", 0x0123}, - {"4567", 0x4567}, - {"89AB", 0x89AB}, - {"CDEF", 0xCDEF}, - {"89ab", 0x89AB}, - {"cdef", 0xCDEF}, - } - for _, test := range tests { - t.Run(test.input, func(t *testing.T) { - got := readHexDigits([]byte(test.input)) - assert.Equal(t, test.want, got) - }) - } -} diff --git a/mautrix-patched/crypto/canonicaljson/jsonv2.go b/mautrix-patched/crypto/canonicaljson/jsonv2.go deleted file mode 100644 index 859073a2..00000000 --- a/mautrix-patched/crypto/canonicaljson/jsonv2.go +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package canonicaljson - -import ( - "bytes" - "cmp" - "encoding/json/jsontext" - "encoding/json/v2" - "fmt" - "slices" - "sync" -) - -var canonicalOpts = []json.Options{ - jsontext.CanonicalizeRawInts(true), - jsontext.CanonicalizeRawFloats(true), - jsontext.AllowDuplicateNames(false), - jsontext.AllowInvalidUTF8(false), - jsontext.ReorderRawObjects(false), // we want UTF-8 ordering rather than UTF-16 -} - -func Canonicalize(input *jsontext.Value) error { - err := input.Format(canonicalOpts...) - if err != nil { - return err - } - return reorderObjectsAndValidate(nil, *input, &[]byte{}) -} - -func Marshal(v any) (jsontext.Value, error) { - data, err := json.Marshal(v, canonicalOpts...) - if err != nil { - return nil, err - } - return data, reorderObjectsAndValidate(nil, data, &[]byte{}) -} - -type objectMember struct { - name []byte - buffer []byte -} - -func (x objectMember) Compare(y objectMember) int { - return bytes.Compare(x.name, y.name) -} - -var objectMemberPool = sync.Pool{ - New: func() any { - return new([]objectMember) - }, -} - -func getObjectMembers() *[]objectMember { - ns := objectMemberPool.Get().(*[]objectMember) - *ns = (*ns)[:0] - return ns -} - -func putObjectMembers(ns *[]objectMember) { - if cap(*ns) < 1<<10 { - clear(*ns) // avoid pinning name and buffer - objectMemberPool.Put(ns) - } -} - -// TODO replace with jsontext.Kind* after dropping Go 1.25 support -const ( - KindNull = 'n' - KindFalse = 'f' - KindTrue = 't' - KindString = '"' - KindNumber = '0' - KindBeginObject = '{' - KindEndObject = '}' - KindBeginArray = '[' - KindEndArray = ']' -) - -// This is based on the standard implementation of [jsontext.ReorderRawObjects] -// in https://github.com/golang/go/blob/go1.26.3/src/encoding/json/jsontext/value.go#L298-L395 -// It has been adjusted to: -// * work outside the standard library -// * assume the data is already minified rather than supporting extra whitespace -// * have additional matrix-specific validation for numbers (reject floats and enforce limits) -// * most importantly: sort objects based on UTF-8 instead of UTF-16 -func reorderObjectsAndValidate(d *jsontext.Decoder, buf []byte, scratch *[]byte) error { - if d == nil { - d = jsontext.NewDecoder( - bytes.NewBuffer(buf), - // This should improve performance slightly since the Format call earlier already rejected invalid data - jsontext.AllowDuplicateNames(true), - jsontext.AllowInvalidUTF8(true), - ) - } - switch tok, err := d.ReadToken(); tok.Kind() { - case KindBeginObject: - // Iterate and collect the name and offsets for every object member. - members := getObjectMembers() - defer putObjectMembers(members) - var prevMember objectMember - isSorted := true - - beforeBody := d.InputOffset() // offset after '{' - for d.PeekKind() != KindEndObject { - beforeName := d.InputOffset() - name, err := d.ReadValue() - if err != nil { - return fmt.Errorf("failed to read key: %w", err) - } - if !bytes.ContainsRune(name, '\\') { - name = name[1 : len(name)-1] // unquote without copying if possible - } else { - name, err = jsontext.AppendUnquote(nil, name) - if err != nil { - return fmt.Errorf("failed to unquote key: %w", err) - } - } - err = reorderObjectsAndValidate(d, buf, scratch) - if err != nil { - return fmt.Errorf("failed to reorder %s: %w", name, err) - } - afterValue := d.InputOffset() - - currMember := objectMember{name, buf[beforeName:afterValue]} - if isSorted && len(*members) > 0 { - isSorted = objectMember.Compare(prevMember, currMember) < 0 - } - *members = append(*members, currMember) - prevMember = currMember - } - afterBody := d.InputOffset() // offset before '}' - _, err = d.ReadToken() - if err != nil { - return fmt.Errorf("failed to read end of object: %w", err) - } - - // Sort the members; return early if it's already sorted. - if isSorted { - return nil - } - firstBufferBeforeSorting := (*members)[0].buffer - slices.SortFunc(*members, objectMember.Compare) - - // Append the reordered members to a new buffer, - // then copy the reordered members back over the original members. - // Avoid swapping in place since each member may be a different size - // where moving a member over a smaller member may corrupt the data - // for subsequent members before they have been moved. - // - // The following invariant must hold: - // sum([m.after-m.before for m in members]) == afterBody-beforeBody - sorted := (*scratch)[:0] - for i, member := range *members { - switch { - case i == 0 && &member.buffer[0] != &firstBufferBeforeSorting[0]: - // First member after sorting is not the first member before sorting, cut off the leading comma - if member.buffer[0] != ',' { - return fmt.Errorf("expected newly sorted first member buffer to start with a comma") - } - sorted = append(sorted, member.buffer[1:]...) - case i != 0 && &member.buffer[0] == &firstBufferBeforeSorting[0]: - // Later member after sorting is the first member before sorting, add leading comma - if member.buffer[0] == ',' { - return fmt.Errorf("expected newly sorted later member buffer to not start with a comma") - } - sorted = append(sorted, ',') - sorted = append(sorted, member.buffer...) - default: - sorted = append(sorted, member.buffer...) - } - } - if int(afterBody-beforeBody) != len(sorted) { - return fmt.Errorf("BUG: length invariant violated") - } - copy(buf[beforeBody:afterBody], sorted) - - // Update scratch buffer to the largest amount ever used. - if len(sorted) > len(*scratch) { - *scratch = sorted - } - return nil - case KindBeginArray: - for d.PeekKind() != KindEndArray { - err = reorderObjectsAndValidate(d, buf, scratch) - if err != nil { - return err - } - } - _, err = d.ReadToken() - return err - case KindNumber: - str := tok.String() - if str == "-0" { - return fmt.Errorf("invalid number: -0") - } else if str == "0" { - return nil - } else if len(str) > 17 { - return fmt.Errorf("too long number: %q", str) - } - var val uint64 - firstDigitIdx := 0 - for i, bt := range []byte(str) { - if i == 0 && bt == '-' { - firstDigitIdx = 1 - continue - } else if (bt >= '1' && bt <= '9') || (bt == '0' && i != firstDigitIdx) { - // ok - } else { - return fmt.Errorf("invalid character %c in number: %q", bt, str) - } - val = val*10 + uint64(bt-'0') - } - // val is always positive since we just ignore the leading - - if val >= 1<<53 { - return fmt.Errorf("number too large: %q", str) - } - return nil - case KindNull, KindFalse, KindTrue, KindString: - return err - default: - // This probably can't happen - return cmp.Or(err, fmt.Errorf("unexpected token: %s", tok)) - } -} - -// Deprecated: Use the new Canonicalize or Marshal functions -func CanonicalJSONAssumeValid(input []byte) []byte { - out, _ := CanonicalJSON(input) - return out -} - -// Deprecated: Use the new Canonicalize or Marshal functions -func CanonicalJSON(input []byte) ([]byte, error) { - out := jsontext.Value(bytes.Clone(input)) - err := Canonicalize(&out) - return out, err -} diff --git a/mautrix-patched/crypto/canonicaljson/jsonv2_test.go b/mautrix-patched/crypto/canonicaljson/jsonv2_test.go deleted file mode 100644 index 1d169b77..00000000 --- a/mautrix-patched/crypto/canonicaljson/jsonv2_test.go +++ /dev/null @@ -1,364 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package canonicaljson_test - -import ( - "encoding/json/jsontext" - "encoding/json/v2" - "math" - "os/exec" - "runtime" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/canonicaljson" -) - -var canonicalizeTests = []struct { - name string - input string - want string -}{ - {"empty object", "{}", "{}"}, - {"reorder in array", `[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`}, - {"nested object reorder", `{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`}, - {"array with primitives", `[true,false,null]`, `[true,false,null]`}, - {"array with big number", `[9007199254740991]`, `[9007199254740991]`}, - {"array with small number", `[-9007199254740991]`, `[-9007199254740991]`}, - {"extra whitespace", "\t\n[9007199254740991]", `[9007199254740991]`}, - {"ascii 0-7", `"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007"`, `"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007"`}, - {"ascii 8-f expanded", `"\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F"`, `"\b\t\n\u000b\f\r\u000e\u000f"`}, - {"ascii 8-f already correct", `"\b\t\n\u000b\f\r\u000e\u000f"`, `"\b\t\n\u000b\f\r\u000e\u000f"`}, - {"ascii 10-17", `"\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017"`, `"\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017"`}, - {"ascii 18-1f", `"\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"`, `"\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f"`}, - {"quote and backslash expanded", `["\u0061\u005C\u0042\u0022"]`, `["a\\B\""]`}, - {"quote and backslash already correct", `["a\\B\""]`, `["a\\B\""]`}, - {"misc unicode escapes", `"\u0120\u0FFF\u1820\uFFFF"`, "\"\u0120\u0FFF\u1820\uFFFF\""}, - {"utf-16 surrogates", `["\uD842\uDC20", "\uDBFF\uDFFF"]`, "[\"\U00020820\",\"\U0010FFFF\"]"}, - {"escaped slash", `{"a": "\/"}`, `{"a":"/"}`}, - {"1.0", `{"a": 1.0}`, `{"a":1}`}, - {"0.0", `{"a": 0.0}`, `{"a":0}`}, - {"-0.0", `{"a": -0.0}`, `{"a":0}`}, - {"1e10", `{"a": 1e10}`, `{"a":10000000000}`}, - {"sorting with backslashes", `{"\"": 4, "\\": 5, "\n": 3, "\r": 2, "\u0000":1}`, `{"\u0000":1,"\n":3,"\r":2,"\"":4,"\\":5}`}, - { - "sorting where utf-8 and utf-16 disagree", - `{"\ud83d\udc31": "meow", "\ud800\udc00": {"\ud800\udc00": "hmm1", "\uFFFF": "meowo1", "\ud800\udc01": "hmm2", "\uEFFF": "meowo2"}, "\uf123": "woof"}`, - "{\"\uf123\":\"woof\",\"\U00010000\":{\"\uEFFF\":\"meowo2\",\"\uFFFF\":\"meowo1\",\"\U00010000\":\"hmm1\",\"\U00010001\":\"hmm2\"},\"\U0001F431\":\"meow\"}", - }, - - // Examples from the Matrix Canonical JSON spec - // (https://spec.matrix.org/v1.18/appendices/#canonical-json). - {"spec: simple", `{"one": 1, "two": "Two"}`, `{"one":1,"two":"Two"}`}, - {"spec: simple sort", `{"b": "2", "a": "1"}`, `{"a":"1","b":"2"}`}, - {"spec: simple sort minified", `{"b":"2","a":"1"}`, `{"a":"1","b":"2"}`}, - { - "spec: complex nested", - `{ - "auth": { - "success": true, - "mxid": "@john.doe:example.com", - "profile": { - "display_name": "John Doe", - "three_pids": [ - { - "medium": "email", - "address": "john.doe@example.org" - }, - { - "medium": "msisdn", - "address": "123456789" - } - ] - } - } -}`, - `{"auth":{"mxid":"@john.doe:example.com","profile":{"display_name":"John Doe","three_pids":[{"address":"john.doe@example.org","medium":"email"},{"address":"123456789","medium":"msisdn"}]},"success":true}}`, - }, - {"spec: japanese value", `{"a": "\u65e5\u672c\u8a9e"}`, "{\"a\":\"\u65e5\u672c\u8a9e\"}"}, - {"spec: japanese keys sort", `{"\u672c": 2, "\u65e5": 1}`, "{\"\u65e5\":1,\"\u672c\":2}"}, - {"spec: unicode escape to japanese", `{"a": "\u65e5"}`, "{\"a\":\"\u65e5\"}"}, - {"spec: null value", `{"a": null}`, `{"a":null}`}, - {"spec: -0 and 1e10", `{"a": -0, "b": 1e10}`, `{"a":0,"b":10000000000}`}, - - // Top-level non-object values. - {"top-level null", `null`, `null`}, - {"top-level true", `true`, `true`}, - {"top-level false", `false`, `false`}, - {"top-level number", `42`, `42`}, - {"top-level negative number", `-42`, `-42`}, - {"top-level string", `"hello"`, `"hello"`}, - {"top-level zero", `0`, `0`}, - - // Empty/single containers. - {"empty array", `[]`, `[]`}, - {"single element array", `[1]`, `[1]`}, - {"single key object", `{"a": 1}`, `{"a":1}`}, - {"empty key", `{"": 1}`, `{"":1}`}, - {"empty key empty value", `{"": ""}`, `{"":""}`}, - {"empty object as value", `{"a":{}}`, `{"a":{}}`}, - {"empty array as value", `{"a":[]}`, `{"a":[]}`}, - - // Number edge cases that exercise the int53 boundary and the - // canonicalization of various numeric representations. - {"integer 0", `{"a": 0}`, `{"a":0}`}, - {"integer -0", `{"a": -0}`, `{"a":0}`}, - {"integer 0e0", `{"a": 0e0}`, `{"a":0}`}, - {"integer -0e0", `{"a": -0e0}`, `{"a":0}`}, - {"integer 0e10", `{"a": 0e10}`, `{"a":0}`}, - {"max safe integer", `{"a": 9007199254740991}`, `{"a":9007199254740991}`}, - {"min safe integer", `{"a": -9007199254740991}`, `{"a":-9007199254740991}`}, - {"max safe as float", `{"a": 9007199254740991.0}`, `{"a":9007199254740991}`}, - {"max safe as scientific", `{"a": 9.007199254740991e15}`, `{"a":9007199254740991}`}, - {"1e15 (within range)", `{"a": 1e15}`, `{"a":1000000000000000}`}, - {"1.5e2 to integer 150", `{"a": 1.5e2}`, `{"a":150}`}, - {"1e2 to integer 100", `{"a": 1e2}`, `{"a":100}`}, - {"explicit positive exponent", `{"a": 1.5e+2}`, `{"a":150}`}, - {"integer with negative exponent", `{"a": 10e-1}`, `{"a":1}`}, - {"max safe integer with negative exponent", `{"a": 90071992547409910e-1}`, `{"a":9007199254740991}`}, - {"zero with huge negative exponent", `{"a": 0e-1000}`, `{"a":0}`}, - {"tiny exponent underflows to zero", `{"a": 1e-1000}`, `{"a":0}`}, - {"negative tiny exponent underflows to zero", `{"a": -1e-1000}`, `{"a":0}`}, - {"fraction rounds to one", `{"a": 0.99999999999999999}`, `{"a":1}`}, - {"fraction rounds down to max safe", `{"a": 9007199254740991.1}`, `{"a":9007199254740991}`}, - {"fraction rounds down within range", `{"a": 9007199254740990.5}`, `{"a":9007199254740990}`}, - {"fraction rounds up within range", `{"a": 999999999999999.99}`, `{"a":1000000000000000}`}, - {"scientific fraction near max safe", `{"a": 9.0071992547409911e15}`, `{"a":9007199254740991}`}, - - // Sorting edge cases. UTF-8 byte sort means uppercase precedes lowercase, - // and shorter prefixes precede longer strings sharing those prefixes. - {"prefix keys", `{"abc":1,"ab":2,"a":3}`, `{"a":3,"ab":2,"abc":1}`}, - {"case-sensitive sort", `{"a":1,"A":2}`, `{"A":2,"a":1}`}, - {"numeric string keys lex", `{"10":1,"2":2,"1":3}`, `{"1":3,"10":1,"2":2}`}, - {"three keys reverse", `{"c":3,"b":2,"a":1}`, `{"a":1,"b":2,"c":3}`}, - {"first element stays first", `{"a":1,"c":3,"b":2}`, `{"a":1,"b":2,"c":3}`}, - {"varying member sizes", `{"x":111111,"a":1,"m":22}`, `{"a":1,"m":22,"x":111111}`}, - {"escaped slash key", `{"b":2,"\/":1}`, `{"/":1,"b":2}`}, - - // Nested structures. - {"nested arrays with sort", `[[1,2],[3,{"b":2,"a":1}]]`, `[[1,2],[3,{"a":1,"b":2}]]`}, - {"deeply nested", `{"a":{"a":{"a":{"a":{"a":1}}}}}`, `{"a":{"a":{"a":{"a":{"a":1}}}}}`}, - - // Strings. - {"non-BMP value as escapes", `{"a":"\ud83d\udc31"}`, "{\"a\":\"\xf0\x9f\x90\xb1\"}"}, - {"non-BMP value raw", "{\"a\":\"\xf0\x9f\x90\xb1\"}", "{\"a\":\"\xf0\x9f\x90\xb1\"}"}, - {"null byte in value", `{"a":"\u0000"}`, `{"a":"\u0000"}`}, - {"DEL char raw 0x7f", "{\"a\":\"\x7f\"}", "{\"a\":\"\x7f\"}"}, - {"DEL char as escape", `{"a":"\u007f"}`, "{\"a\":\"\x7f\"}"}, -} - -func TestCanonicalize(t *testing.T) { - for _, test := range canonicalizeTests { - t.Run(test.name, func(t *testing.T) { - val := jsontext.Value(test.input) - err := canonicaljson.Canonicalize(&val) - assert.NoError(t, err) - assert.Equal(t, test.want, string(val)) - t.Run("python", func(t *testing.T) { - checkPython(t, string(val)) - }) - }) - } -} - -const pythonCanonicalJSON = `import json, sys; print(json.dumps(json.loads(sys.argv[1]), allow_nan=False, ensure_ascii=False, separators=(',',':'),sort_keys=True))` - -var python3, _ = exec.LookPath("python3") - -func checkPython(t *testing.T, val string) { - if python3 == "" { - t.SkipNow() - } - out, err := exec.CommandContext(t.Context(), python3, "-c", pythonCanonicalJSON, val).Output() - require.NoError(t, err) - assert.Equal(t, val, strings.TrimSuffix(string(out), "\n")) -} - -// Unmarshal preserves negative zeroes, so Marshal will reject it, -// while Canonicalize will accept it and convert to a plain zero. -// Both behaviors are acceptable, so skip tests for that here. -var roundtripSkip = map[string]bool{ - "-0.0": true, - "integer -0": true, - "integer -0e0": true, - "negative tiny exponent underflows to zero": true, - "spec: -0 and 1e10": true, -} - -func TestMarshal_Roundtrip(t *testing.T) { - for _, test := range canonicalizeTests { - t.Run(test.name, func(t *testing.T) { - if roundtripSkip[test.name] { - t.SkipNow() - } - var temp any - require.NoError(t, json.Unmarshal([]byte(test.input), &temp)) - val, err := canonicaljson.Marshal(temp) - require.NoError(t, err) - assert.Equal(t, test.want, string(val)) - }) - } -} - -type m = map[string]any - -type marshalStructInner struct { - B int `json:"b"` - A int `json:"a"` -} - -type marshalStruct struct { - Z int `json:"z"` - Inner marshalStructInner `json:"inner"` - Name string `json:"name"` -} - -func TestMarshal(t *testing.T) { - var tests = []struct { - name string - input any - want string - }{ - {"empty object", m{}, `{}`}, - {"simple types", m{"c": nil, "d": "foo", "e": 1234, "a": true, "b": false}, `{"a":true,"b":false,"c":null,"d":"foo","e":1234}`}, - {"nil", nil, `null`}, - {"top-level true", true, `true`}, - {"top-level false", false, `false`}, - {"top-level int", 42, `42`}, - {"top-level string", "hello", `"hello"`}, - {"empty slice", []int{}, `[]`}, - {"nil slice", []int(nil), `[]`}, - {"nil map", map[string]int(nil), `{}`}, - {"slice of maps", []m{{"b": 1, "a": 2}, {"d": 3, "c": 4}}, `[{"a":2,"b":1},{"c":4,"d":3}]`}, - {"nested map", m{"b": m{"y": 1, "x": 2}, "a": 5}, `{"a":5,"b":{"x":2,"y":1}}`}, - {"struct with json tags", marshalStruct{Z: 1, Inner: marshalStructInner{B: 2, A: 3}, Name: "test"}, `{"inner":{"a":3,"b":2},"name":"test","z":1}`}, - {"mixed any slice", []any{nil, true, false, 1, "hi"}, `[null,true,false,1,"hi"]`}, - {"max safe int64", int64(9007199254740991), `9007199254740991`}, - {"min safe int64", int64(-9007199254740991), `-9007199254740991`}, - {"max safe uint64", uint64(9007199254740991), `9007199254740991`}, - {"japanese keys", m{"本": 2, "日": 1}, "{\"日\":1,\"本\":2}"}, - {"non-bmp value", m{"a": "\U0001F431"}, "{\"a\":\"\U0001F431\"}"}, - {"int 0", 0, `0`}, - {"int -1", -1, `-1`}, - {"float that is integer", 1.0, `1`}, - {"float 1e10", 1e10, `10000000000`}, - {"empty struct", struct{}{}, `{}`}, - {"raw unknown value", unknownRaw{Foo: "meow", Unknown: jsontext.Value(`{ "hello": "world" } `)}, `{"foo":"meow","hello":"world"}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := canonicaljson.Marshal(test.input) - assert.NoError(t, err) - assert.Equal(t, test.want, string(got)) - }) - } -} - -var canonicalizeErrorTests = []struct { - name string - input string -}{ - {"duplicate keys", `{"a":1,"a":2}`}, - {"escape-equivalent duplicate keys", `{"a":1,"\u0061":2}`}, - {"invalid UTF-8", "\"\xff\xfe\xfd\""}, - {"uppercase \\b", `"\B"`}, - {"uppercase \\t", `"\T"`}, - {"uppercase \\n", `"\N"`}, - {"uppercase \\f", `"\F"`}, - {"uppercase \\r", `"\R"`}, - {"lone surrogate", `"\uD800"`}, - {"lone trailing surrogate", `"\uDC00"`}, - {"floating point number", `{"a": 1.2}`}, - {"plain decimal 0.1", `{"a": 0.1}`}, - {"non-integer that rounds out of range", `{"a": 9007199254740991.5}`}, - {"too large number", `{"a": 9007199254740992}`}, - {"too small number", `{"a": -9007199254740992}`}, - {"zero-prefixed negative number", `{"a": -010}`}, - {"zero-prefixed number", `{"a": 010}`}, - {"big exponent number", `{"a": 1e100}`}, - {"too large via 1e16", `{"a": 1e16}`}, - {"too large via negative exponent", `{"a": 90071992547409920e-1}`}, - {"non-integer small exponent", `{"a": 1e-10}`}, - {"smallest subnormal float", `{"a": 5e-324}`}, - {"trailing comma", `{"a":1,}`}, - {"JS-style comment", `{/*hi*/"a":1}`}, - {"NaN literal", `{"a": NaN}`}, - {"Infinity literal", `{"a": Infinity}`}, - {"unquoted key", `{a:1}`}, - {"trailing data", `{}{}`}, - {"empty input", ``}, -} - -func TestCanonicalize_Error(t *testing.T) { - for _, test := range canonicalizeErrorTests { - t.Run(test.name, func(t *testing.T) { - val := jsontext.Value(test.input) - err := canonicaljson.Canonicalize(&val) - assert.Error(t, err, "Expected error, got %s instead", val) - }) - } -} -func TestMarshal_Roundtrip_Error(t *testing.T) { - for _, test := range canonicalizeErrorTests { - t.Run(test.name, func(t *testing.T) { - var temp any - err := json.Unmarshal([]byte(test.input), &temp) - if err != nil { - return // error during unmarshal is fine - } - out, err := canonicaljson.Marshal(temp) - assert.Error(t, err, "Expected error, got %s instead", out) - }) - } -} - -type unknownMap struct { - Foo string `json:"foo"` - Unknown map[string]any `json:",unknown"` -} - -type unknownRaw struct { - Foo string `json:"foo"` - Unknown jsontext.Value `json:",unknown"` -} - -func TestMarshal_Error(t *testing.T) { - var tests = []struct { - name string - input any - }{ - {"invalid UTF-8", m{"a": "\xff\xfe\xfd"}}, - {"floating point number", m{"a": 1.2}}, - {"too large number", m{"a": 9007199254740992}}, - {"too small number", m{"a": -9007199254740992}}, - {"big exponent number", m{"a": 1e100}}, - {"negative zero", m{"a": math.Copysign(0, -1)}}, - {"NaN", m{"a": math.NaN()}}, - {"+Inf", m{"a": math.Inf(1)}}, - {"-Inf", m{"a": math.Inf(-1)}}, - {"non-integer float", m{"a": 1.5}}, - {"uint64 way too large", m{"a": ^uint64(0)}}, - {"duplicate key in map unknown", unknownMap{Foo: "meow", Unknown: map[string]any{"foo": "woof"}}}, - {"duplicate key in raw unknown", unknownRaw{Foo: "meow", Unknown: jsontext.Value(`{"foo": "woof"}`)}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // Go 1.25 emitted NaN and infinities as strings - // TODO remove this check after dropping Go 1.25 support - if strings.HasPrefix(runtime.Version(), "go1.25") && (test.name == "NaN" || test.name == "+Inf" || test.name == "-Inf") { - t.SkipNow() - } - out, err := canonicaljson.Marshal(test.input) - assert.Error(t, err, "Expected error, got %s instead", out) - }) - } -} diff --git a/mautrix-patched/crypto/cross_sign_key.go b/mautrix-patched/crypto/cross_sign_key.go deleted file mode 100644 index 5d9bf5b3..00000000 --- a/mautrix-patched/crypto/cross_sign_key.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "fmt" - - "go.mau.fi/util/jsonbytes" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/id" -) - -// CrossSigningKeysCache holds the three cross-signing keys for the current user. -type CrossSigningKeysCache struct { - MasterKey olm.PKSigning - SelfSigningKey olm.PKSigning - UserSigningKey olm.PKSigning -} - -func (cskc *CrossSigningKeysCache) PublicKeys() *CrossSigningPublicKeysCache { - return &CrossSigningPublicKeysCache{ - MasterKey: cskc.MasterKey.PublicKey(), - SelfSigningKey: cskc.SelfSigningKey.PublicKey(), - UserSigningKey: cskc.UserSigningKey.PublicKey(), - } -} - -type CrossSigningSeeds struct { - MasterKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.master"` - SelfSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.self_signing"` - UserSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.user_signing"` -} - -func (mach *OlmMachine) ExportCrossSigningKeys() CrossSigningSeeds { - return CrossSigningSeeds{ - MasterKey: mach.CrossSigningKeys.MasterKey.Seed(), - SelfSigningKey: mach.CrossSigningKeys.SelfSigningKey.Seed(), - UserSigningKey: mach.CrossSigningKeys.UserSigningKey.Seed(), - } -} - -func (mach *OlmMachine) ImportCrossSigningKeys(keys CrossSigningSeeds) (err error) { - var keysCache CrossSigningKeysCache - if keysCache.MasterKey, err = olm.NewPKSigningFromSeed(keys.MasterKey); err != nil { - return - } - if keysCache.SelfSigningKey, err = olm.NewPKSigningFromSeed(keys.SelfSigningKey); err != nil { - return - } - if keysCache.UserSigningKey, err = olm.NewPKSigningFromSeed(keys.UserSigningKey); err != nil { - return - } - - mach.Log.Debug(). - Str("master", keysCache.MasterKey.PublicKey().String()). - Str("self_signing", keysCache.SelfSigningKey.PublicKey().String()). - Str("user_signing", keysCache.UserSigningKey.PublicKey().String()). - Msg("Imported own cross-signing keys") - - mach.CrossSigningKeys = &keysCache - mach.crossSigningPubkeys = keysCache.PublicKeys() - return -} - -// GenerateCrossSigningKeys generates new cross-signing keys. -func (mach *OlmMachine) GenerateCrossSigningKeys() (*CrossSigningKeysCache, error) { - var keysCache CrossSigningKeysCache - var err error - if keysCache.MasterKey, err = olm.NewPKSigning(); err != nil { - return nil, fmt.Errorf("failed to generate master key: %w", err) - } - if keysCache.SelfSigningKey, err = olm.NewPKSigning(); err != nil { - return nil, fmt.Errorf("failed to generate self-signing key: %w", err) - } - if keysCache.UserSigningKey, err = olm.NewPKSigning(); err != nil { - return nil, fmt.Errorf("failed to generate user-signing key: %w", err) - } - mach.Log.Debug(). - Str("master", keysCache.MasterKey.PublicKey().String()). - Str("self_signing", keysCache.SelfSigningKey.PublicKey().String()). - Str("user_signing", keysCache.UserSigningKey.PublicKey().String()). - Msg("Generated cross-signing keys") - return &keysCache, nil -} - -// PublishCrossSigningKeys signs and uploads the public keys of the given cross-signing keys to the server. -func (mach *OlmMachine) PublishCrossSigningKeys(ctx context.Context, keys *CrossSigningKeysCache, uiaCallback mautrix.UIACallback) error { - userID := mach.Client.UserID - masterKeyID := id.NewKeyID(id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String()) - masterKey := mautrix.CrossSigningKeys{ - UserID: userID, - Usage: []id.CrossSigningUsage{id.XSUsageMaster}, - Keys: map[id.KeyID]id.Ed25519{ - masterKeyID: keys.MasterKey.PublicKey(), - }, - } - masterSig, err := mach.account.SignJSON(masterKey) - if err != nil { - return fmt.Errorf("failed to sign master key: %w", err) - } - masterKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, mach.Client.DeviceID.String(), masterSig) - - selfKey := mautrix.CrossSigningKeys{ - UserID: userID, - Usage: []id.CrossSigningUsage{id.XSUsageSelfSigning}, - Keys: map[id.KeyID]id.Ed25519{ - id.NewKeyID(id.KeyAlgorithmEd25519, keys.SelfSigningKey.PublicKey().String()): keys.SelfSigningKey.PublicKey(), - }, - } - selfSig, err := keys.MasterKey.SignJSON(selfKey) - if err != nil { - return fmt.Errorf("failed to sign self-signing key: %w", err) - } - selfKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), selfSig) - - userKey := mautrix.CrossSigningKeys{ - UserID: userID, - Usage: []id.CrossSigningUsage{id.XSUsageUserSigning}, - Keys: map[id.KeyID]id.Ed25519{ - id.NewKeyID(id.KeyAlgorithmEd25519, keys.UserSigningKey.PublicKey().String()): keys.UserSigningKey.PublicKey(), - }, - } - userSig, err := keys.MasterKey.SignJSON(userKey) - if err != nil { - return fmt.Errorf("failed to sign user-signing key: %w", err) - } - userKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), userSig) - - err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq[any]{ - Master: masterKey, - SelfSigning: selfKey, - UserSigning: userKey, - }, uiaCallback) - if err != nil { - return err - } - - if err := mach.CryptoStore.PutSignature(ctx, userID, keys.MasterKey.PublicKey(), userID, mach.account.SigningKey(), masterSig); err != nil { - return fmt.Errorf("error storing signature of master key by device signing key in crypto store: %w", err) - } - if err := mach.CryptoStore.PutSignature(ctx, userID, keys.SelfSigningKey.PublicKey(), userID, keys.MasterKey.PublicKey(), selfSig); err != nil { - return fmt.Errorf("error storing signature of self-signing key by master key in crypto store: %w", err) - } - if err := mach.CryptoStore.PutSignature(ctx, userID, keys.UserSigningKey.PublicKey(), userID, keys.MasterKey.PublicKey(), userSig); err != nil { - return fmt.Errorf("error storing signature of user-signing key by master key in crypto store: %w", err) - } - - mach.CrossSigningKeys = keys - mach.crossSigningPubkeys = keys.PublicKeys() - - return nil -} diff --git a/mautrix-patched/crypto/cross_sign_pubkey.go b/mautrix-patched/crypto/cross_sign_pubkey.go deleted file mode 100644 index 63f660e5..00000000 --- a/mautrix-patched/crypto/cross_sign_pubkey.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "fmt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" -) - -type CrossSigningPublicKeysCache struct { - MasterKey id.Ed25519 - SelfSigningKey id.Ed25519 - UserSigningKey id.Ed25519 -} - -func (mach *OlmMachine) GetOwnCrossSigningVerificationStatus(ctx context.Context) (masterKeyVerified, selfSigningKeyVerified, userSigningKeyVerified bool, err error) { - var pubkeys *CrossSigningPublicKeysCache - pubkeys, err = mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil || pubkeys == nil { - return - } - // If we have the private keys, it means we trust the public keys too - if mach.CrossSigningKeys != nil && *mach.CrossSigningKeys.PublicKeys() == *pubkeys { - return true, true, true, nil - } - masterKeyVerified, err = mach.CryptoStore.IsKeySignedBy( - ctx, mach.Client.UserID, pubkeys.MasterKey, mach.Client.UserID, mach.account.SigningKey(), - ) - if err != nil { - err = fmt.Errorf("failed to check if master key is signed by current device key: %w", err) - return - } - selfSigningKeyVerified, err = mach.CryptoStore.IsKeySignedBy( - ctx, mach.Client.UserID, pubkeys.SelfSigningKey, mach.Client.UserID, pubkeys.MasterKey, - ) - if err != nil { - err = fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err) - return - } - userSigningKeyVerified, err = mach.CryptoStore.IsKeySignedBy( - ctx, mach.Client.UserID, pubkeys.UserSigningKey, mach.Client.UserID, pubkeys.MasterKey, - ) - if err != nil { - err = fmt.Errorf("failed to check if user-signing key is signed by master key: %w", err) - } - return -} - -func (mach *OlmMachine) GetOwnVerificationStatus(ctx context.Context) (hasKeys, isVerified bool, err error) { - var pubkeys *CrossSigningPublicKeysCache - pubkeys, err = mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil || pubkeys == nil { - return - } - hasKeys = true - isVerified, err = mach.CryptoStore.IsKeySignedBy( - ctx, mach.Client.UserID, mach.GetAccount().SigningKey(), mach.Client.UserID, pubkeys.SelfSigningKey, - ) - if err != nil { - err = fmt.Errorf("failed to check if current device is signed by own self-signing key: %w", err) - } - return -} - -func (mach *OlmMachine) GetOwnCrossSigningPublicKeys(ctx context.Context) (*CrossSigningPublicKeysCache, error) { - if mach.crossSigningPubkeys != nil { - return mach.crossSigningPubkeys, nil - } - if mach.CrossSigningKeys != nil { - mach.crossSigningPubkeys = mach.CrossSigningKeys.PublicKeys() - return mach.crossSigningPubkeys, nil - } - if mach.crossSigningPubkeysFetched { - return nil, nil - } - cspk, err := mach.GetCrossSigningPublicKeys(ctx, mach.Client.UserID) - if err != nil { - return nil, err - } - mach.crossSigningPubkeys = cspk - mach.crossSigningPubkeysFetched = true - return mach.crossSigningPubkeys, nil -} - -func (mach *OlmMachine) GetCrossSigningPublicKeys(ctx context.Context, userID id.UserID) (*CrossSigningPublicKeysCache, error) { - dbKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to get keys from database: %w", err) - } - if len(dbKeys) > 0 { - masterKey, ok := dbKeys[id.XSUsageMaster] - if ok { - selfSigning := dbKeys[id.XSUsageSelfSigning] - userSigning := dbKeys[id.XSUsageUserSigning] - return &CrossSigningPublicKeysCache{ - MasterKey: masterKey.Key, - SelfSigningKey: selfSigning.Key, - UserSigningKey: userSigning.Key, - }, nil - } - } - - keys, err := mach.Client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ - DeviceKeys: mautrix.DeviceKeysRequest{ - userID: mautrix.DeviceIDList{}, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to query keys: %w", err) - } - - var cspk CrossSigningPublicKeysCache - - masterKeys, ok := keys.MasterKeys[userID] - if !ok { - return nil, nil - } - cspk.MasterKey = masterKeys.FirstKey() - - selfSigningKeys, ok := keys.SelfSigningKeys[userID] - if !ok { - return nil, nil - } - cspk.SelfSigningKey = selfSigningKeys.FirstKey() - - userSigningKeys, ok := keys.UserSigningKeys[userID] - if ok { - cspk.UserSigningKey = userSigningKeys.FirstKey() - } - return &cspk, nil -} diff --git a/mautrix-patched/crypto/cross_sign_signing.go b/mautrix-patched/crypto/cross_sign_signing.go deleted file mode 100644 index 4dead803..00000000 --- a/mautrix-patched/crypto/cross_sign_signing.go +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "errors" - "fmt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/id" -) - -var ( - ErrCrossSigningPubkeysNotCached = errors.New("cross-signing public keys not in cache") - ErrUserSigningKeyNotCached = errors.New("user-signing private key not in cache") - ErrSelfSigningKeyNotCached = errors.New("self-signing private key not in cache") - ErrSignatureUploadFail = errors.New("server-side failure uploading signatures") - ErrCantSignOwnMasterKey = errors.New("signing your own master key is not allowed") - ErrCantSignOtherDevice = errors.New("signing other users' devices is not allowed") - ErrUserNotInQueryResponse = errors.New("could not find user in query keys response") - ErrDeviceNotInQueryResponse = errors.New("could not find device in query keys response") - ErrOlmAccountNotLoaded = errors.New("olm account has not been loaded") - - ErrCrossSigningMasterKeyNotFound = errors.New("cross-signing master key not found") - ErrMasterKeyMACNotFound = errors.New("found cross-signing master key, but didn't find corresponding MAC in verification request") - ErrMismatchingMasterKeyMAC = errors.New("mismatching cross-signing master key MAC") -) - -// SignUser creates a cross-signing signature for a user, stores it and uploads it to the server. -func (mach *OlmMachine) SignUser(ctx context.Context, userID id.UserID, masterKey id.Ed25519) error { - if userID == mach.Client.UserID { - return ErrCantSignOwnMasterKey - } else if mach.CrossSigningKeys == nil || mach.CrossSigningKeys.UserSigningKey == nil { - return ErrUserSigningKeyNotCached - } - - masterKeyObj := mautrix.ReqKeysSignatures{ - UserID: userID, - Usage: []id.CrossSigningUsage{id.XSUsageMaster}, - Keys: map[id.KeyID]string{ - id.NewKeyID(id.KeyAlgorithmEd25519, masterKey.String()): masterKey.String(), - }, - } - - signature, err := mach.signAndUpload(ctx, masterKeyObj, userID, masterKey.String(), mach.CrossSigningKeys.UserSigningKey) - if err != nil { - return err - } - - mach.Log.Debug(). - Str("user_id", userID.String()). - Str("signature", signature). - Msg("Signed master key of user with our user-signing key") - - if err := mach.CryptoStore.PutSignature(ctx, userID, masterKey, mach.Client.UserID, mach.CrossSigningKeys.UserSigningKey.PublicKey(), signature); err != nil { - return fmt.Errorf("error storing signature in crypto store: %w", err) - } - - return nil -} - -// SignOwnMasterKey uses the current account for signing the current user's master key and uploads the signature. -func (mach *OlmMachine) SignOwnMasterKey(ctx context.Context) error { - crossSigningPubkeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } else if crossSigningPubkeys == nil { - return ErrCrossSigningPubkeysNotCached - } else if mach.account == nil { - return ErrOlmAccountNotLoaded - } - - userID := mach.Client.UserID - deviceID := mach.Client.DeviceID - masterKey := crossSigningPubkeys.MasterKey - - masterKeyObj := mautrix.ReqKeysSignatures{ - UserID: userID, - Usage: []id.CrossSigningUsage{id.XSUsageMaster}, - Keys: map[id.KeyID]string{ - id.NewKeyID(id.KeyAlgorithmEd25519, masterKey.String()): masterKey.String(), - }, - } - signature, err := mach.account.SignJSON(masterKeyObj) - if err != nil { - return fmt.Errorf("failed to sign JSON: %w", err) - } - masterKeyObj.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, deviceID.String(), signature) - mach.Log.Debug(). - Str("device_id", deviceID.String()). - Str("signature", signature). - Msg("Signed own master key with own device key") - - resp, err := mach.Client.UploadSignatures(ctx, &mautrix.ReqUploadSignatures{ - userID: map[string]mautrix.ReqKeysSignatures{ - masterKey.String(): masterKeyObj, - }, - }) - - if err != nil { - return fmt.Errorf("error while uploading signatures: %w", err) - } else if len(resp.Failures) > 0 { - return fmt.Errorf("%w: %+v", ErrSignatureUploadFail, resp.Failures) - } - - if err := mach.CryptoStore.PutSignature(ctx, userID, masterKey, userID, mach.account.SigningKey(), signature); err != nil { - return fmt.Errorf("error storing signature in crypto store: %w", err) - } - - return nil -} - -// SignOwnDevice creates a cross-signing signature for a device belonging to the current user and uploads it to the server. -func (mach *OlmMachine) SignOwnDevice(ctx context.Context, device *id.Device) error { - if device.UserID != mach.Client.UserID { - return ErrCantSignOtherDevice - } else if mach.CrossSigningKeys == nil || mach.CrossSigningKeys.SelfSigningKey == nil { - return ErrSelfSigningKeyNotCached - } - - deviceKeys, err := mach.getFullDeviceKeys(ctx, device) - if err != nil { - return err - } - - deviceKeyObj := mautrix.ReqKeysSignatures{ - UserID: device.UserID, - DeviceID: device.DeviceID, - Algorithms: deviceKeys.Algorithms, - Keys: make(map[id.KeyID]string), - } - for keyID, key := range deviceKeys.Keys { - deviceKeyObj.Keys[id.KeyID(keyID)] = key - } - - signature, err := mach.signAndUpload(ctx, deviceKeyObj, device.UserID, device.DeviceID.String(), mach.CrossSigningKeys.SelfSigningKey) - if err != nil { - return err - } - - mach.Log.Debug(). - Str("user_id", device.UserID.String()). - Str("device_id", device.DeviceID.String()). - Str("signature", signature). - Msg("Signed own device key with self-signing key") - - if err := mach.CryptoStore.PutSignature(ctx, device.UserID, device.SigningKey, mach.Client.UserID, mach.CrossSigningKeys.SelfSigningKey.PublicKey(), signature); err != nil { - return fmt.Errorf("error storing signature in crypto store: %w", err) - } - - return nil -} - -// getFullDeviceKeys gets the full device keys object for the given device. -// This is used because we don't cache some of the details like list of algorithms and unsupported key types. -func (mach *OlmMachine) getFullDeviceKeys(ctx context.Context, device *id.Device) (*mautrix.DeviceKeys, error) { - devicesKeys, err := mach.Client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ - DeviceKeys: mautrix.DeviceKeysRequest{ - device.UserID: mautrix.DeviceIDList{device.DeviceID}, - }, - }) - if err != nil { - return nil, fmt.Errorf("error querying device keys for %s: %w", device.DeviceID, err) - } - userKeys, ok := devicesKeys.DeviceKeys[device.UserID] - if !ok { - return nil, ErrUserNotInQueryResponse - } - deviceKeys, ok := userKeys[device.DeviceID] - if !ok { - return nil, ErrDeviceNotInQueryResponse - } - _, err = mach.validateDevice(device.UserID, device.DeviceID, deviceKeys, device) - return &deviceKeys, err -} - -// signAndUpload signs the given key signatures object and uploads it to the server. -func (mach *OlmMachine) signAndUpload(ctx context.Context, req mautrix.ReqKeysSignatures, userID id.UserID, signedThing string, key olm.PKSigning) (string, error) { - signature, err := key.SignJSON(req) - if err != nil { - return "", fmt.Errorf("failed to sign JSON: %w", err) - } - req.Signatures = signatures.NewSingleSignature(mach.Client.UserID, id.KeyAlgorithmEd25519, key.PublicKey().String(), signature) - - resp, err := mach.Client.UploadSignatures(ctx, &mautrix.ReqUploadSignatures{ - userID: map[string]mautrix.ReqKeysSignatures{ - signedThing: req, - }, - }) - if err != nil { - return "", fmt.Errorf("error while uploading signatures: %w", err) - } else if len(resp.Failures) > 0 { - return "", fmt.Errorf("%w: %+v", ErrSignatureUploadFail, resp.Failures) - } - return signature, nil -} diff --git a/mautrix-patched/crypto/cross_sign_ssss.go b/mautrix-patched/crypto/cross_sign_ssss.go deleted file mode 100644 index fd42880d..00000000 --- a/mautrix-patched/crypto/cross_sign_ssss.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "errors" - "fmt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/ssss" - "maunium.net/go/mautrix/crypto/utils" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// FetchCrossSigningKeysFromSSSS fetches all the cross-signing keys from SSSS, decrypts them using the given key and stores them in the olm machine. -func (mach *OlmMachine) FetchCrossSigningKeysFromSSSS(ctx context.Context, key *ssss.Key) error { - masterKey, err := mach.retrieveDecryptXSigningKey(ctx, event.AccountDataCrossSigningMaster, key) - if err != nil { - return err - } - selfSignKey, err := mach.retrieveDecryptXSigningKey(ctx, event.AccountDataCrossSigningSelf, key) - if err != nil { - return err - } - userSignKey, err := mach.retrieveDecryptXSigningKey(ctx, event.AccountDataCrossSigningUser, key) - if err != nil { - return err - } - - return mach.ImportCrossSigningKeys(CrossSigningSeeds{ - MasterKey: masterKey[:], - SelfSigningKey: selfSignKey[:], - UserSigningKey: userSignKey[:], - }) -} - -// retrieveDecryptXSigningKey retrieves the requested cross-signing key from SSSS and decrypts it using the given SSSS key. -func (mach *OlmMachine) retrieveDecryptXSigningKey(ctx context.Context, keyName event.Type, key *ssss.Key) ([utils.AESCTRKeyLength]byte, error) { - var decryptedKey [utils.AESCTRKeyLength]byte - var encData ssss.EncryptedAccountDataEventContent - - // retrieve and parse the account data for this key type from SSSS - err := mach.Client.GetAccountData(ctx, keyName.Type, &encData) - if err != nil { - return decryptedKey, err - } - - decrypted, err := encData.Decrypt(keyName.Type, key) - if err != nil { - return decryptedKey, err - } - copy(decryptedKey[:], decrypted) - return decryptedKey, nil -} - -func (mach *OlmMachine) GenerateAndUploadCrossSigningKeysWithPassword(ctx context.Context, userPassword, passphrase string) (string, *CrossSigningKeysCache, error) { - return mach.GenerateAndUploadCrossSigningKeys(ctx, func(uiResp *mautrix.RespUserInteractive) interface{} { - return &mautrix.ReqUIAuthLogin{ - BaseAuthData: mautrix.BaseAuthData{ - Type: mautrix.AuthTypePassword, - Session: uiResp.Session, - }, - User: mach.Client.UserID.String(), - Password: userPassword, - } - }, passphrase) -} - -func (mach *OlmMachine) VerifyWithRecoveryKey(ctx context.Context, recoveryKey string) error { - keyID, keyData, err := mach.SSSS.GetDefaultKeyData(ctx) - if err != nil { - return fmt.Errorf("failed to get default SSSS key data: %w", err) - } - key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey) - if errors.Is(err, ssss.ErrUnverifiableKey) { - mach.machOrContextLog(ctx).Warn(). - Str("key_id", keyID). - Msg("SSSS key is unverifiable, trying to use without verifying") - } else if err != nil { - return err - } - err = mach.FetchCrossSigningKeysFromSSSS(ctx, key) - if err != nil { - return fmt.Errorf("failed to fetch cross-signing keys from SSSS: %w", err) - } - err = mach.SignOwnDevice(ctx, mach.OwnIdentity()) - if err != nil { - return fmt.Errorf("failed to sign own device: %w", err) - } - err = mach.SignOwnMasterKey(ctx) - if err != nil { - return fmt.Errorf("failed to sign own master key: %w", err) - } - return nil -} - -func (mach *OlmMachine) GenerateAndVerifyWithRecoveryKey(ctx context.Context) (recoveryKey string, err error) { - recoveryKey, _, err = mach.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - if err != nil { - err = fmt.Errorf("failed to generate and upload cross-signing keys: %w", err) - } else if err = mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil { - err = fmt.Errorf("failed to sign own device: %w", err) - } else if err = mach.SignOwnMasterKey(ctx); err != nil { - err = fmt.Errorf("failed to sign own master key: %w", err) - } - return -} - -// GenerateAndUploadCrossSigningKeys generates a new key with all corresponding cross-signing keys. -// -// A passphrase can be provided to generate the SSSS key. If the passphrase is empty, a random key -// is used. The base58-formatted recovery key is the first return parameter. -// -// The account password of the user is required for uploading keys to the server. -func (mach *OlmMachine) GenerateAndUploadCrossSigningKeys(ctx context.Context, uiaCallback mautrix.UIACallback, passphrase string) (string, *CrossSigningKeysCache, error) { - key, err := mach.SSSS.GenerateAndUploadKey(ctx, passphrase) - if err != nil { - return "", nil, fmt.Errorf("failed to generate and upload SSSS key: %w", err) - } - - // generate the three cross-signing keys - keysCache, err := mach.GenerateCrossSigningKeys() - if err != nil { - return "", nil, err - } - - // Store the private keys in SSSS - if err := mach.UploadCrossSigningKeysToSSSS(ctx, key, keysCache); err != nil { - return "", nil, fmt.Errorf("failed to upload cross-signing keys to SSSS: %w", err) - } - - // Publish cross-signing keys - err = mach.PublishCrossSigningKeys(ctx, keysCache, uiaCallback) - if err != nil { - return key.RecoveryKey(), keysCache, fmt.Errorf("failed to publish cross-signing keys: %w", err) - } - - err = mach.SSSS.SetDefaultKeyID(ctx, key.ID) - if err != nil { - return key.RecoveryKey(), keysCache, fmt.Errorf("failed to mark %s as the default key: %w", key.ID, err) - } - - return key.RecoveryKey(), keysCache, nil -} - -// UploadCrossSigningKeysToSSSS stores the given cross-signing keys on the server encrypted with the given key. -func (mach *OlmMachine) UploadCrossSigningKeysToSSSS(ctx context.Context, key *ssss.Key, keys *CrossSigningKeysCache) error { - if err := mach.SSSS.SetEncryptedAccountData(ctx, event.AccountDataCrossSigningMaster, keys.MasterKey.Seed(), key); err != nil { - return err - } - if err := mach.SSSS.SetEncryptedAccountData(ctx, event.AccountDataCrossSigningSelf, keys.SelfSigningKey.Seed(), key); err != nil { - return err - } - if err := mach.SSSS.SetEncryptedAccountData(ctx, event.AccountDataCrossSigningUser, keys.UserSigningKey.Seed(), key); err != nil { - return err - } - - // Also store these locally - if err := mach.CryptoStore.PutCrossSigningKey(ctx, mach.Client.UserID, id.XSUsageMaster, keys.MasterKey.PublicKey()); err != nil { - return err - } - if err := mach.CryptoStore.PutCrossSigningKey(ctx, mach.Client.UserID, id.XSUsageSelfSigning, keys.SelfSigningKey.PublicKey()); err != nil { - return err - } - if err := mach.CryptoStore.PutCrossSigningKey(ctx, mach.Client.UserID, id.XSUsageUserSigning, keys.UserSigningKey.PublicKey()); err != nil { - return err - } - - return nil -} diff --git a/mautrix-patched/crypto/cross_sign_store.go b/mautrix-patched/crypto/cross_sign_store.go deleted file mode 100644 index 8fae1dc5..00000000 --- a/mautrix-patched/crypto/cross_sign_store.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - - "go.mau.fi/util/exzerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/id" -) - -func (mach *OlmMachine) storeCrossSigningKeys(ctx context.Context, crossSigningKeys map[id.UserID]mautrix.CrossSigningKeys, deviceKeys map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys) { - log := mach.machOrContextLog(ctx) - for userID, userKeys := range crossSigningKeys { - log := log.With().Stringer("user_id", userID).Logger() - currentKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) - if err != nil { - log.Error().Err(err). - Msg("Error fetching current cross-signing keys of user") - } - for curKeyUsage, curKey := range currentKeys { - log := log.With().Stringer("old_key", curKey.Key).Str("old_key_usage", string(curKeyUsage)).Logger() - // got a new key with the same usage as an existing key - for _, newKeyUsage := range userKeys.Usage { - if newKeyUsage == curKeyUsage { - if _, ok := userKeys.Keys[id.NewKeyID(id.KeyAlgorithmEd25519, curKey.Key.String())]; !ok { - // old key is not in the new key map, so we drop signatures made by it - if count, err := mach.CryptoStore.DropSignaturesByKey(ctx, userID, curKey.Key); err != nil { - log.Error().Err(err).Msg("Error deleting old signatures made by user") - } else { - log.Debug(). - Int64("signature_count", count). - Msg("Dropped signatures made by old key as it has been replaced") - } - } - break - } - } - } - - for _, key := range userKeys.Keys { - log := log.With().Stringer("key", key).Array("usages", exzerolog.ArrayOfStrs(userKeys.Usage)).Logger() - for _, usage := range userKeys.Usage { - // Note: we store keys before verification, but that's fine because the presence of keys in the table - // doesn't imply it's trusted. All code paths that want trusted keys will also check the signature table. - // Also, PutCrossSigningKey will not overwrite the first seen key. - log.Trace().Str("usage", string(usage)).Msg("Storing cross-signing key") - if err = mach.CryptoStore.PutCrossSigningKey(ctx, userID, usage, key); err != nil { - log.Error().Err(err).Msg("Error storing cross-signing key") - } - } - - for signUserID, keySigs := range userKeys.Signatures { - for signKeyID, signature := range keySigs { - _, signKeyName := signKeyID.Parse() - signingKey := id.Ed25519(signKeyName) - log := log.With(). - Str("sign_key_id", signKeyID.String()). - Str("signer_user_id", signUserID.String()). - Str("signing_key", signingKey.String()). - Logger() - // if the signer is one of this user's own devices, find the key from the key ID - if signUserID == userID { - ownDeviceID := id.DeviceID(signKeyName) - if ownDeviceKeys, ok := deviceKeys[userID][ownDeviceID]; ok { - signingKey = ownDeviceKeys.Keys.GetEd25519(ownDeviceID) - log.Trace(). - Str("device_id", signKeyName). - Msg("Treating key name as device ID") - } - } - if len(signingKey) != 43 { - log.Trace().Msg("Cross-signing key has a signature from an unknown key") - continue - } - - log.Trace().Msg("Verifying cross-signing key signature") - if verified, err := signatures.VerifySignatureJSON(userKeys, signUserID, signKeyName, signingKey); err != nil { - log.Warn().Err(err).Msg("Error verifying cross-signing key signature") - } else { - if verified { - log.Trace().Err(err).Msg("Cross-signing key signature verified") - err = mach.CryptoStore.PutSignature(ctx, userID, key, signUserID, signingKey, signature) - if err != nil { - log.Error().Err(err).Msg("Error storing cross-signing key signature") - } - } else { - log.Warn().Err(err).Msg("Cross-siging key signature is invalid") - } - } - } - } - } - - // Clear internal cache so that it refreshes from crypto store - if userID == mach.Client.UserID && mach.crossSigningPubkeys != nil { - log.Debug().Msg("Resetting internal cross-signing key cache") - mach.crossSigningPubkeys = nil - mach.crossSigningPubkeysFetched = false - } - } -} diff --git a/mautrix-patched/crypto/cross_sign_test.go b/mautrix-patched/crypto/cross_sign_test.go deleted file mode 100644 index 39c2abbb..00000000 --- a/mautrix-patched/crypto/cross_sign_test.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "database/sql" - "testing" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -var noopLogger = zerolog.Nop() - -func getOlmMachine(t *testing.T) *OlmMachine { - rawDB, err := sql.Open("sqlite3", ":memory:?_busy_timeout=5000") - require.NoError(t, err, "Error opening raw database") - db, err := dbutil.NewWithDB(rawDB, "sqlite3") - require.NoError(t, err, "Error creating database wrapper") - sqlStore := NewSQLCryptoStore(db, nil, "accid", id.DeviceID("dev"), []byte("test")) - err = sqlStore.DB.Upgrade(context.TODO()) - require.NoError(t, err, "Error upgrading database") - - userID := id.UserID("@mautrix") - mk, _ := olm.NewPKSigning() - ssk, _ := olm.NewPKSigning() - usk, _ := olm.NewPKSigning() - - sqlStore.PutCrossSigningKey(context.TODO(), userID, id.XSUsageMaster, mk.PublicKey()) - sqlStore.PutCrossSigningKey(context.TODO(), userID, id.XSUsageSelfSigning, ssk.PublicKey()) - sqlStore.PutCrossSigningKey(context.TODO(), userID, id.XSUsageUserSigning, usk.PublicKey()) - - return &OlmMachine{ - account: NewOlmAccount(), - CryptoStore: sqlStore, - CrossSigningKeys: &CrossSigningKeysCache{ - MasterKey: mk, - SelfSigningKey: ssk, - UserSigningKey: usk, - }, - Client: &mautrix.Client{ - UserID: userID, - DeviceID: "OWNDEVICEID", - }, - Log: &noopLogger, - } -} - -func TestTrustOwnDevice(t *testing.T) { - m := getOlmMachine(t) - ownDevice := m.OwnIdentity() - ownDevice.Trust = id.TrustStateUnset - assert.False(t, m.IsDeviceTrusted(context.TODO(), ownDevice), "Own device trusted while it shouldn't be") - trustState, err := m.ResolveTrustContext(context.TODO(), ownDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateUnset, trustState) - - csKeys := m.CrossSigningKeys - m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, csKeys.SelfSigningKey.PublicKey(), ownDevice.UserID, csKeys.MasterKey.PublicKey(), "sig1") - m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, csKeys.UserSigningKey.PublicKey(), ownDevice.UserID, csKeys.MasterKey.PublicKey(), "sig1") - m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, ownDevice.SigningKey, - ownDevice.UserID, csKeys.SelfSigningKey.PublicKey(), "sig2") - - trusted, err := m.IsUserTrusted(context.TODO(), ownDevice.UserID) - require.NoError(t, err, "Error checking if own user is trusted") - assert.True(t, trusted, "Own user not trusted while they should be") - assert.True(t, m.IsDeviceTrusted(context.TODO(), ownDevice), "Own device not trusted while it should be") - - trustState, err = m.ResolveTrustContext(context.TODO(), ownDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateCrossSignedVerified, trustState) - - // Without cross-signing private keys, we don't know if they're really ours - m.CrossSigningKeys = nil - trustState, err = m.ResolveTrustContext(context.TODO(), ownDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateCrossSignedTOFU, trustState) - - // Now sign the master key with the current device to confirm it's ours - m.CryptoStore.PutSignature(context.TODO(), ownDevice.UserID, csKeys.MasterKey.PublicKey(), ownDevice.UserID, ownDevice.SigningKey, "sig3") - trustState, err = m.ResolveTrustContext(context.TODO(), ownDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateCrossSignedVerified, trustState) -} - -func TestTrustOtherUser(t *testing.T) { - m := getOlmMachine(t) - otherUser := id.UserID("@anotheruser") - trusted, err := m.IsUserTrusted(context.TODO(), otherUser) - require.NoError(t, err, "Error checking if other user is trusted") - assert.False(t, trusted, "Other user trusted while they shouldn't be") - - theirMasterKey, _ := olm.NewPKSigning() - m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, theirMasterKey.PublicKey()) - - m.CryptoStore.PutSignature(context.TODO(), m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), - m.Client.UserID, m.CrossSigningKeys.MasterKey.PublicKey(), "sig1") - - // sign them with self-signing instead of user-signing key - m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), - m.Client.UserID, m.CrossSigningKeys.SelfSigningKey.PublicKey(), "invalid_sig") - - trusted, err = m.IsUserTrusted(context.TODO(), otherUser) - require.NoError(t, err, "Error checking if other user is trusted") - assert.False(t, trusted, "Other user trusted before their master key has been signed with our user-signing key") - - m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), - m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), "sig2") - - trusted, err = m.IsUserTrusted(context.TODO(), otherUser) - require.NoError(t, err, "Error checking if other user is trusted") - assert.True(t, trusted, "Other user not trusted while they should be") -} - -func TestTrustOtherDevice(t *testing.T) { - m := getOlmMachine(t) - otherUser := id.UserID("@anotheruser") - theirDevice := &id.Device{ - UserID: otherUser, - DeviceID: "theirDevice", - SigningKey: id.Ed25519("theirDeviceKey"), - } - - trusted, err := m.IsUserTrusted(context.TODO(), otherUser) - require.NoError(t, err, "Error checking if other user is trusted") - assert.False(t, trusted, "Other user trusted while they shouldn't be") - assert.False(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device trusted while it shouldn't be") - - trustState, err := m.ResolveTrustContext(context.TODO(), theirDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateUnset, trustState) - - theirMasterKey, _ := olm.NewPKSigning() - m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, theirMasterKey.PublicKey()) - theirSSK, _ := olm.NewPKSigning() - m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageSelfSigning, theirSSK.PublicKey()) - - m.CryptoStore.PutSignature(context.TODO(), otherUser, theirSSK.PublicKey(), - otherUser, theirMasterKey.PublicKey(), "sig3") - - assert.False(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device trusted before it has been signed with user's SSK") - trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateUnset, trustState) - - m.CryptoStore.PutSignature(context.TODO(), otherUser, theirDevice.SigningKey, - otherUser, theirSSK.PublicKey(), "sig4") - assert.True(t, m.IsDeviceTrusted(context.TODO(), theirDevice), "Other device not trusted after it has been signed with user's SSK") - trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateCrossSignedTOFU, trustState) - - m.CryptoStore.PutSignature(context.TODO(), otherUser, theirMasterKey.PublicKey(), - m.Client.UserID, m.CrossSigningKeys.UserSigningKey.PublicKey(), "sig2") - trusted, err = m.IsUserTrusted(context.TODO(), otherUser) - require.NoError(t, err, "Error checking if other user is trusted") - assert.True(t, trusted, "Other user not trusted while they should be") - - trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateCrossSignedVerified, trustState) - - rotatedMasterKey, _ := olm.NewPKSigning() - m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageMaster, rotatedMasterKey.PublicKey()) - rotatedSSK, _ := olm.NewPKSigning() - m.CryptoStore.PutCrossSigningKey(context.TODO(), otherUser, id.XSUsageSelfSigning, rotatedSSK.PublicKey()) - - m.CryptoStore.PutSignature(context.TODO(), otherUser, rotatedSSK.PublicKey(), - otherUser, rotatedMasterKey.PublicKey(), "sig3") - m.CryptoStore.PutSignature(context.TODO(), otherUser, theirDevice.SigningKey, - otherUser, rotatedSSK.PublicKey(), "sig4") - - trustState, err = m.ResolveTrustContext(context.TODO(), theirDevice) - assert.NoError(t, err) - assert.Equal(t, id.TrustStateCrossSignedUntrusted, trustState) -} diff --git a/mautrix-patched/crypto/cross_sign_validation.go b/mautrix-patched/crypto/cross_sign_validation.go deleted file mode 100644 index 5444b682..00000000 --- a/mautrix-patched/crypto/cross_sign_validation.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "fmt" - - "maunium.net/go/mautrix/id" -) - -// ResolveTrust resolves the trust state of the device from cross-signing. -// -// Deprecated: This method doesn't take a context. Use [OlmMachine.ResolveTrustContext] instead. -func (mach *OlmMachine) ResolveTrust(device *id.Device) id.TrustState { - state, _ := mach.ResolveTrustContext(context.Background(), device) - return state -} - -// ResolveTrustContext resolves the trust state of the device from cross-signing. -func (mach *OlmMachine) ResolveTrustContext(ctx context.Context, device *id.Device) (id.TrustState, error) { - if device.Trust == id.TrustStateVerified || device.Trust == id.TrustStateBlacklisted { - return device.Trust, nil - } - theirKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, device.UserID) - if err != nil { - mach.machOrContextLog(ctx).Error().Err(err). - Str("user_id", device.UserID.String()). - Msg("Error retrieving cross-signing key of user from database") - return id.TrustStateUnset, err - } - theirMSK, ok := theirKeys[id.XSUsageMaster] - if !ok { - mach.machOrContextLog(ctx).Debug(). - Str("user_id", device.UserID.String()). - Msg("Master key of user not found") - return id.TrustStateUnset, nil - } - if device.UserID == mach.Client.UserID { - ownKeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - mach.machOrContextLog(ctx).Err(err). - Str("user_id", device.UserID.String()). - Msg("Error retrieving own cross-signing keys from database") - return id.TrustStateUnset, err - } else if ownKeys != nil && ownKeys.MasterKey != theirMSK.Key { - mach.machOrContextLog(ctx).Error(). - Str("user_id", device.UserID.String()). - Stringer("new_master_key", theirMSK.Key). - Stringer("first_seen_master_key", theirMSK.First). - Stringer("cached_master_key", ownKeys.MasterKey). - Msg("Own master key has changed") - return id.TrustStateUnset, nil - } - } - theirSSK, ok := theirKeys[id.XSUsageSelfSigning] - if !ok { - mach.machOrContextLog(ctx).Error(). - Str("user_id", device.UserID.String()). - Msg("Self-signing key of user not found") - return id.TrustStateUnset, nil - } - sskSigExists, err := mach.CryptoStore.IsKeySignedBy(ctx, device.UserID, theirSSK.Key, device.UserID, theirMSK.Key) - if err != nil { - mach.machOrContextLog(ctx).Error().Err(err). - Str("user_id", device.UserID.String()). - Msg("Error retrieving cross-signing signatures for master key of user from database") - return id.TrustStateUnset, err - } - if !sskSigExists { - mach.machOrContextLog(ctx).Error(). - Str("user_id", device.UserID.String()). - Msg("Self-signing key of user is not signed by their master key") - return id.TrustStateUnset, nil - } - deviceSigExists, err := mach.CryptoStore.IsKeySignedBy(ctx, device.UserID, device.SigningKey, device.UserID, theirSSK.Key) - if err != nil { - mach.machOrContextLog(ctx).Error().Err(err). - Str("user_id", device.UserID.String()). - Str("device_key", device.SigningKey.String()). - Msg("Error retrieving cross-signing signatures for device from database") - return id.TrustStateUnset, err - } - if deviceSigExists { - if trusted, err := mach.IsUserTrusted(ctx, device.UserID); trusted { - return id.TrustStateCrossSignedVerified, err - } else if theirMSK.Key == theirMSK.First { - return id.TrustStateCrossSignedTOFU, nil - } - return id.TrustStateCrossSignedUntrusted, nil - } - return id.TrustStateUnset, nil -} - -// IsDeviceTrusted returns whether a device has been determined to be trusted either through verification or cross-signing. -// -// Note: this will return false if resolving the trust state fails due to database errors. -// Use [OlmMachine.ResolveTrustContext] if special error handling is required. -func (mach *OlmMachine) IsDeviceTrusted(ctx context.Context, device *id.Device) bool { - trust, _ := mach.ResolveTrustContext(ctx, device) - switch trust { - case id.TrustStateVerified, id.TrustStateCrossSignedTOFU, id.TrustStateCrossSignedVerified: - return true - default: - return false - } -} - -// IsUserTrusted returns whether a user has been determined to be trusted by our user-signing key having signed their master key. -// In the case the user ID is our own and we have successfully retrieved our cross-signing keys, we trust our own user. -func (mach *OlmMachine) IsUserTrusted(ctx context.Context, userID id.UserID) (bool, error) { - csPubkeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return false, fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } else if csPubkeys == nil { - return false, nil - } - mkTrusted, selfSigningTrusted, userSigningTrusted, err := mach.GetOwnCrossSigningVerificationStatus(ctx) - if err != nil { - return false, fmt.Errorf("failed to check if own cross-signing keys are trusted: %w", err) - } else if !mkTrusted || !userSigningTrusted { - mach.machOrContextLog(ctx).Warn(). - Bool("mk_trusted", mkTrusted). - Bool("usk_trusted", userSigningTrusted). - Msg("Own cross-signing keys are not fully trusted, treating other users as untrusted") - return false, nil - } - if userID == mach.Client.UserID { - return selfSigningTrusted, nil - } - theirKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) - if err != nil { - mach.machOrContextLog(ctx).Error().Err(err). - Str("user_id", userID.String()). - Msg("Error retrieving cross-signing key of user from database") - return false, err - } - theirMskKey, ok := theirKeys[id.XSUsageMaster] - if !ok { - mach.machOrContextLog(ctx).Error(). - Str("user_id", userID.String()). - Msg("Master key of user not found") - return false, nil - } - sigExists, err := mach.CryptoStore.IsKeySignedBy(ctx, userID, theirMskKey.Key, mach.Client.UserID, csPubkeys.UserSigningKey) - if err != nil { - mach.machOrContextLog(ctx).Error().Err(err). - Str("user_id", userID.String()). - Msg("Error retrieving cross-signing signatures for master key of user from database") - return false, err - } - return sigExists, nil -} diff --git a/mautrix-patched/crypto/cryptohelper/cryptohelper.go b/mautrix-patched/crypto/cryptohelper/cryptohelper.go deleted file mode 100644 index b62dc128..00000000 --- a/mautrix-patched/crypto/cryptohelper/cryptohelper.go +++ /dev/null @@ -1,440 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cryptohelper - -import ( - "context" - "errors" - "fmt" - "sync" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - _ "go.mau.fi/util/dbutil/litestream" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/sqlstatestore" -) - -type CryptoHelper struct { - client *mautrix.Client - mach *crypto.OlmMachine - log zerolog.Logger - lock sync.RWMutex - pickleKey []byte - - managedStateStore *sqlstatestore.SQLStateStore - unmanagedCryptoStore crypto.Store - dbForManagedStores *dbutil.Database - - DecryptErrorCallback func(*event.Event, error) - - MSC4190 bool - LoginAs *mautrix.ReqLogin - - ASEventProcessor crypto.ASEventProcessor - CustomPostDecrypt func(context.Context, *event.Event) - - DBAccountID string -} - -var _ mautrix.CryptoHelper = (*CryptoHelper)(nil) - -// NewCryptoHelper creates a struct that helps a mautrix client struct with Matrix e2ee operations. -// -// The client and pickle key are always required. Additionally, you must either: -// - Provide a crypto.Store here and set a StateStore in the client, or -// - Provide a dbutil.Database here to automatically create missing stores. -// - Provide a string here to use it as a path to a SQLite database, and then automatically create missing stores. -// -// The same database may be shared across multiple clients, but note that doing that will allow all clients access to -// decryption keys received by any one of the clients. For that reason, the pickle key must also be same for all clients -// using the same database. -func NewCryptoHelper(cli *mautrix.Client, pickleKey []byte, store any) (*CryptoHelper, error) { - if len(pickleKey) == 0 { - return nil, fmt.Errorf("pickle key must be provided") - } - _, isExtensible := cli.Syncer.(mautrix.ExtensibleSyncer) - if !cli.SetAppServiceDeviceID && !isExtensible { - return nil, fmt.Errorf("the client syncer must implement ExtensibleSyncer") - } - - var managedStateStore *sqlstatestore.SQLStateStore - var dbForManagedStores *dbutil.Database - var unmanagedCryptoStore crypto.Store - switch typedStore := store.(type) { - case crypto.Store: - if cli.StateStore == nil { - return nil, fmt.Errorf("when passing a crypto.Store to NewCryptoHelper, the client must have a state store set beforehand") - } else if _, isCryptoCompatible := cli.StateStore.(crypto.StateStore); !isCryptoCompatible { - return nil, fmt.Errorf("the client state store must implement crypto.StateStore") - } - unmanagedCryptoStore = typedStore - case string: - db, err := dbutil.NewWithDialect(fmt.Sprintf("file:%s?_txlock=immediate", typedStore), "sqlite3-fk-wal") - if err != nil { - return nil, err - } - dbForManagedStores = db - case *dbutil.Database: - dbForManagedStores = typedStore - default: - return nil, fmt.Errorf("you must pass a *dbutil.Database or *crypto.StateStore to NewCryptoHelper") - } - log := cli.Log.With().Str("component", "crypto").Logger() - if cli.StateStore == nil && dbForManagedStores != nil { - managedStateStore = sqlstatestore.NewSQLStateStore(dbForManagedStores, dbutil.ZeroLogger(log.With().Str("db_section", "matrix_state").Logger()), false) - cli.StateStore = managedStateStore - } else if _, isCryptoCompatible := cli.StateStore.(crypto.StateStore); !isCryptoCompatible { - return nil, fmt.Errorf("the client state store must implement crypto.StateStore") - } - - return &CryptoHelper{ - client: cli, - log: log, - pickleKey: pickleKey, - - unmanagedCryptoStore: unmanagedCryptoStore, - managedStateStore: managedStateStore, - dbForManagedStores: dbForManagedStores, - - DecryptErrorCallback: func(_ *event.Event, _ error) {}, - }, nil -} - -func (helper *CryptoHelper) Init(ctx context.Context) error { - if helper == nil { - return fmt.Errorf("crypto helper is nil") - } - syncer, ok := helper.client.Syncer.(mautrix.ExtensibleSyncer) - if !ok { - if !helper.client.SetAppServiceDeviceID { - return fmt.Errorf("the client syncer must implement ExtensibleSyncer") - } - } - - var stateStore crypto.StateStore - if helper.managedStateStore != nil { - err := helper.managedStateStore.Upgrade(ctx) - if err != nil { - return fmt.Errorf("failed to upgrade client state store: %w", err) - } - stateStore = helper.managedStateStore - } else { - stateStore = helper.client.StateStore.(crypto.StateStore) - } - var cryptoStore crypto.Store - if helper.unmanagedCryptoStore == nil { - managedCryptoStore := crypto.NewSQLCryptoStore(helper.dbForManagedStores, dbutil.ZeroLogger(helper.log.With().Str("db_section", "crypto").Logger()), helper.DBAccountID, helper.client.DeviceID, helper.pickleKey) - if helper.client.Store == nil { - helper.client.Store = managedCryptoStore - } else if _, isMemory := helper.client.Store.(*mautrix.MemorySyncStore); isMemory { - helper.client.Store = managedCryptoStore - } - err := managedCryptoStore.DB.Upgrade(ctx) - if err != nil { - return fmt.Errorf("failed to upgrade crypto state store: %w", err) - } - cryptoStore = managedCryptoStore - } else { - cryptoStore = helper.unmanagedCryptoStore - } - shouldFindDeviceID := helper.LoginAs != nil || helper.unmanagedCryptoStore == nil - if rawCryptoStore, ok := cryptoStore.(*crypto.SQLCryptoStore); ok && shouldFindDeviceID { - storedDeviceID, err := rawCryptoStore.FindDeviceID(ctx) - if err != nil { - return fmt.Errorf("failed to find existing device ID: %w", err) - } - if helper.MSC4190 { - helper.log.Debug().Msg("Creating bot device with MSC4190") - err = helper.client.CreateDeviceMSC4190(ctx, storedDeviceID, helper.LoginAs.InitialDeviceDisplayName) - if err != nil { - return fmt.Errorf("failed to create device for bot: %w", err) - } - rawCryptoStore.DeviceID = helper.client.DeviceID - } else if helper.LoginAs != nil && helper.LoginAs.Type == mautrix.AuthTypeAppservice && helper.client.SetAppServiceDeviceID { - if storedDeviceID == "" { - helper.log.Debug(). - Str("username", helper.LoginAs.Identifier.User). - Msg("Logging in with appservice") - var resp *mautrix.RespLogin - resp, err = helper.client.Login(ctx, helper.LoginAs) - if err != nil { - return err - } - helper.client.DeviceID = resp.DeviceID - } else { - helper.log.Debug(). - Str("username", helper.LoginAs.Identifier.User). - Stringer("device_id", storedDeviceID). - Msg("Using existing device") - helper.client.DeviceID = storedDeviceID - } - } else if helper.LoginAs != nil { - if storedDeviceID != "" { - helper.LoginAs.DeviceID = storedDeviceID - } - helper.LoginAs.StoreCredentials = true - helper.log.Debug(). - Str("username", helper.LoginAs.Identifier.User). - Str("device_id", helper.LoginAs.DeviceID.String()). - Msg("Logging in") - _, err = helper.client.Login(ctx, helper.LoginAs) - if err != nil { - return err - } - } else if storedDeviceID != "" && storedDeviceID != helper.client.DeviceID { - return fmt.Errorf("mismatching device ID in client and crypto store (%q != %q)", storedDeviceID, helper.client.DeviceID) - } - rawCryptoStore.DeviceID = helper.client.DeviceID - } else if helper.LoginAs != nil { - return fmt.Errorf("LoginAs can only be used with a managed crypto store") - } - if helper.client.DeviceID == "" || helper.client.UserID == "" { - return fmt.Errorf("the client must be logged in") - } - helper.mach = crypto.NewOlmMachine(helper.client, &helper.log, cryptoStore, stateStore) - err := helper.mach.Load(ctx) - if err != nil { - return fmt.Errorf("failed to load olm account: %w", err) - } else if err = helper.verifyDeviceKeysOnServer(ctx); err != nil { - return err - } - - if syncer != nil { - syncer.OnSync(helper.mach.ProcessSyncResponse) - syncer.OnEventType(event.StateMember, helper.mach.HandleMemberEvent) - if _, ok = helper.client.Syncer.(mautrix.DispatchableSyncer); ok { - syncer.OnEventType(event.EventEncrypted, helper.HandleEncrypted) - } else { - helper.log.Warn().Msg("Client syncer does not implement DispatchableSyncer. Events will not be decrypted automatically.") - } - if helper.managedStateStore != nil { - syncer.OnEvent(helper.client.StateStoreSyncHandler) - } - } else if helper.ASEventProcessor != nil { - helper.mach.AddAppserviceListener(helper.ASEventProcessor) - helper.ASEventProcessor.On(event.EventEncrypted, helper.HandleEncrypted) - } - - return nil -} - -func (helper *CryptoHelper) Close() error { - if helper != nil && helper.dbForManagedStores != nil { - err := helper.dbForManagedStores.Close() - if err != nil { - return err - } - } - return nil -} - -func (helper *CryptoHelper) Machine() *crypto.OlmMachine { - if helper == nil || helper.mach == nil { - panic("Machine() called before initing CryptoHelper") - } - return helper.mach -} - -func (helper *CryptoHelper) verifyDeviceKeysOnServer(ctx context.Context) error { - helper.log.Debug().Msg("Making sure our device has the expected keys on the server") - resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{ - DeviceKeys: map[id.UserID]mautrix.DeviceIDList{ - helper.client.UserID: {helper.client.DeviceID}, - }, - }) - if err != nil { - return fmt.Errorf("failed to query own keys to make sure device is properly configured: %w", err) - } - ownID := helper.mach.OwnIdentity() - isShared := helper.mach.GetAccount().Shared - device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID] - if !ok || len(device.Keys) == 0 { - if isShared { - return fmt.Errorf("olm account is marked as shared, keys seem to have disappeared from the server") - } - helper.log.Debug().Msg("Olm account not shared and keys not on server, sharing initial keys") - err = helper.mach.ShareKeys(ctx, -1) - if err != nil { - return fmt.Errorf("failed to share keys: %w", err) - } - return nil - } else if !isShared { - return fmt.Errorf("olm account is not marked as shared, but there are keys on the server") - } else if ed := device.Keys.GetEd25519(helper.client.DeviceID); ownID.SigningKey != ed { - return fmt.Errorf("mismatching identity key on server (%q != %q)", ownID.SigningKey, ed) - } else { - helper.log.Debug().Msg("Olm account marked as shared and keys on server match, device is fine") - return nil - } -} - -var NoSessionFound = crypto.ErrNoSessionFound - -const initialSessionWaitTimeout = 3 * time.Second -const extendedSessionWaitTimeout = 22 * time.Second - -func (helper *CryptoHelper) HandleEncrypted(ctx context.Context, evt *event.Event) { - if helper == nil { - return - } - content := evt.Content.AsEncrypted() - // TODO use context log instead of helper? - log := helper.log.With(). - Str("event_id", evt.ID.String()). - Str("session_id", content.SessionID.String()). - Logger() - log.Debug().Msg("Decrypting received event") - ctx = log.WithContext(ctx) - - decrypted, err := helper.Decrypt(ctx, evt) - if errors.Is(err, NoSessionFound) && ctx.Value(mautrix.SyncTokenContextKey) != "" { - go helper.waitForSession(ctx, evt) - } else if err != nil { - log.Warn().Err(err).Msg("Failed to decrypt event") - helper.DecryptErrorCallback(evt, err) - } else { - helper.postDecrypt(ctx, decrypted) - } -} - -func (helper *CryptoHelper) postDecrypt(ctx context.Context, decrypted *event.Event) { - decrypted.Mautrix.EventSource |= event.SourceDecrypted - if helper.CustomPostDecrypt != nil { - helper.CustomPostDecrypt(ctx, decrypted) - } else if helper.ASEventProcessor != nil { - helper.ASEventProcessor.Dispatch(ctx, decrypted) - } else { - helper.client.Syncer.(mautrix.DispatchableSyncer).Dispatch(ctx, decrypted) - } -} - -func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) { - if helper == nil { - return - } - helper.lock.RLock() - defer helper.lock.RUnlock() - if deviceID == "" { - deviceID = "*" - } - // TODO get log from context - log := helper.log.With(). - Str("session_id", sessionID.String()). - Str("user_id", userID.String()). - Str("device_id", deviceID.String()). - Str("room_id", roomID.String()). - Logger() - err := helper.mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{ - userID: {deviceID}, - helper.client.UserID: {"*"}, - }) - if err != nil { - log.Warn().Err(err).Msg("Failed to send key request") - } else { - log.Debug().Msg("Sent key request") - } -} - -func (helper *CryptoHelper) waitForSession(ctx context.Context, evt *event.Event) { - log := zerolog.Ctx(ctx) - content := evt.Content.AsEncrypted() - - log.Debug(). - Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())). - Msg("Couldn't find session, waiting for keys to arrive...") - if helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) { - log.Debug().Msg("Got keys after waiting, trying to decrypt event again") - decrypted, err := helper.Decrypt(ctx, evt) - if err != nil { - log.Warn().Err(err).Msg("Failed to decrypt event") - helper.DecryptErrorCallback(evt, err) - } else { - helper.postDecrypt(ctx, decrypted) - } - } else { - go helper.waitLongerForSession(ctx, evt) - } -} - -func (helper *CryptoHelper) waitLongerForSession(ctx context.Context, evt *event.Event) { - log := zerolog.Ctx(ctx) - content := evt.Content.AsEncrypted() - log.Debug().Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())).Msg("Couldn't find session, requesting keys and waiting longer...") - - //lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank - go helper.RequestSession(context.TODO(), evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID) - - if !helper.mach.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) { - log.Debug().Msg("Didn't get session, giving up") - helper.DecryptErrorCallback(evt, NoSessionFound) - return - } - - log.Debug().Msg("Got keys after waiting longer, trying to decrypt event again") - decrypted, err := helper.Decrypt(ctx, evt) - if err != nil { - log.Error().Err(err).Msg("Failed to decrypt event") - helper.DecryptErrorCallback(evt, err) - return - } - - helper.postDecrypt(ctx, decrypted) -} - -func (helper *CryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool { - if helper == nil { - return false - } - helper.lock.RLock() - defer helper.lock.RUnlock() - return helper.mach.WaitForSession(ctx, roomID, senderKey, sessionID, timeout) -} - -func (helper *CryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) { - if helper == nil { - return nil, fmt.Errorf("crypto helper is nil") - } - return helper.mach.DecryptMegolmEvent(ctx, evt) -} - -func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (encrypted *event.EncryptedEventContent, err error) { - return helper.EncryptWithStateKey(ctx, roomID, evtType, nil, content) -} - -func (helper *CryptoHelper) EncryptWithStateKey(ctx context.Context, roomID id.RoomID, evtType event.Type, stateKey *string, content any) (encrypted *event.EncryptedEventContent, err error) { - if helper == nil { - return nil, fmt.Errorf("crypto helper is nil") - } - helper.lock.RLock() - defer helper.lock.RUnlock() - encrypted, err = helper.mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, stateKey, content) - if err != nil { - if !errors.Is(err, crypto.ErrSessionExpired) && err != crypto.ErrNoGroupSession && !errors.Is(err, crypto.ErrSessionNotShared) { - return - } - helper.log.Debug(). - Err(err). - Str("room_id", roomID.String()). - Msg("Got session error while encrypting event, sharing group session and trying again") - var users []id.UserID - users, err = helper.client.StateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID) - if err != nil { - err = fmt.Errorf("failed to get room member list: %w", err) - } else if err = helper.mach.ShareGroupSession(ctx, roomID, users); err != nil { - err = fmt.Errorf("failed to share group session: %w", err) - } else if encrypted, err = helper.mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, stateKey, content); err != nil { - err = fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err) - } - } - return -} diff --git a/mautrix-patched/crypto/decryptmegolm.go b/mautrix-patched/crypto/decryptmegolm.go deleted file mode 100644 index c933f637..00000000 --- a/mautrix-patched/crypto/decryptmegolm.go +++ /dev/null @@ -1,353 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/rs/zerolog" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - "go.mau.fi/util/exgjson" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ( - ErrIncorrectEncryptedContentType = errors.New("event content is not instance of *event.EncryptedEventContent") - ErrNoSessionFound = errors.New("failed to decrypt megolm event: no session with given ID found") - ErrDuplicateMessageIndex = errors.New("duplicate megolm message index") - ErrWrongRoom = errors.New("encrypted megolm event is not intended for this room") - ErrDeviceKeyMismatch = errors.New("device keys in event and verified device info do not match") - ErrRatchetError = errors.New("failed to ratchet session after use") - ErrCorruptedMegolmPayload = errors.New("corrupted megolm payload") -) - -// Deprecated: use variables prefixed with Err -var ( - IncorrectEncryptedContentType = ErrIncorrectEncryptedContentType - NoSessionFound = ErrNoSessionFound - DuplicateMessageIndex = ErrDuplicateMessageIndex - WrongRoom = ErrWrongRoom - DeviceKeyMismatch = ErrDeviceKeyMismatch - RatchetError = ErrRatchetError -) - -type megolmEvent struct { - RoomID id.RoomID `json:"room_id"` - Type event.Type `json:"type"` - StateKey *string `json:"state_key"` - Content event.Content `json:"content"` -} - -var ( - relatesToContentPath = exgjson.Path("m.relates_to") - relatesToTopLevelPath = exgjson.Path("content", "m.relates_to") -) - -const sessionIDLength = 43 - -func validateCiphertextCharacters(ciphertext []byte) bool { - for _, b := range ciphertext { - if (b < 'a' || b > 'z') && (b < 'A' || b > 'Z') && (b < '0' || b > '9') && b != '+' && b != '/' { - return false - } - } - return true -} - -// DecryptMegolmEvent decrypts an m.room.encrypted event where the algorithm is m.megolm.v1.aes-sha2 -func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event) (*event.Event, error) { - content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) - if !ok { - return nil, ErrIncorrectEncryptedContentType - } else if content.Algorithm != id.AlgorithmMegolmV1 { - return nil, ErrUnsupportedAlgorithm - } else if len(content.MegolmCiphertext) < 74 { - return nil, fmt.Errorf("%w: ciphertext too short (%d bytes)", ErrCorruptedMegolmPayload, len(content.MegolmCiphertext)) - } else if len(content.SessionID) != sessionIDLength { - return nil, fmt.Errorf("%w: invalid session ID length %d", ErrCorruptedMegolmPayload, len(content.SessionID)) - } else if !validateCiphertextCharacters(content.MegolmCiphertext) { - return nil, fmt.Errorf("%w: invalid characters in ciphertext", ErrCorruptedMegolmPayload) - } - log := mach.machOrContextLog(ctx).With(). - Str("action", "decrypt megolm event"). - Str("event_id", evt.ID.String()). - Str("sender", evt.Sender.String()). - Str("sender_key", content.SenderKey.String()). - Str("session_id", content.SessionID.String()). - Logger() - ctx = log.WithContext(ctx) - encryptionRoomID := evt.RoomID - // Allow the server to move encrypted events between rooms if both the real room and target room are on a non-federatable .local domain. - // The message index checks to prevent replay attacks still apply and aren't based on the room ID, - // so the event ID and timestamp must remain the same when the event is moved to a different room. - if origRoomID, ok := evt.Content.Raw["com.beeper.original_room_id"].(string); ok && strings.HasSuffix(origRoomID, ".local") && strings.HasSuffix(evt.RoomID.String(), ".local") && mach.AllowBeeperRoomReroute { - encryptionRoomID = id.RoomID(origRoomID) - } - sess, plaintext, messageIndex, err := mach.actuallyDecryptMegolmEvent(ctx, evt, encryptionRoomID, content) - if err != nil { - return nil, err - } - log = log.With().Uint("message_index", messageIndex).Logger() - - var trustLevel id.TrustState - var forwardedKeys bool - var device *id.Device - ownSigningKey, ownIdentityKey := mach.account.Keys() - if sess.KeySource == id.KeySourceBackup || sess.KeySource == id.KeySourceImport { - // Key backup is currently not trusted, so use a special trust level for it. - trustLevel = id.TrustStateBackup - } else if sess.SigningKey == ownSigningKey && sess.SenderKey == ownIdentityKey && sess.KeySource == id.KeySourceDirect && len(sess.ForwardingChains) == 0 { - trustLevel = id.TrustStateVerified - } else { - if mach.DisableDecryptKeyFetching || !mach.keyFetchAttempted.Add(userSenderKeyTuple{evt.Sender, sess.SenderKey}) { - device, err = mach.CryptoStore.FindDeviceByKey(ctx, evt.Sender, sess.SenderKey) - } else { - device, err = mach.GetOrFetchDeviceByKey(ctx, evt.Sender, sess.SenderKey) - } - if err != nil { - // We don't want to throw these errors as the message can still be decrypted. - log.Debug().Err(err).Msg("Failed to get device to verify session") - trustLevel = id.TrustStateUnknownDevice - } else if len(sess.ForwardingChains) == 0 || (len(sess.ForwardingChains) == 1 && sess.ForwardingChains[0] == sess.SenderKey.String()) { - if device == nil { - log.Debug(). - Str("session_sender_key", sess.SenderKey.String()). - Msg("Couldn't resolve trust level of session: sent by unknown device") - trustLevel = id.TrustStateUnknownDevice - } else if device.SigningKey != sess.SigningKey || device.IdentityKey != sess.SenderKey { - log.Debug(). - Stringer("session_sender_key", sess.SenderKey). - Stringer("device_sender_key", device.IdentityKey). - Stringer("session_signing_key", sess.SigningKey). - Stringer("device_signing_key", device.SigningKey). - Msg("Device keys don't match keys in session, marking as untrusted") - trustLevel = id.TrustStateDeviceKeyMismatch - } else { - trustLevel, err = mach.ResolveTrustContext(ctx, device) - if err != nil { - return nil, err - } - } - } else { - forwardedKeys = true - lastChainItem := sess.ForwardingChains[len(sess.ForwardingChains)-1] - device, _ = mach.CryptoStore.FindDeviceByKey(ctx, evt.Sender, id.IdentityKey(lastChainItem)) - if device != nil { - trustLevel, err = mach.ResolveTrustContext(ctx, device) - if err != nil { - return nil, err - } - } else { - log.Debug(). - Str("forward_last_sender_key", lastChainItem). - Msg("Couldn't resolve trust level of session: forwarding chain ends with unknown device") - trustLevel = id.TrustStateForwarded - } - } - } - - if content.RelatesTo != nil { - relation := gjson.GetBytes(evt.Content.VeryRaw, relatesToContentPath) - if relation.Exists() && !gjson.GetBytes(plaintext, relatesToTopLevelPath).IsObject() { - var raw []byte - if relation.Index > 0 { - raw = evt.Content.VeryRaw[relation.Index : relation.Index+len(relation.Raw)] - } else { - raw = []byte(relation.Raw) - } - updatedPlaintext, err := sjson.SetRawBytes(plaintext, relatesToTopLevelPath, raw) - if err != nil { - log.Warn().Msg("Failed to copy m.relates_to to decrypted payload") - } else if updatedPlaintext != nil { - plaintext = updatedPlaintext - } - } else if !relation.Exists() { - log.Warn().Msg("Failed to find m.relates_to in raw encrypted event even though it was present in parsed content") - } - } - - megolmEvt := &megolmEvent{} - err = json.Unmarshal(plaintext, &megolmEvt) - if err != nil { - return nil, fmt.Errorf("failed to parse megolm payload: %w", err) - } else if megolmEvt.RoomID != encryptionRoomID { - return nil, ErrWrongRoom - } - if evt.StateKey != nil && megolmEvt.StateKey != nil && mach.AllowEncryptedState { - megolmEvt.Type.Class = event.StateEventType - } else { - megolmEvt.Type.Class = evt.Type.Class - megolmEvt.StateKey = nil - } - log = log.With().Str("decrypted_event_type", megolmEvt.Type.Repr()).Logger() - err = megolmEvt.Content.ParseRaw(megolmEvt.Type) - if err != nil { - if errors.Is(err, event.ErrUnsupportedContentType) { - log.Warn().Msg("Unsupported event type in encrypted event") - } else if !mach.IgnorePostDecryptionParseErrors { - return nil, fmt.Errorf("failed to parse content of megolm payload event: %w", err) - } - } - log.Debug().Msg("Event decrypted successfully") - megolmEvt.Type.Class = evt.Type.Class - return &event.Event{ - Sender: evt.Sender, - Type: megolmEvt.Type, - StateKey: megolmEvt.StateKey, - Timestamp: evt.Timestamp, - ID: evt.ID, - RoomID: evt.RoomID, - Content: megolmEvt.Content, - Unsigned: evt.Unsigned, - Mautrix: event.MautrixInfo{ - TrustState: trustLevel, - TrustSource: device, - ForwardedKeys: forwardedKeys, - WasEncrypted: true, - EventSource: evt.Mautrix.EventSource | event.SourceDecrypted, - ReceivedAt: evt.Mautrix.ReceivedAt, - }, - }, nil -} - -func removeItem(slice []uint, item uint) ([]uint, bool) { - for i, s := range slice { - if s == item { - return append(slice[:i], slice[i+1:]...), true - } - } - return slice, false -} - -const missedIndexCutoff = 10 - -func (mach *OlmMachine) checkUndecryptableMessageIndexDuplication(ctx context.Context, sess *InboundGroupSession, evt *event.Event, content *event.EncryptedEventContent) (uint, error) { - log := *zerolog.Ctx(ctx) - messageIndex, decodeErr := ParseMegolmMessageIndex(content.MegolmCiphertext) - if decodeErr != nil { - log.Warn().Err(decodeErr).Msg("Failed to parse message index to check if it's a duplicate for message that failed to decrypt") - return 0, fmt.Errorf("%w (also failed to parse message index)", olm.ErrUnknownMessageIndex) - } - firstKnown := sess.Internal.FirstKnownIndex() - log = log.With().Uint("message_index", messageIndex).Uint32("first_known_index", firstKnown).Logger() - if ok, err := mach.CryptoStore.ValidateMessageIndex(ctx, sess.ID(), evt.ID, messageIndex, evt.Timestamp); err != nil { - log.Debug().Err(err).Msg("Failed to check if message index is duplicate") - return messageIndex, fmt.Errorf("%w (failed to check if index is duplicate; received: %d, earliest known: %d)", olm.ErrUnknownMessageIndex, messageIndex, firstKnown) - } else if !ok { - log.Debug().Msg("Failed to decrypt message due to unknown index and found duplicate") - return messageIndex, fmt.Errorf("%w %d (also failed to decrypt because earliest known index is %d)", ErrDuplicateMessageIndex, messageIndex, firstKnown) - } - log.Debug().Msg("Failed to decrypt message due to unknown index, but index is not duplicate") - return messageIndex, fmt.Errorf("%w (not duplicate index; received: %d, earliest known: %d)", olm.ErrUnknownMessageIndex, messageIndex, firstKnown) -} - -func (mach *OlmMachine) actuallyDecryptMegolmEvent(ctx context.Context, evt *event.Event, encryptionRoomID id.RoomID, content *event.EncryptedEventContent) (*InboundGroupSession, []byte, uint, error) { - mach.megolmDecryptLock.Lock() - defer mach.megolmDecryptLock.Unlock() - - sess, err := mach.CryptoStore.GetGroupSession(ctx, encryptionRoomID, content.SessionID) - if err != nil { - return nil, nil, 0, fmt.Errorf("failed to get group session: %w", err) - } else if sess == nil { - return nil, nil, 0, fmt.Errorf("%w (ID %s)", ErrNoSessionFound, content.SessionID) - } - plaintext, messageIndex, err := sess.Internal.Decrypt(content.MegolmCiphertext) - if err != nil { - if errors.Is(err, olm.ErrUnknownMessageIndex) && mach.RatchetKeysOnDecrypt { - messageIndex, err = mach.checkUndecryptableMessageIndexDuplication(ctx, sess, evt, content) - return sess, nil, messageIndex, fmt.Errorf("failed to decrypt megolm event: %w", err) - } - return sess, nil, 0, fmt.Errorf("failed to decrypt megolm event: %w", err) - } else if ok, err := mach.CryptoStore.ValidateMessageIndex(ctx, sess.ID(), evt.ID, messageIndex, evt.Timestamp); err != nil { - return sess, nil, messageIndex, fmt.Errorf("failed to check if message index is duplicate: %w", err) - } else if !ok { - return sess, nil, messageIndex, fmt.Errorf("%w %d", ErrDuplicateMessageIndex, messageIndex) - } - - // Normal clients don't care about tracking the ratchet state, so let them bypass the rest of the function - if mach.DisableRatchetTracking { - return sess, plaintext, messageIndex, nil - } - - expectedMessageIndex := sess.RatchetSafety.NextIndex - didModify := false - switch { - case messageIndex > expectedMessageIndex: - // When the index jumps, add indices in between to the missed indices list. - for i := expectedMessageIndex; i < messageIndex; i++ { - sess.RatchetSafety.MissedIndices = append(sess.RatchetSafety.MissedIndices, i) - } - fallthrough - case messageIndex == expectedMessageIndex: - // When the index moves forward (to the next one or jumping ahead), update the last received index. - sess.RatchetSafety.NextIndex = messageIndex + 1 - didModify = true - default: - sess.RatchetSafety.MissedIndices, didModify = removeItem(sess.RatchetSafety.MissedIndices, messageIndex) - } - // Use presence of ReceivedAt as a sign that this is a recent megolm session, - // and therefore it's safe to drop missed indices entirely. - if !sess.ReceivedAt.IsZero() && len(sess.RatchetSafety.MissedIndices) > 0 && int(sess.RatchetSafety.MissedIndices[0]) < int(sess.RatchetSafety.NextIndex)-missedIndexCutoff { - limit := sess.RatchetSafety.NextIndex - missedIndexCutoff - var cutoff int - for ; cutoff < len(sess.RatchetSafety.MissedIndices) && sess.RatchetSafety.MissedIndices[cutoff] < limit; cutoff++ { - } - sess.RatchetSafety.LostIndices = append(sess.RatchetSafety.LostIndices, sess.RatchetSafety.MissedIndices[:cutoff]...) - sess.RatchetSafety.MissedIndices = sess.RatchetSafety.MissedIndices[cutoff:] - didModify = true - } - ratchetTargetIndex := uint32(sess.RatchetSafety.NextIndex) - if len(sess.RatchetSafety.MissedIndices) > 0 { - ratchetTargetIndex = uint32(sess.RatchetSafety.MissedIndices[0]) - } - ratchetCurrentIndex := sess.Internal.FirstKnownIndex() - log := zerolog.Ctx(ctx).With(). - Uint32("prev_ratchet_index", ratchetCurrentIndex). - Uint32("new_ratchet_index", ratchetTargetIndex). - Uint("next_new_index", sess.RatchetSafety.NextIndex). - Uints("missed_indices", sess.RatchetSafety.MissedIndices). - Uints("lost_indices", sess.RatchetSafety.LostIndices). - Int("max_messages", sess.MaxMessages). - Logger() - if sess.MaxMessages > 0 && int(ratchetTargetIndex) >= sess.MaxMessages && len(sess.RatchetSafety.MissedIndices) == 0 && mach.DeleteFullyUsedKeysOnDecrypt { - err = mach.CryptoStore.RedactGroupSession(ctx, sess.RoomID, sess.ID(), "maximum messages reached") - if err != nil { - log.Err(err).Msg("Failed to delete fully used session") - return sess, plaintext, messageIndex, ErrRatchetError - } else { - log.Info().Msg("Deleted fully used session") - } - } else if ratchetCurrentIndex < ratchetTargetIndex && mach.RatchetKeysOnDecrypt { - if err = sess.RatchetTo(ratchetTargetIndex); err != nil { - log.Err(err).Msg("Failed to ratchet session") - return sess, plaintext, messageIndex, ErrRatchetError - } else if err = mach.CryptoStore.PutGroupSession(ctx, sess); err != nil { - log.Err(err).Msg("Failed to store ratcheted session") - return sess, plaintext, messageIndex, ErrRatchetError - } else { - log.Info().Msg("Ratcheted session forward") - } - } else if didModify { - if err = mach.CryptoStore.PutGroupSession(ctx, sess); err != nil { - log.Err(err).Msg("Failed to store updated ratchet safety data") - return sess, plaintext, messageIndex, ErrRatchetError - } else { - log.Debug().Msg("Ratchet safety data changed (ratchet state didn't change)") - } - } else { - log.Debug().Msg("Ratchet safety data didn't change") - } - return sess, plaintext, messageIndex, nil -} diff --git a/mautrix-patched/crypto/decryptolm.go b/mautrix-patched/crypto/decryptolm.go deleted file mode 100644 index b5c27dc0..00000000 --- a/mautrix-patched/crypto/decryptolm.go +++ /dev/null @@ -1,438 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "slices" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/goolm/account" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ( - ErrUnsupportedAlgorithm = errors.New("unsupported event encryption algorithm") - ErrNotEncryptedForMe = errors.New("olm event doesn't contain ciphertext for this device") - ErrUnsupportedOlmMessageType = errors.New("unsupported olm message type") - ErrDecryptionFailedWithMatchingSession = errors.New("decryption failed with matching session") - ErrDecryptionFailedForNormalMessage = errors.New("decryption failed for normal message") - ErrSenderMismatch = errors.New("mismatched sender in olm payload") - ErrSenderKeyMismatch = errors.New("mismatched sender key in olm payload") - ErrSigningKeyMismatch = errors.New("mismatched signing key in olm payload and device info") - ErrRecipientMismatch = errors.New("mismatched recipient in olm payload") - ErrRecipientKeyMismatch = errors.New("mismatched recipient key in olm payload") - ErrDuplicateMessage = errors.New("duplicate olm message") -) - -// Deprecated: use variables prefixed with Err -var ( - UnsupportedAlgorithm = ErrUnsupportedAlgorithm - NotEncryptedForMe = ErrNotEncryptedForMe - UnsupportedOlmMessageType = ErrUnsupportedOlmMessageType - DecryptionFailedWithMatchingSession = ErrDecryptionFailedWithMatchingSession - DecryptionFailedForNormalMessage = ErrDecryptionFailedForNormalMessage - SenderMismatch = ErrSenderMismatch - RecipientMismatch = ErrRecipientMismatch - RecipientKeyMismatch = ErrRecipientKeyMismatch -) - -// DecryptedOlmEvent represents an event that was decrypted from an event encrypted with the m.olm.v1.curve25519-aes-sha2 algorithm. -type DecryptedOlmEvent struct { - Source *event.Event `json:"-"` - - SenderKey id.SenderKey `json:"-"` - SenderDevice *id.Device `json:"-"` - - Sender id.UserID `json:"sender"` - SenderDeviceKeys *mautrix.DeviceKeys `json:"sender_device_keys,omitempty"` - Keys OlmEventKeys `json:"keys"` - Recipient id.UserID `json:"recipient"` - RecipientKeys OlmEventKeys `json:"recipient_keys"` - - // Deprecated: not a real field, do not use - SenderDeviceID id.DeviceID `json:"sender_device"` - - Type event.Type `json:"type"` - Content event.Content `json:"content"` -} - -func (mach *OlmMachine) decryptOlmEvent(ctx context.Context, evt *event.Event) (*DecryptedOlmEvent, error) { - content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) - if !ok { - return nil, ErrIncorrectEncryptedContentType - } else if content.Algorithm != id.AlgorithmOlmV1 { - return nil, ErrUnsupportedAlgorithm - } - ownContent, ok := content.OlmCiphertext[mach.account.IdentityKey()] - if !ok { - return nil, ErrNotEncryptedForMe - } - decrypted, err := mach.decryptAndParseOlmCiphertext(ctx, evt, content.SenderKey, ownContent.Type, ownContent.Body) - if err != nil { - return nil, err - } - decrypted.Source = evt - return decrypted, nil -} - -type OlmEventKeys struct { - Ed25519 id.Ed25519 `json:"ed25519"` -} - -type userSenderKeyTuple struct { - UserID id.UserID - SenderKey id.SenderKey -} - -func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *event.Event, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string) (*DecryptedOlmEvent, error) { - if olmType != id.OlmMsgTypePreKey && olmType != id.OlmMsgTypeMsg { - return nil, ErrUnsupportedOlmMessageType - } - - log := mach.machOrContextLog(ctx).With(). - Stringer("sender_key", senderKey). - Int("olm_msg_type", int(olmType)). - Logger() - ctx = log.WithContext(ctx) - endTimeTrace := mach.timeTrace(ctx, "decrypting olm ciphertext", 5*time.Second) - plaintext, err := mach.tryDecryptOlmCiphertext(ctx, evt.Sender, senderKey, olmType, ciphertext) - endTimeTrace() - if err != nil { - return nil, err - } - - defer mach.timeTrace(ctx, "parsing decrypted olm event", time.Second)() - - var olmEvt DecryptedOlmEvent - err = json.Unmarshal(plaintext, &olmEvt) - if err != nil { - return nil, fmt.Errorf("failed to parse olm payload: %w", err) - } - olmEvt.Type.Class = evt.Type.Class - if evt.Sender != olmEvt.Sender { - return nil, ErrSenderMismatch - } else if mach.Client.UserID != olmEvt.Recipient { - return nil, ErrRecipientMismatch - } else if mach.account.SigningKey() != olmEvt.RecipientKeys.Ed25519 { - return nil, ErrRecipientKeyMismatch - } - device, err := mach.CryptoStore.FindDeviceByKey(ctx, evt.Sender, senderKey) - if err != nil { - return nil, fmt.Errorf("failed to find device for sender key: %w", err) - } - if olmEvt.SenderDeviceKeys != nil { - if olmEvt.SenderDeviceKeys.UserID != olmEvt.Sender { - return nil, fmt.Errorf("sender_device_keys: %w", ErrSenderMismatch) - } else if olmEvt.SenderDeviceKeys.Keys.GetCurve25519(olmEvt.SenderDeviceKeys.DeviceID) != senderKey { - return nil, fmt.Errorf("sender_device_keys: %w", ErrSenderKeyMismatch) - } else if device, err = mach.validateDevice(evt.Sender, olmEvt.SenderDeviceKeys.DeviceID, *olmEvt.SenderDeviceKeys, device); err != nil { - return nil, fmt.Errorf("sender_device_keys: %w", err) - } - } else if device == nil && !mach.DisableDecryptKeyFetching && mach.keyFetchAttempted.Add(userSenderKeyTuple{evt.Sender, senderKey}) { - log.Warn().Msg("Olm sender device not found in store or event, fetching from server") - devices := mach.LoadDevices(ctx, evt.Sender) - for _, d := range devices { - if d.IdentityKey == senderKey { - device = d - break - } - } - } - if device == nil { - // TODO: drop these events in the future (allowed temporarily until all clients send sender_device_keys) - log.Warn().Msg("Olm sender device not found in store or event, but fetching is disabled or already attempted") - } - if device != nil && device.SigningKey != olmEvt.Keys.Ed25519 { - return nil, ErrSigningKeyMismatch - } - - if len(olmEvt.Content.VeryRaw) > 0 { - err = olmEvt.Content.ParseRaw(olmEvt.Type) - if err != nil && !errors.Is(err, event.ErrUnsupportedContentType) { - return nil, fmt.Errorf("failed to parse content of olm payload event: %w", err) - } - } - - olmEvt.SenderDevice = device - olmEvt.SenderKey = senderKey - - return &olmEvt, nil -} - -func olmMessageHash(ciphertext string) ([32]byte, error) { - if ciphertext == "" { - return [32]byte{}, fmt.Errorf("empty ciphertext") - } - ciphertextBytes, err := base64.RawStdEncoding.DecodeString(ciphertext) - return sha256.Sum256(ciphertextBytes), err -} - -func (mach *OlmMachine) tryDecryptOlmCiphertext(ctx context.Context, sender id.UserID, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string) ([]byte, error) { - ciphertextHash, err := olmMessageHash(ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to hash olm ciphertext: %w", err) - } - - log := *zerolog.Ctx(ctx) - endTimeTrace := mach.timeTrace(ctx, "waiting for olm lock", 5*time.Second) - mach.olmLock.Lock() - endTimeTrace() - defer mach.olmLock.Unlock() - - duplicateTS, err := mach.CryptoStore.GetOlmHash(ctx, ciphertextHash) - if err != nil { - log.Warn().Err(err).Msg("Failed to check for duplicate olm message") - } else if !duplicateTS.IsZero() { - log.Warn(). - Hex("ciphertext_hash", ciphertextHash[:]). - Time("duplicate_ts", duplicateTS). - Msg("Ignoring duplicate olm message") - return nil, ErrDuplicateMessage - } - - plaintext, err := mach.tryDecryptOlmCiphertextWithExistingSession(ctx, senderKey, olmType, ciphertext, ciphertextHash) - if err != nil { - if err == ErrDecryptionFailedWithMatchingSession { - log.Warn().Msg("Found matching session, but decryption failed") - go mach.unwedgeDevice(log, sender, senderKey) - } - return nil, fmt.Errorf("failed to decrypt olm event: %w", err) - } - - if plaintext != nil { - // Decryption successful - return plaintext, nil - } - - // Decryption failed with every known session or no known sessions, let's try to create a new session. - // - // New sessions can only be created if it's a prekey message, we can't decrypt the message - // if it isn't one at this point in time anymore, so return early. - if olmType != id.OlmMsgTypePreKey { - go mach.unwedgeDevice(log, sender, senderKey) - return nil, ErrDecryptionFailedForNormalMessage - } - - accountBackup, _ := mach.account.Internal.Pickle([]byte("tmp")) - log.Trace().Msg("Trying to create inbound session") - endTimeTrace = mach.timeTrace(ctx, "creating inbound olm session", time.Second) - session, err := mach.account.NewInboundSessionFrom(senderKey, ciphertext) - endTimeTrace() - if err != nil { - go mach.unwedgeDevice(log, sender, senderKey) - return nil, fmt.Errorf("failed to create new session from prekey message: %w", err) - } - log = log.With().Str("new_olm_session_id", session.ID().String()).Logger() - log.Debug(). - Hex("ciphertext_hash", ciphertextHash[:]). - Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). - Str("olm_session_description", session.Describe()). - Msg("Created inbound olm session") - ctx = log.WithContext(ctx) - - endTimeTrace = mach.timeTrace(ctx, "decrypting prekey olm message", time.Second) - plaintext, err = session.Decrypt(ciphertext, olmType) - endTimeTrace() - if err != nil { - log.Debug(). - Hex("ciphertext_hash", ciphertextHash[:]). - Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). - Str("ciphertext", ciphertext). - Str("olm_session_description", session.Describe()). - Msg("DEBUG: Failed to decrypt prekey olm message with newly created session") - err2 := mach.goolmRetryHack(ctx, senderKey, ciphertext, accountBackup) - if err2 != nil { - log.Debug().Err(err2).Msg("Goolm confirmed decryption failure") - } else { - log.Warn().Msg("Goolm decryption was successful after libolm failure?") - } - - go mach.unwedgeDevice(log, sender, senderKey) - return nil, fmt.Errorf("failed to decrypt olm event with session created from prekey message: %w", err) - } - - endTimeTrace = mach.timeTrace(ctx, "saving new session in database", time.Second) - err = mach.CryptoStore.PutOlmHash(ctx, ciphertextHash, time.Now()) - if err != nil { - log.Warn().Err(err).Msg("Failed to store olm message hash after decrypting") - } - err = mach.CryptoStore.AddSession(ctx, senderKey, session) - if err != nil { - log.Warn().Err(err).Msg("Failed to save new olm session in crypto store after decrypting") - } else if err = mach.account.Internal.RemoveOneTimeKeys(session.Internal); err != nil { - log.Warn().Err(err).Msg("Failed to remove one time keys from olm account after creating new session") - } else { - mach.saveAccount(ctx) - } - endTimeTrace() - return plaintext, nil -} - -func (mach *OlmMachine) goolmRetryHack(ctx context.Context, senderKey id.SenderKey, ciphertext string, accountBackup []byte) error { - acc, err := account.AccountFromPickled(accountBackup, []byte("tmp")) - if err != nil { - return fmt.Errorf("failed to unpickle olm account: %w", err) - } - sess, err := acc.NewInboundSessionFrom(&senderKey, ciphertext) - if err != nil { - return fmt.Errorf("failed to create inbound session: %w", err) - } - _, err = sess.Decrypt(ciphertext, id.OlmMsgTypePreKey) - if err != nil { - // This is the expected result if libolm failed - return fmt.Errorf("failed to decrypt with new session: %w", err) - } - return nil -} - -const MaxOlmSessionsPerDevice = 5 - -func (mach *OlmMachine) tryDecryptOlmCiphertextWithExistingSession( - ctx context.Context, senderKey id.SenderKey, olmType id.OlmMsgType, ciphertext string, ciphertextHash [32]byte, -) ([]byte, error) { - log := *zerolog.Ctx(ctx) - endTimeTrace := mach.timeTrace(ctx, "getting sessions with sender key", time.Second) - sessions, err := mach.CryptoStore.GetSessions(ctx, senderKey) - endTimeTrace() - if err != nil { - return nil, fmt.Errorf("failed to get session for %s: %w", senderKey, err) - } - if len(sessions) > MaxOlmSessionsPerDevice*2 { - // SQL store sorts sessions, but other implementations may not, so re-sort just in case - slices.SortFunc(sessions, func(a, b *OlmSession) int { - return b.LastDecryptedTime.Compare(a.LastDecryptedTime) - }) - log.Warn(). - Int("session_count", len(sessions)). - Time("newest_last_decrypted_at", sessions[0].LastDecryptedTime). - Time("oldest_last_decrypted_at", sessions[len(sessions)-1].LastDecryptedTime). - Msg("Too many sessions, deleting old ones") - for i := MaxOlmSessionsPerDevice; i < len(sessions); i++ { - err = mach.CryptoStore.DeleteSession(ctx, senderKey, sessions[i]) - if err != nil { - log.Warn().Err(err). - Stringer("olm_session_id", sessions[i].ID()). - Time("last_decrypt", sessions[i].LastDecryptedTime). - Msg("Failed to delete olm session") - } else { - log.Debug(). - Stringer("olm_session_id", sessions[i].ID()). - Time("last_decrypt", sessions[i].LastDecryptedTime). - Msg("Deleted olm session") - } - } - sessions = sessions[:MaxOlmSessionsPerDevice] - } - - for _, session := range sessions { - log := log.With().Str("olm_session_id", session.ID().String()).Logger() - ctx := log.WithContext(ctx) - if olmType == id.OlmMsgTypePreKey { - endTimeTrace = mach.timeTrace(ctx, "checking if prekey olm message matches session", time.Second) - matches, err := session.Internal.MatchesInboundSession(ciphertext) - endTimeTrace() - if err != nil { - return nil, fmt.Errorf("failed to check if ciphertext matches inbound session: %w", err) - } else if !matches { - continue - } - } - endTimeTrace = mach.timeTrace(ctx, "decrypting olm message", time.Second) - plaintext, err := session.Decrypt(ciphertext, olmType) - endTimeTrace() - if err != nil { - log.Warn().Err(err). - Hex("ciphertext_hash", ciphertextHash[:]). - Hex("ciphertext_hash_repeat", ptr.Ptr(exerrors.Must(olmMessageHash(ciphertext)))[:]). - Str("session_description", session.Describe()). - Msg("Failed to decrypt olm message") - if olmType == id.OlmMsgTypePreKey { - return nil, ErrDecryptionFailedWithMatchingSession - } - } else { - endTimeTrace = mach.timeTrace(ctx, "updating session in database", time.Second) - err = mach.CryptoStore.PutOlmHash(ctx, ciphertextHash, time.Now()) - if err != nil { - log.Warn().Err(err).Msg("Failed to store olm message hash after decrypting") - } - err = mach.CryptoStore.UpdateSession(ctx, senderKey, session) - endTimeTrace() - if err != nil { - log.Warn().Err(err).Msg("Failed to update olm session in crypto store after decrypting") - } - log.Debug(). - Hex("ciphertext_hash", ciphertextHash[:]). - Str("session_description", session.Describe()). - Msg("Decrypted olm message") - return plaintext, nil - } - } - return nil, nil -} - -const MinUnwedgeInterval = 1 * time.Hour - -func (mach *OlmMachine) unwedgeDevice(log zerolog.Logger, sender id.UserID, senderKey id.SenderKey) { - log = log.With().Str("action", "unwedge olm session").Logger() - ctx := log.WithContext(mach.backgroundCtx) - mach.recentlyUnwedgedLock.Lock() - prevUnwedge, ok := mach.recentlyUnwedged[senderKey] - delta := time.Since(prevUnwedge) - if ok && delta < MinUnwedgeInterval { - log.Debug(). - Str("previous_recreation", delta.String()). - Msg("Not creating new Olm session as it was already recreated recently") - mach.recentlyUnwedgedLock.Unlock() - return - } - mach.recentlyUnwedged[senderKey] = time.Now() - mach.recentlyUnwedgedLock.Unlock() - - lastCreatedAt, err := mach.CryptoStore.GetNewestSessionCreationTS(ctx, senderKey) - if err != nil { - log.Warn().Err(err).Msg("Failed to get newest session creation timestamp") - return - } else if time.Since(lastCreatedAt) < MinUnwedgeInterval { - log.Debug(). - Time("last_created_at", lastCreatedAt). - Msg("Not creating new Olm session as it was already recreated recently") - return - } - - deviceIdentity, err := mach.GetOrFetchDeviceByKey(ctx, sender, senderKey) - if err != nil { - log.Error().Err(err).Msg("Failed to find device info by identity key") - return - } else if deviceIdentity == nil { - log.Warn().Msg("Didn't find identity for device") - return - } - - log.Debug(). - Time("last_created", lastCreatedAt). - Stringer("device_id", deviceIdentity.DeviceID). - Msg("Creating new Olm session") - mach.devicesToUnwedgeLock.Lock() - mach.devicesToUnwedge[senderKey] = true - mach.devicesToUnwedgeLock.Unlock() - err = mach.SendEncryptedToDevice(ctx, deviceIdentity, event.ToDeviceDummy, event.Content{}) - if err != nil { - log.Error().Err(err).Msg("Failed to send dummy event to unwedge session") - } -} diff --git a/mautrix-patched/crypto/devicelist.go b/mautrix-patched/crypto/devicelist.go deleted file mode 100644 index 6e6c4c76..00000000 --- a/mautrix-patched/crypto/devicelist.go +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "errors" - "fmt" - "slices" - "strings" - - "github.com/rs/zerolog" - "go.mau.fi/util/exzerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/id" -) - -var ( - ErrMismatchingDeviceID = errors.New("mismatching device ID in parameter and keys object") - ErrMismatchingUserID = errors.New("mismatching user ID in parameter and keys object") - ErrMismatchingSigningKey = errors.New("received update for device with different signing key") - ErrNoSigningKeyFound = errors.New("didn't find ed25519 signing key") - ErrNoIdentityKeyFound = errors.New("didn't find curve25519 identity key") - ErrInvalidKeySignature = errors.New("invalid signature on device keys") - ErrUserNotTracked = errors.New("user is not tracked") -) - -// Deprecated: use variables prefixed with Err -var ( - MismatchingDeviceID = ErrMismatchingDeviceID - MismatchingUserID = ErrMismatchingUserID - MismatchingSigningKey = ErrMismatchingSigningKey - NoSigningKeyFound = ErrNoSigningKeyFound - NoIdentityKeyFound = ErrNoIdentityKeyFound - InvalidKeySignature = ErrInvalidKeySignature -) - -func (mach *OlmMachine) LoadDevices(ctx context.Context, user id.UserID) (keys map[id.DeviceID]*id.Device) { - log := zerolog.Ctx(ctx) - - if keys, err := mach.FetchKeys(ctx, []id.UserID{user}, true); err != nil { - log.Err(err).Msg("Failed to load devices") - } else if keys != nil { - return keys[user] - } - - return nil -} - -type CachedDevices struct { - Devices []*id.Device - MasterKey *id.CrossSigningKey - HasValidSelfSigningKey bool - MasterKeySignedByUs bool -} - -func (mach *OlmMachine) GetCachedDevices(ctx context.Context, userID id.UserID) (*CachedDevices, error) { - userIDs, err := mach.CryptoStore.FilterTrackedUsers(ctx, []id.UserID{userID}) - if err != nil { - return nil, fmt.Errorf("failed to check if user's devices are tracked: %w", err) - } else if len(userIDs) == 0 { - return nil, ErrUserNotTracked - } - ownKeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } - var ownUserSigningKey id.Ed25519 - if ownKeys != nil { - ownUserSigningKey = ownKeys.UserSigningKey - } - var resp CachedDevices - csKeys, err := mach.CryptoStore.GetCrossSigningKeys(ctx, userID) - theirMasterKey := csKeys[id.XSUsageMaster] - theirSelfSignKey := csKeys[id.XSUsageSelfSigning] - if err != nil { - return nil, fmt.Errorf("failed to get cross-signing keys: %w", err) - } else if csKeys != nil && theirMasterKey.Key != "" { - resp.MasterKey = &theirMasterKey - if theirSelfSignKey.Key != "" { - resp.HasValidSelfSigningKey, err = mach.CryptoStore.IsKeySignedBy(ctx, userID, theirSelfSignKey.Key, userID, theirMasterKey.Key) - if err != nil { - return nil, fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err) - } - } - } - devices, err := mach.CryptoStore.GetDevices(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to get devices: %w", err) - } - if userID == mach.Client.UserID { - if ownKeys != nil && ownKeys.MasterKey == theirMasterKey.Key { - resp.MasterKeySignedByUs, _, _, err = mach.GetOwnCrossSigningVerificationStatus(ctx) - if err != nil { - return nil, fmt.Errorf("failed to check if own cross-signing keys are trusted: %w", err) - } - } - } else if ownUserSigningKey != "" && theirMasterKey.Key != "" { - resp.MasterKeySignedByUs, err = mach.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, mach.Client.UserID, ownUserSigningKey) - if resp.MasterKeySignedByUs { - ownMKTrusted, _, ownUSKTrusted, err := mach.GetOwnCrossSigningVerificationStatus(ctx) - if err != nil { - return nil, fmt.Errorf("failed to check if own cross-signing keys are trusted: %w", err) - } else if !ownMKTrusted || !ownUSKTrusted { - // TODO log warning? - resp.MasterKeySignedByUs = false - } - } - } - if err != nil { - return nil, fmt.Errorf("failed to check if user is trusted: %w", err) - } - resp.Devices = make([]*id.Device, len(devices)) - i := 0 - for _, device := range devices { - resp.Devices[i] = device - if resp.HasValidSelfSigningKey && device.Trust == id.TrustStateUnset { - signed, err := mach.CryptoStore.IsKeySignedBy(ctx, device.UserID, device.SigningKey, device.UserID, theirSelfSignKey.Key) - if err != nil { - return nil, fmt.Errorf("failed to check if device %s is signed by self-signing key: %w", device.DeviceID, err) - } else if signed { - if resp.MasterKeySignedByUs { - device.Trust = id.TrustStateCrossSignedVerified - } else if theirMasterKey.Key == theirMasterKey.First { - device.Trust = id.TrustStateCrossSignedTOFU - } else { - device.Trust = id.TrustStateCrossSignedUntrusted - } - } - } - i++ - } - slices.SortFunc(resp.Devices, func(a, b *id.Device) int { - return strings.Compare(a.DeviceID.String(), b.DeviceID.String()) - }) - return &resp, nil -} - -func (mach *OlmMachine) storeDeviceSelfSignatures(ctx context.Context, userID id.UserID, deviceID id.DeviceID, resp *mautrix.RespQueryKeys) { - log := zerolog.Ctx(ctx) - deviceKeys := resp.DeviceKeys[userID][deviceID] - for signerUserID, signerKeys := range deviceKeys.Signatures { - for signerKey, signature := range signerKeys { - // verify and save self-signing key signature for each device - if selfSignKeys, ok := resp.SelfSigningKeys[signerUserID]; ok { - for _, pubKey := range selfSignKeys.Keys { - if selfSigs, ok := deviceKeys.Signatures[signerUserID]; !ok { - continue - } else if _, ok := selfSigs[id.NewKeyID(id.KeyAlgorithmEd25519, pubKey.String())]; !ok { - continue - } - if verified, err := signatures.VerifySignatureJSON(deviceKeys, signerUserID, pubKey.String(), pubKey); verified { - if signKey, ok := deviceKeys.Keys[id.DeviceKeyID(signerKey)]; ok { - signature := deviceKeys.Signatures[signerUserID][id.NewKeyID(id.KeyAlgorithmEd25519, pubKey.String())] - log.Trace().Err(err). - Str("signer_user_id", signerUserID.String()). - Str("signed_device_id", deviceID.String()). - Str("signature", signature). - Msg("Verified self-signing signature") - err = mach.CryptoStore.PutSignature(ctx, userID, id.Ed25519(signKey), signerUserID, pubKey, signature) - if err != nil { - log.Warn().Err(err). - Str("signer_user_id", signerUserID.String()). - Str("signed_device_id", deviceID.String()). - Msg("Failed to store self-signing signature") - } - } - } else { - if err == nil { - err = errors.New("invalid signature") - } - log.Warn().Err(err). - Str("signer_user_id", signerUserID.String()). - Str("signed_device_id", deviceID.String()). - Msg("Failed to verify self-signing signature") - } - } - } - // save signature of device made by its own device signing key - if signKey, ok := deviceKeys.Keys[id.DeviceKeyID(signerKey)]; ok { - err := mach.CryptoStore.PutSignature(ctx, userID, id.Ed25519(signKey), signerUserID, id.Ed25519(signKey), signature) - if err != nil { - log.Warn().Err(err). - Str("signer_user_id", signerUserID.String()). - Str("signer_key", signKey). - Msg("Failed to store self-signing signature") - } - } - } - } -} - -// FetchKeys fetches the devices of a list of other users. If includeUntracked -// is set to false, then the users are filtered to to only include user IDs -// whose device lists have been stored with the PutDevices function on the -// [Store]. See the FilterTrackedUsers function on [Store] for details. -func (mach *OlmMachine) FetchKeys(ctx context.Context, users []id.UserID, includeUntracked bool) (data map[id.UserID]map[id.DeviceID]*id.Device, err error) { - req := &mautrix.ReqQueryKeys{ - DeviceKeys: mautrix.DeviceKeysRequest{}, - Timeout: 10 * 1000, - } - log := mach.machOrContextLog(ctx) - if !includeUntracked { - users, err = mach.CryptoStore.FilterTrackedUsers(ctx, users) - if err != nil { - return nil, fmt.Errorf("failed to filter tracked user list: %w", err) - } - } - if len(users) == 0 { - return - } - for _, userID := range users { - req.DeviceKeys[userID] = mautrix.DeviceIDList{} - } - log.Debug().Array("users", exzerolog.ArrayOfStrs(users)).Msg("Querying keys for users") - resp, err := mach.Client.QueryKeys(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to query keys: %w", err) - } - for server, err := range resp.Failures { - log.Warn().Interface("query_error", err).Str("server", server).Msg("Query keys failure for server") - } - log.Trace().Int("user_count", len(resp.DeviceKeys)).Msg("Query key result received") - data = make(map[id.UserID]map[id.DeviceID]*id.Device) - for userID, devices := range resp.DeviceKeys { - log := log.With().Stringer("user_id", userID).Logger() - delete(req.DeviceKeys, userID) - - newDevices := make(map[id.DeviceID]*id.Device) - existingDevices, err := mach.CryptoStore.GetDevices(ctx, userID) - if err != nil { - log.Warn().Err(err).Msg("Failed to get existing devices for user") - existingDevices = make(map[id.DeviceID]*id.Device) - } - - log.Debug(). - Int("new_device_count", len(devices)). - Int("old_device_count", len(existingDevices)). - Msg("Updating devices in store") - changed := false - for deviceID, deviceKeys := range devices { - log := log.With().Stringer("device_id", deviceID).Logger() - existing, existed := existingDevices[deviceID] - log.Trace().Msg("Validating device") - newDevice, err := mach.validateDevice(userID, deviceID, deviceKeys, existing) - if err != nil { - log.Error().Err(err).Msg("Failed to validate device") - } else if newDevice != nil { - newDevices[deviceID] = newDevice - mach.storeDeviceSelfSignatures(ctx, userID, deviceID, resp) - if !existed { - // New device, always mark as changed so megolm keys are reset even if - // the total count stays the same (e.g. one device added and one removed). - changed = true - } - } - } - log.Trace().Int("new_device_count", len(newDevices)).Msg("Storing new device list") - err = mach.CryptoStore.PutDevices(ctx, userID, newDevices) - if err != nil { - log.Warn().Err(err).Msg("Failed to update device list") - } - data[userID] = newDevices - - changed = changed || len(newDevices) != len(existingDevices) - if changed { - if mach.DeleteKeysOnDeviceDelete { - for deviceID := range newDevices { - delete(existingDevices, deviceID) - } - for _, device := range existingDevices { - log := log.With(). - Str("device_id", device.DeviceID.String()). - Str("identity_key", device.IdentityKey.String()). - Str("signing_key", device.SigningKey.String()). - Logger() - sessionIDs, err := mach.CryptoStore.RedactGroupSessions(ctx, "", device.IdentityKey, "device removed") - if err != nil { - log.Err(err).Msg("Failed to redact megolm sessions from deleted device") - } else { - log.Info(). - Array("session_ids", exzerolog.ArrayOfStrs(sessionIDs)). - Msg("Redacted megolm sessions from deleted device") - } - } - } - mach.OnDevicesChanged(ctx, userID) - } - } - for userID := range req.DeviceKeys { - log.Warn().Stringer("user_id", userID).Msg("Didn't get any keys for user") - } - - mach.storeCrossSigningKeys(ctx, resp.MasterKeys, resp.DeviceKeys) - mach.storeCrossSigningKeys(ctx, resp.SelfSigningKeys, resp.DeviceKeys) - mach.storeCrossSigningKeys(ctx, resp.UserSigningKeys, resp.DeviceKeys) - - return data, nil -} - -// OnDevicesChanged finds all shared rooms with the given user and invalidates outbound sessions in those rooms. -// -// This is called automatically whenever a device list change is noticed in ProcessSyncResponse and usually does -// not need to be called manually. -func (mach *OlmMachine) OnDevicesChanged(ctx context.Context, userID id.UserID) { - if mach.DisableDeviceChangeKeyRotation { - return - } - rooms, err := mach.StateStore.FindSharedRooms(ctx, userID) - if err != nil { - mach.machOrContextLog(ctx).Err(err). - Stringer("with_user_id", userID). - Msg("Failed to find shared rooms to invalidate group sessions") - return - } - for _, roomID := range rooms { - mach.machOrContextLog(ctx).Debug(). - Str("user_id", userID.String()). - Str("room_id", roomID.String()). - Msg("Invalidating group session in room due to device change notification") - err = mach.CryptoStore.RemoveOutboundGroupSession(ctx, roomID) - if err != nil { - mach.machOrContextLog(ctx).Err(err). - Str("user_id", userID.String()). - Str("room_id", roomID.String()). - Msg("Failed to invalidate outbound group session") - } - } -} - -func (mach *OlmMachine) validateDevice(userID id.UserID, deviceID id.DeviceID, deviceKeys mautrix.DeviceKeys, existing *id.Device) (*id.Device, error) { - if deviceID != deviceKeys.DeviceID { - return nil, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingDeviceID, deviceID, deviceKeys.DeviceID) - } else if userID != deviceKeys.UserID { - return nil, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingUserID, userID, deviceKeys.UserID) - } - - signingKey := deviceKeys.Keys.GetEd25519(deviceID) - identityKey := deviceKeys.Keys.GetCurve25519(deviceID) - if signingKey == "" { - return nil, ErrNoSigningKeyFound - } else if identityKey == "" { - return nil, ErrNoIdentityKeyFound - } - - if existing != nil && existing.SigningKey != signingKey { - return existing, fmt.Errorf("%w (expected %s, got %s)", ErrMismatchingSigningKey, existing.SigningKey, signingKey) - } - - ok, err := signatures.VerifySignatureJSON(deviceKeys, userID, deviceID.String(), signingKey) - if err != nil { - return existing, fmt.Errorf("failed to verify signature: %w", err) - } else if !ok { - return existing, ErrInvalidKeySignature - } - - name, ok := deviceKeys.Unsigned["device_display_name"].(string) - if !ok { - if deviceKeys.Dehydrated { - name = "Dehydrated device" - } else { - name = string(deviceID) - } - } - - // This trust state doesn't matter much as most trust happens through cross-signing, but preserve it anyway. - trust := id.TrustStateUnset - if existing != nil { - trust = existing.Trust - } - return &id.Device{ - UserID: userID, - DeviceID: deviceID, - IdentityKey: identityKey, - SigningKey: signingKey, - Trust: trust, - Name: name, - Deleted: false, - }, nil -} diff --git a/mautrix-patched/crypto/ed25519/ed25519.go b/mautrix-patched/crypto/ed25519/ed25519.go deleted file mode 100644 index 327cbb3c..00000000 --- a/mautrix-patched/crypto/ed25519/ed25519.go +++ /dev/null @@ -1,302 +0,0 @@ -// Copyright 2024 Sumner Evans. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package ed25519 implements the Ed25519 signature algorithm. See -// https://ed25519.cr.yp.to/. -// -// This package stores the private key in the NaCl format, which is a different -// format than that used by the [crypto/ed25519] package in the standard -// library. -// -// This picture will help with the rest of the explanation: -// https://blog.mozilla.org/warner/files/2011/11/key-formats.png -// -// The private key in the [crypto/ed25519] package is a 64-byte value where the -// first 32-bytes are the seed and the last 32-bytes are the public key. -// -// The private key in this package is stored in the NaCl format. That is, the -// left 32-bytes are the private scalar A and the right 32-bytes are the right -// half of the SHA512 result. -// -// The contents of this package are mostly copied from the standard library, -// and as such the source code is licensed under the BSD license of the -// standard library implementation. -// -// Other notable changes from the standard library include: -// -// - The Seed function of the standard library is not implemented in this -// package because there is no way to recover the seed after hashing it. -package ed25519 - -import ( - "crypto" - "crypto/ed25519" - cryptorand "crypto/rand" - "crypto/sha512" - "crypto/subtle" - "errors" - "io" - "strconv" - - "filippo.io/edwards25519" -) - -const ( - // PublicKeySize is the size, in bytes, of public keys as used in this package. - PublicKeySize = 32 - // PrivateKeySize is the size, in bytes, of private keys as used in this package. - PrivateKeySize = 64 - // SignatureSize is the size, in bytes, of signatures generated and verified by this package. - SignatureSize = 64 - // SeedSize is the size, in bytes, of private key seeds. These are the private key representations used by RFC 8032. - SeedSize = 32 -) - -// PublicKey is the type of Ed25519 public keys. -type PublicKey []byte - -// Any methods implemented on PublicKey might need to also be implemented on -// PrivateKey, as the latter embeds the former and will expose its methods. - -// Equal reports whether pub and x have the same value. -func (pub PublicKey) Equal(x crypto.PublicKey) bool { - switch x := x.(type) { - case PublicKey: - return subtle.ConstantTimeCompare(pub, x) == 1 - case ed25519.PublicKey: - return subtle.ConstantTimeCompare(pub, x) == 1 - default: - return false - } -} - -// PrivateKey is the type of Ed25519 private keys. It implements [crypto.Signer]. -type PrivateKey []byte - -// Public returns the [PublicKey] corresponding to priv. -// -// This method differs from the standard library because it calculates the -// public key instead of returning the right half of the private key (which -// contains the public key in the standard library). -func (priv PrivateKey) Public() crypto.PublicKey { - s, err := edwards25519.NewScalar().SetBytesWithClamping(priv[:32]) - if err != nil { - panic("ed25519: internal error: setting scalar failed") - } - return (&edwards25519.Point{}).ScalarBaseMult(s).Bytes() -} - -// Equal reports whether priv and x have the same value. -func (priv PrivateKey) Equal(x crypto.PrivateKey) bool { - // TODO do we have any need to check equality with standard library ed25519 - // private keys? - xx, ok := x.(PrivateKey) - if !ok { - return false - } - return subtle.ConstantTimeCompare(priv, xx) == 1 -} - -// Sign signs the given message with priv. rand is ignored and can be nil. -// -// If opts.HashFunc() is [crypto.SHA512], the pre-hashed variant Ed25519ph is used -// and message is expected to be a SHA-512 hash, otherwise opts.HashFunc() must -// be [crypto.Hash](0) and the message must not be hashed, as Ed25519 performs two -// passes over messages to be signed. -// -// A value of type [Options] can be used as opts, or crypto.Hash(0) or -// crypto.SHA512 directly to select plain Ed25519 or Ed25519ph, respectively. -func (priv PrivateKey) Sign(rand io.Reader, message []byte, opts crypto.SignerOpts) (signature []byte, err error) { - hash := opts.HashFunc() - context := "" - if opts, ok := opts.(*Options); ok { - context = opts.Context - } - switch { - case hash == crypto.SHA512: // Ed25519ph - if l := len(message); l != sha512.Size { - return nil, errors.New("ed25519: bad Ed25519ph message hash length: " + strconv.Itoa(l)) - } - if l := len(context); l > 255 { - return nil, errors.New("ed25519: bad Ed25519ph context length: " + strconv.Itoa(l)) - } - signature := make([]byte, SignatureSize) - sign(signature, priv, message, domPrefixPh, context) - return signature, nil - case hash == crypto.Hash(0) && context != "": // Ed25519ctx - if l := len(context); l > 255 { - return nil, errors.New("ed25519: bad Ed25519ctx context length: " + strconv.Itoa(l)) - } - signature := make([]byte, SignatureSize) - sign(signature, priv, message, domPrefixCtx, context) - return signature, nil - case hash == crypto.Hash(0): // Ed25519 - return Sign(priv, message), nil - default: - return nil, errors.New("ed25519: expected opts.HashFunc() zero (unhashed message, for standard Ed25519) or SHA-512 (for Ed25519ph)") - } -} - -// Options can be used with [PrivateKey.Sign] or [VerifyWithOptions] -// to select Ed25519 variants. -type Options struct { - // Hash can be zero for regular Ed25519, or crypto.SHA512 for Ed25519ph. - Hash crypto.Hash - - // Context, if not empty, selects Ed25519ctx or provides the context string - // for Ed25519ph. It can be at most 255 bytes in length. - Context string -} - -// HashFunc returns o.Hash. -func (o *Options) HashFunc() crypto.Hash { return o.Hash } - -// GenerateKey generates a public/private key pair using entropy from rand. -// If rand is nil, [crypto/rand.Reader] will be used. -// -// The output of this function is deterministic, and equivalent to reading -// [SeedSize] bytes from rand, and passing them to [NewKeyFromSeed]. -func GenerateKey(rand io.Reader) (PublicKey, PrivateKey, error) { - if rand == nil { - rand = cryptorand.Reader - } - - seed := make([]byte, SeedSize) - if _, err := io.ReadFull(rand, seed); err != nil { - return nil, nil, err - } - - privateKey := NewKeyFromSeed(seed) - return PublicKey(privateKey.Public().([]byte)), privateKey, nil -} - -// NewKeyFromSeed calculates a private key from a seed. It will panic if -// len(seed) is not [SeedSize]. This function is provided for interoperability -// with RFC 8032. RFC 8032's private keys correspond to seeds in this -// package. -func NewKeyFromSeed(seed []byte) PrivateKey { - // Outline the function body so that the returned key can be stack-allocated. - privateKey := make([]byte, PrivateKeySize) - newKeyFromSeed(privateKey, seed) - return privateKey -} - -func newKeyFromSeed(privateKey, seed []byte) { - if l := len(seed); l != SeedSize { - panic("ed25519: bad seed length: " + strconv.Itoa(l)) - } - - h := sha512.Sum512(seed) - - // Apply clamping to get A in the left half, and leave the right half - // as-is. This gets the private key into the NaCl format. - h[0] &= 248 - h[31] &= 63 - h[31] |= 64 - copy(privateKey, h[:]) -} - -// Sign signs the message with privateKey and returns a signature. It will -// panic if len(privateKey) is not [PrivateKeySize]. -func Sign(privateKey PrivateKey, message []byte) []byte { - // Outline the function body so that the returned signature can be - // stack-allocated. - signature := make([]byte, SignatureSize) - sign(signature, privateKey, message, domPrefixPure, "") - return signature -} - -// Domain separation prefixes used to disambiguate Ed25519/Ed25519ph/Ed25519ctx. -// See RFC 8032, Section 2 and Section 5.1. -const ( - // domPrefixPure is empty for pure Ed25519. - domPrefixPure = "" - // domPrefixPh is dom2(phflag=1) for Ed25519ph. It must be followed by the - // uint8-length prefixed context. - domPrefixPh = "SigEd25519 no Ed25519 collisions\x01" - // domPrefixCtx is dom2(phflag=0) for Ed25519ctx. It must be followed by the - // uint8-length prefixed context. - domPrefixCtx = "SigEd25519 no Ed25519 collisions\x00" -) - -func sign(signature []byte, privateKey PrivateKey, message []byte, domPrefix, context string) { - if l := len(privateKey); l != PrivateKeySize { - panic("ed25519: bad private key length: " + strconv.Itoa(l)) - } - // We have to extract the public key from the private key. - publicKey := privateKey.Public().([]byte) - // The private key is already the hashed value of the seed. - h := privateKey - - s, err := edwards25519.NewScalar().SetBytesWithClamping(h[:32]) - if err != nil { - panic("ed25519: internal error: setting scalar failed") - } - prefix := h[32:] - - mh := sha512.New() - if domPrefix != domPrefixPure { - mh.Write([]byte(domPrefix)) - mh.Write([]byte{byte(len(context))}) - mh.Write([]byte(context)) - } - mh.Write(prefix) - mh.Write(message) - messageDigest := make([]byte, 0, sha512.Size) - messageDigest = mh.Sum(messageDigest) - r, err := edwards25519.NewScalar().SetUniformBytes(messageDigest) - if err != nil { - panic("ed25519: internal error: setting scalar failed") - } - - R := (&edwards25519.Point{}).ScalarBaseMult(r) - - kh := sha512.New() - if domPrefix != domPrefixPure { - kh.Write([]byte(domPrefix)) - kh.Write([]byte{byte(len(context))}) - kh.Write([]byte(context)) - } - kh.Write(R.Bytes()) - kh.Write(publicKey) - kh.Write(message) - hramDigest := make([]byte, 0, sha512.Size) - hramDigest = kh.Sum(hramDigest) - k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest) - if err != nil { - panic("ed25519: internal error: setting scalar failed") - } - - S := edwards25519.NewScalar().MultiplyAdd(k, s, r) - - copy(signature[:32], R.Bytes()) - copy(signature[32:], S.Bytes()) -} - -// Verify reports whether sig is a valid signature of message by publicKey. It -// will panic if len(publicKey) is not [PublicKeySize]. -// -// This is just a wrapper around [ed25519.Verify] from the standard library. -func Verify(publicKey PublicKey, message, sig []byte) bool { - return ed25519.Verify(ed25519.PublicKey(publicKey), message, sig) -} - -// VerifyWithOptions reports whether sig is a valid signature of message by -// publicKey. A valid signature is indicated by returning a nil error. It will -// panic if len(publicKey) is not [PublicKeySize]. -// -// If opts.Hash is [crypto.SHA512], the pre-hashed variant Ed25519ph is used and -// message is expected to be a SHA-512 hash, otherwise opts.Hash must be -// [crypto.Hash](0) and the message must not be hashed, as Ed25519 performs two -// passes over messages to be signed. -// -// This is just a wrapper around [ed25519.VerifyWithOptions] from the standard -// library. -func VerifyWithOptions(publicKey PublicKey, message, sig []byte, opts *Options) error { - return ed25519.VerifyWithOptions(ed25519.PublicKey(publicKey), message, sig, &ed25519.Options{ - Hash: opts.Hash, - Context: opts.Context, - }) -} diff --git a/mautrix-patched/crypto/ed25519/ed25519_test.go b/mautrix-patched/crypto/ed25519/ed25519_test.go deleted file mode 100644 index 931c06f6..00000000 --- a/mautrix-patched/crypto/ed25519/ed25519_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package ed25519_test - -import ( - stdlibed25519 "crypto/ed25519" - "testing" - - "github.com/stretchr/testify/assert" - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/crypto/ed25519" -) - -func TestPubkeyEqual(t *testing.T) { - pubkeyBytes := random.Bytes(32) - pubkey := ed25519.PublicKey(pubkeyBytes) - pubkey2 := ed25519.PublicKey(pubkeyBytes) - stdlibPubkey := stdlibed25519.PublicKey(pubkeyBytes) - assert.True(t, pubkey.Equal(pubkey2)) - assert.True(t, pubkey.Equal(stdlibPubkey)) -} diff --git a/mautrix-patched/crypto/encryptmegolm.go b/mautrix-patched/crypto/encryptmegolm.go deleted file mode 100644 index 88f9c8d4..00000000 --- a/mautrix-patched/crypto/encryptmegolm.go +++ /dev/null @@ -1,463 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - - "github.com/rs/zerolog" - "github.com/tidwall/gjson" - "go.mau.fi/util/exgjson" - "go.mau.fi/util/exzerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ( - ErrNoGroupSession = errors.New("no group session created") -) - -// Deprecated: use variables prefixed with Err -var ( - NoGroupSession = ErrNoGroupSession -) - -func getRawJSON[T any](content json.RawMessage, path ...string) *T { - value := gjson.GetBytes(content, exgjson.Path(path...)) - if !value.IsObject() { - return nil - } - var result T - err := json.Unmarshal([]byte(value.Raw), &result) - if err != nil { - return nil - } - return &result -} - -func getRelatesTo(content any, plaintext json.RawMessage) *event.RelatesTo { - contentJSON, ok := content.(json.RawMessage) - if ok { - return getRawJSON[event.RelatesTo](contentJSON, "m.relates_to") - } - contentStruct, ok := content.(*event.Content) - if ok { - content = contentStruct.Parsed - } - relatable, ok := content.(event.Relatable) - if ok { - return relatable.OptionalGetRelatesTo() - } - return getRawJSON[event.RelatesTo](plaintext, "content", "m.relates_to") -} - -func getMentions(content any) *event.Mentions { - contentJSON, ok := content.(json.RawMessage) - if ok { - return getRawJSON[event.Mentions](contentJSON, "m.mentions") - } - contentStruct, ok := content.(*event.Content) - if ok { - content = contentStruct.Parsed - } - message, ok := content.(*event.MessageEventContent) - if ok { - return message.Mentions - } - return nil -} - -type rawMegolmEvent struct { - RoomID id.RoomID `json:"room_id"` - Type event.Type `json:"type"` - StateKey *string `json:"state_key,omitempty"` - Content interface{} `json:"content"` -} - -// IsShareError returns true if the error is caused by the lack of an outgoing megolm session and can be solved with OlmMachine.ShareGroupSession -func IsShareError(err error) bool { - return err == ErrSessionExpired || err == ErrSessionNotShared || err == ErrNoGroupSession -} - -func ParseMegolmMessageIndex(ciphertext []byte) (uint, error) { - if len(ciphertext) == 0 { - return 0, fmt.Errorf("empty ciphertext") - } - decoded := make([]byte, base64.RawStdEncoding.DecodedLen(len(ciphertext))) - var err error - _, err = base64.RawStdEncoding.Decode(decoded, ciphertext) - if err != nil { - return 0, err - } else if len(decoded) < 2+binary.MaxVarintLen64 { - return 0, fmt.Errorf("decoded ciphertext too short: %d bytes", len(decoded)) - } else if decoded[0] != 3 || decoded[1] != 8 { - return 0, fmt.Errorf("unexpected initial bytes %d and %d", decoded[0], decoded[1]) - } - index, read := binary.Uvarint(decoded[2 : 2+binary.MaxVarintLen64]) - if read <= 0 { - return 0, fmt.Errorf("failed to decode varint, read value %d", read) - } - return uint(index), nil -} - -// EncryptMegolmEvent encrypts data with the m.megolm.v1.aes-sha2 algorithm. -// -// If you use the event.Content struct, make sure you pass a pointer to the struct, -// as JSON serialization will not work correctly otherwise. -func (mach *OlmMachine) EncryptMegolmEvent(ctx context.Context, roomID id.RoomID, evtType event.Type, content interface{}) (*event.EncryptedEventContent, error) { - return mach.EncryptMegolmEventWithStateKey(ctx, roomID, evtType, nil, content) -} - -// EncryptMegolmEventWithStateKey encrypts data with the m.megolm.v1.aes-sha2 algorithm. -// -// If you use the event.Content struct, make sure you pass a pointer to the struct, -// as JSON serialization will not work correctly otherwise. -func (mach *OlmMachine) EncryptMegolmEventWithStateKey(ctx context.Context, roomID id.RoomID, evtType event.Type, stateKey *string, content interface{}) (*event.EncryptedEventContent, error) { - mach.megolmEncryptLock.Lock() - defer mach.megolmEncryptLock.Unlock() - session, err := mach.CryptoStore.GetOutboundGroupSession(ctx, roomID) - if err != nil { - return nil, fmt.Errorf("failed to get outbound group session: %w", err) - } else if session == nil { - return nil, ErrNoGroupSession - } - plaintext, err := json.Marshal(&rawMegolmEvent{ - RoomID: roomID, - Type: evtType, - StateKey: stateKey, - Content: content, - }) - if err != nil { - return nil, err - } - log := mach.machOrContextLog(ctx).With(). - Str("event_type", evtType.Type). - Any("state_key", stateKey). - Str("room_id", roomID.String()). - Str("session_id", session.ID().String()). - Uint("expected_index", session.Internal.MessageIndex()). - Logger() - log.Trace().Msg("Encrypting event...") - ciphertext, err := session.Encrypt(plaintext) - if err != nil { - return nil, err - } - idx, err := ParseMegolmMessageIndex(ciphertext) - if err != nil { - log.Warn().Err(err).Msg("Failed to get megolm message index of encrypted event") - } else { - log = log.With().Uint("message_index", idx).Logger() - } - log.Debug().Msg("Encrypted event successfully") - err = mach.CryptoStore.UpdateOutboundGroupSession(ctx, session) - if err != nil { - return nil, fmt.Errorf("failed to update outbound group session after encrypting: %w", err) - } - encrypted := &event.EncryptedEventContent{ - Algorithm: id.AlgorithmMegolmV1, - SessionID: session.ID(), - MegolmCiphertext: ciphertext, - RelatesTo: getRelatesTo(content, plaintext), - - // These are deprecated - SenderKey: mach.account.IdentityKey(), - DeviceID: mach.Client.DeviceID, - } - if mach.MSC4392Relations && encrypted.RelatesTo != nil { - // When MSC4392 mode is enabled, reply and reaction metadata is stripped from the unencrypted content. - // Other relations like threads are still left unencrypted. - encrypted.RelatesTo.InReplyTo = nil - encrypted.RelatesTo.IsFallingBack = false - if evtType == event.EventReaction || encrypted.RelatesTo.Type == "" { - encrypted.RelatesTo = nil - } - } - if mach.PlaintextMentions { - encrypted.Mentions = getMentions(content) - } - return encrypted, nil -} - -func (mach *OlmMachine) newOutboundGroupSession(ctx context.Context, roomID id.RoomID) (*OutboundGroupSession, error) { - encryptionEvent, err := mach.StateStore.GetEncryptionEvent(ctx, roomID) - if err != nil { - mach.machOrContextLog(ctx).Err(err). - Stringer("room_id", roomID). - Msg("Failed to get encryption event in room") - return nil, fmt.Errorf("failed to get encryption event in room %s: %w", roomID, err) - } - session, err := NewOutboundGroupSession(roomID, encryptionEvent) - if err != nil { - return nil, err - } - if !mach.DontStoreOutboundKeys { - signingKey, idKey := mach.account.Keys() - err := mach.createGroupSession(ctx, idKey, signingKey, roomID, session.ID(), session.Internal.Key(), session.MaxAge, session.MaxMessages, false) - if err != nil { - return nil, err - } - } - return session, err -} - -type deviceSessionWrapper struct { - session *OlmSession - identity *id.Device -} - -// ShareGroupSession shares a group session for a specific room with all the devices of the given user list. -// -// For devices with TrustStateBlacklisted, a m.room_key.withheld event with code=m.blacklisted is sent. -// If AllowUnverifiedDevices is false, a similar event with code=m.unverified is sent to devices with TrustStateUnset -func (mach *OlmMachine) ShareGroupSession(ctx context.Context, roomID id.RoomID, users []id.UserID) error { - mach.megolmEncryptLock.Lock() - defer mach.megolmEncryptLock.Unlock() - session, err := mach.CryptoStore.GetOutboundGroupSession(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to get previous outbound group session: %w", err) - } else if session != nil && session.Shared && !session.Expired() { - mach.machOrContextLog(ctx).Debug().Stringer("room_id", roomID).Msg("Not re-sharing group session, already shared") - return nil - } - log := mach.machOrContextLog(ctx).With(). - Str("room_id", roomID.String()). - Str("action", "share megolm session"). - Logger() - ctx = log.WithContext(ctx) - if session == nil || session.Expired() { - if session, err = mach.newOutboundGroupSession(ctx, roomID); err != nil { - return err - } - } - log = log.With().Str("session_id", session.ID().String()).Logger() - ctx = log.WithContext(ctx) - log.Debug().Array("users", exzerolog.ArrayOfStrs(users)).Msg("Sharing group session for room") - - withheldCount := 0 - toDeviceWithheld := &mautrix.ReqSendToDevice{Messages: make(map[id.UserID]map[id.DeviceID]*event.Content)} - olmSessions := make(map[id.UserID]map[id.DeviceID]deviceSessionWrapper) - missingSessions := make(map[id.UserID]map[id.DeviceID]*id.Device) - missingUserSessions := make(map[id.DeviceID]*id.Device) - var fetchKeysForUsers []id.UserID - - for _, userID := range users { - log := log.With().Stringer("target_user_id", userID).Logger() - devices, err := mach.CryptoStore.GetDevices(ctx, userID) - if err != nil { - log.Err(err).Msg("Failed to get devices of user") - return fmt.Errorf("failed to get devices of user %s: %w", userID, err) - } else if devices == nil { - log.Debug().Msg("GetDevices returned nil, will fetch keys and retry") - fetchKeysForUsers = append(fetchKeysForUsers, userID) - } else if len(devices) == 0 { - log.Trace().Msg("User has no devices, skipping") - } else { - log.Trace().Msg("Trying to find olm session to encrypt megolm session for user") - toDeviceWithheld.Messages[userID] = make(map[id.DeviceID]*event.Content) - olmSessions[userID] = make(map[id.DeviceID]deviceSessionWrapper) - mach.findOlmSessionsForUser(ctx, session, userID, devices, olmSessions[userID], toDeviceWithheld.Messages[userID], missingUserSessions) - log.Debug(). - Int("olm_session_count", len(olmSessions[userID])). - Int("withheld_count", len(toDeviceWithheld.Messages[userID])). - Int("missing_count", len(missingUserSessions)). - Msg("Completed first pass of finding olm sessions") - withheldCount += len(toDeviceWithheld.Messages[userID]) - if len(missingUserSessions) > 0 { - missingSessions[userID] = missingUserSessions - missingUserSessions = make(map[id.DeviceID]*id.Device) - } - if len(toDeviceWithheld.Messages[userID]) == 0 { - delete(toDeviceWithheld.Messages, userID) - } - } - } - - if len(fetchKeysForUsers) > 0 { - log.Debug().Array("users", exzerolog.ArrayOfStrs(fetchKeysForUsers)).Msg("Fetching missing keys") - keys, err := mach.FetchKeys(ctx, fetchKeysForUsers, true) - if err != nil { - log.Err(err).Array("users", exzerolog.ArrayOfStrs(fetchKeysForUsers)).Msg("Failed to fetch missing keys") - return fmt.Errorf("failed to fetch missing keys: %w", err) - } - for userID, devices := range keys { - log.Debug(). - Int("device_count", len(devices)). - Str("target_user_id", userID.String()). - Msg("Got device keys for user") - missingSessions[userID] = devices - } - } - - if len(missingSessions) > 0 { - log.Debug().Msg("Creating missing olm sessions") - err = mach.createOutboundSessions(ctx, missingSessions) - if err != nil { - log.Err(err).Msg("Failed to create missing olm sessions") - return fmt.Errorf("failed to create missing olm sessions: %w", err) - } - } - - for userID, devices := range missingSessions { - if len(devices) == 0 { - // No missing sessions - continue - } - output, ok := olmSessions[userID] - if !ok { - output = make(map[id.DeviceID]deviceSessionWrapper) - olmSessions[userID] = output - } - withheld, ok := toDeviceWithheld.Messages[userID] - if !ok { - withheld = make(map[id.DeviceID]*event.Content) - toDeviceWithheld.Messages[userID] = withheld - } - - log := log.With().Stringer("target_user_id", userID).Logger() - log.Trace().Msg("Trying to find olm session to encrypt megolm session for user (post-fetch retry)") - mach.findOlmSessionsForUser(ctx, session, userID, devices, output, withheld, nil) - log.Debug(). - Int("olm_session_count", len(output)). - Int("withheld_count", len(withheld)). - Msg("Completed post-fetch retry of finding olm sessions") - withheldCount += len(toDeviceWithheld.Messages[userID]) - if len(toDeviceWithheld.Messages[userID]) == 0 { - delete(toDeviceWithheld.Messages, userID) - } - } - - err = mach.encryptAndSendGroupSession(ctx, session, olmSessions) - if err != nil { - return fmt.Errorf("failed to share group session: %w", err) - } - - if len(toDeviceWithheld.Messages) > 0 { - log.Debug(). - Int("device_count", withheldCount). - Int("user_count", len(toDeviceWithheld.Messages)). - Msg("Sending to-device messages to report withheld key") - // TODO remove the next 4 lines once clients support m.room_key.withheld - _, err = mach.Client.SendToDevice(ctx, event.ToDeviceOrgMatrixRoomKeyWithheld, toDeviceWithheld) - if err != nil { - log.Warn().Err(err).Msg("Failed to report withheld keys (legacy event type)") - } - _, err = mach.Client.SendToDevice(ctx, event.ToDeviceRoomKeyWithheld, toDeviceWithheld) - if err != nil { - log.Warn().Err(err).Msg("Failed to report withheld keys") - } - } - - log.Debug().Msg("Group session successfully shared") - session.Shared = true - return mach.CryptoStore.AddOutboundGroupSession(ctx, session) -} - -func (mach *OlmMachine) encryptAndSendGroupSession(ctx context.Context, session *OutboundGroupSession, olmSessions map[id.UserID]map[id.DeviceID]deviceSessionWrapper) error { - mach.olmLock.Lock() - defer mach.olmLock.Unlock() - log := zerolog.Ctx(ctx) - log.Trace().Msg("Encrypting group session for all found devices") - deviceCount := 0 - toDevice := &mautrix.ReqSendToDevice{Messages: make(map[id.UserID]map[id.DeviceID]*event.Content)} - logUsers := zerolog.Dict() - for userID, sessions := range olmSessions { - if len(sessions) == 0 { - continue - } - logDevices := zerolog.Dict() - output := make(map[id.DeviceID]*event.Content) - toDevice.Messages[userID] = output - for deviceID, device := range sessions { - content := mach.encryptOlmEvent(ctx, device.session, device.identity, event.ToDeviceRoomKey, session.ShareContent()) - output[deviceID] = &event.Content{Parsed: content} - logDevices.Str(string(deviceID), string(device.identity.IdentityKey)) - deviceCount++ - if !mach.DisableSharedGroupSessionTracking { - err := mach.CryptoStore.MarkOutboundGroupSessionShared(ctx, userID, device.identity.IdentityKey, session.id) - if err != nil { - log.Warn(). - Err(err). - Stringer("target_user_id", userID). - Stringer("target_device_id", deviceID). - Stringer("target_identity_key", device.identity.IdentityKey). - Stringer("target_session_id", session.id). - Msg("Failed to mark outbound group session shared") - } - } - } - logUsers.Dict(string(userID), logDevices) - } - - log.Debug(). - Int("device_count", deviceCount). - Int("user_count", len(toDevice.Messages)). - Dict("destination_map", logUsers). - Msg("Sending to-device messages to share group session") - _, err := mach.Client.SendToDevice(ctx, event.ToDeviceEncrypted, toDevice) - return err -} - -func (mach *OlmMachine) findOlmSessionsForUser(ctx context.Context, session *OutboundGroupSession, userID id.UserID, devices map[id.DeviceID]*id.Device, output map[id.DeviceID]deviceSessionWrapper, withheld map[id.DeviceID]*event.Content, missingOutput map[id.DeviceID]*id.Device) { - for deviceID, device := range devices { - log := zerolog.Ctx(ctx).With(). - Stringer("target_user_id", userID). - Stringer("target_device_id", deviceID). - Stringer("target_identity_key", device.IdentityKey). - Logger() - userKey := UserDevice{UserID: userID, DeviceID: deviceID} - if state := session.Users[userKey]; state != OGSNotShared { - continue - } else if userID == mach.Client.UserID && deviceID == mach.Client.DeviceID { - session.Users[userKey] = OGSIgnored - } else if device.Trust == id.TrustStateBlacklisted { - log.Debug().Msg("Not encrypting group session for device: device is blacklisted") - withheld[deviceID] = &event.Content{Parsed: &event.RoomKeyWithheldEventContent{ - RoomID: session.RoomID, - Algorithm: id.AlgorithmMegolmV1, - SessionID: session.ID(), - SenderKey: mach.account.IdentityKey(), - Code: event.RoomKeyWithheldBlacklisted, - Reason: "Device is blacklisted", - }} - session.Users[userKey] = OGSIgnored - } else if trustState, _ := mach.ResolveTrustContext(ctx, device); trustState < mach.SendKeysMinTrust { - log.Debug(). - Str("min_trust", mach.SendKeysMinTrust.String()). - Str("device_trust", trustState.String()). - Msg("Not encrypting group session for device: device is not trusted") - withheld[deviceID] = &event.Content{Parsed: &event.RoomKeyWithheldEventContent{ - RoomID: session.RoomID, - Algorithm: id.AlgorithmMegolmV1, - SessionID: session.ID(), - SenderKey: mach.account.IdentityKey(), - Code: event.RoomKeyWithheldUnverified, - Reason: "This device does not encrypt messages for unverified devices", - }} - session.Users[userKey] = OGSIgnored - } else if deviceSession, err := mach.CryptoStore.GetLatestSession(ctx, device.IdentityKey); err != nil { - log.Error().Err(err).Msg("Failed to get olm session to encrypt group session") - } else if deviceSession == nil { - log.Warn().Err(err).Msg("Didn't find olm session to encrypt group session") - if missingOutput != nil { - missingOutput[deviceID] = device - } - } else { - output[deviceID] = deviceSessionWrapper{ - session: deviceSession, - identity: device, - } - session.Users[userKey] = OGSAlreadyShared - } - } -} diff --git a/mautrix-patched/crypto/encryptolm.go b/mautrix-patched/crypto/encryptolm.go deleted file mode 100644 index c752460d..00000000 --- a/mautrix-patched/crypto/encryptolm.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "encoding/json" - "fmt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (mach *OlmMachine) EncryptToDevices(ctx context.Context, eventType event.Type, req *mautrix.ReqSendToDevice) (*mautrix.ReqSendToDevice, error) { - devicesToCreateSessions := make(map[id.UserID]map[id.DeviceID]*id.Device) - for userID, devices := range req.Messages { - for deviceID := range devices { - device, err := mach.GetOrFetchDevice(ctx, userID, deviceID) - if err != nil { - return nil, fmt.Errorf("failed to get device %s of user %s: %w", deviceID, userID, err) - } - - if _, ok := devicesToCreateSessions[userID]; !ok { - devicesToCreateSessions[userID] = make(map[id.DeviceID]*id.Device) - } - devicesToCreateSessions[userID][deviceID] = device - } - } - if err := mach.createOutboundSessions(ctx, devicesToCreateSessions); err != nil { - return nil, fmt.Errorf("failed to create outbound sessions: %w", err) - } - - mach.olmLock.Lock() - defer mach.olmLock.Unlock() - - encryptedReq := &mautrix.ReqSendToDevice{ - Messages: make(map[id.UserID]map[id.DeviceID]*event.Content), - } - - log := mach.machOrContextLog(ctx) - - for userID, devices := range req.Messages { - encryptedReq.Messages[userID] = make(map[id.DeviceID]*event.Content) - - for deviceID, content := range devices { - device := devicesToCreateSessions[userID][deviceID] - - olmSess, err := mach.CryptoStore.GetLatestSession(ctx, device.IdentityKey) - if err != nil { - return nil, fmt.Errorf("failed to get latest session for device %s of %s: %w", deviceID, userID, err) - } else if olmSess == nil { - log.Warn(). - Str("target_user_id", userID.String()). - Str("target_device_id", deviceID.String()). - Str("identity_key", device.IdentityKey.String()). - Msg("No outbound session found for device") - continue - } - - encrypted := mach.encryptOlmEvent(ctx, olmSess, device, eventType, *content) - encryptedContent := &event.Content{Parsed: &encrypted} - - log.Debug(). - Str("decrypted_type", eventType.Type). - Str("target_user_id", userID.String()). - Str("target_device_id", deviceID.String()). - Str("target_identity_key", device.IdentityKey.String()). - Str("olm_session_id", olmSess.ID().String()). - Msg("Encrypted to-device event") - - encryptedReq.Messages[userID][deviceID] = encryptedContent - } - } - - return encryptedReq, nil -} - -func (mach *OlmMachine) encryptOlmEvent(ctx context.Context, session *OlmSession, recipient *id.Device, evtType event.Type, content event.Content) *event.EncryptedEventContent { - evt := &DecryptedOlmEvent{ - Sender: mach.Client.UserID, - SenderDeviceID: mach.Client.DeviceID, - SenderDeviceKeys: mach.getKeysForOlmMessage(ctx), - Keys: OlmEventKeys{Ed25519: mach.account.SigningKey()}, - Recipient: recipient.UserID, - RecipientKeys: OlmEventKeys{Ed25519: recipient.SigningKey}, - Type: evtType, - Content: content, - } - plaintext, err := json.Marshal(evt) - if err != nil { - panic(err) - } - log := mach.machOrContextLog(ctx) - msgType, ciphertext, err := session.Encrypt(plaintext) - if err != nil { - panic(err) - } - ciphertextStr := string(ciphertext) - ciphertextHash, _ := olmMessageHash(ciphertextStr) - log.Debug(). - Stringer("event_type", evtType). - Str("recipient_identity_key", recipient.IdentityKey.String()). - Str("olm_session_id", session.ID().String()). - Str("olm_session_description", session.Describe()). - Hex("ciphertext_hash", ciphertextHash[:]). - Msg("Encrypted olm message") - err = mach.CryptoStore.UpdateSession(ctx, recipient.IdentityKey, session) - if err != nil { - log.Error().Err(err).Msg("Failed to update olm session in crypto store after encrypting") - } - return &event.EncryptedEventContent{ - Algorithm: id.AlgorithmOlmV1, - SenderKey: mach.account.IdentityKey(), - OlmCiphertext: event.OlmCiphertexts{ - recipient.IdentityKey: { - Type: msgType, - Body: ciphertextStr, - }, - }, - } -} - -func (mach *OlmMachine) shouldCreateNewSession(ctx context.Context, identityKey id.IdentityKey) bool { - if !mach.CryptoStore.HasSession(ctx, identityKey) { - return true - } - mach.devicesToUnwedgeLock.Lock() - _, shouldUnwedge := mach.devicesToUnwedge[identityKey] - if shouldUnwedge { - delete(mach.devicesToUnwedge, identityKey) - } - mach.devicesToUnwedgeLock.Unlock() - return shouldUnwedge -} - -func (mach *OlmMachine) createOutboundSessions(ctx context.Context, input map[id.UserID]map[id.DeviceID]*id.Device) error { - request := make(mautrix.OneTimeKeysRequest) - for userID, devices := range input { - request[userID] = make(map[id.DeviceID]id.KeyAlgorithm) - for deviceID, identity := range devices { - if mach.shouldCreateNewSession(ctx, identity.IdentityKey) { - request[userID][deviceID] = id.KeyAlgorithmSignedCurve25519 - } - } - if len(request[userID]) == 0 { - delete(request, userID) - } - } - if len(request) == 0 { - return nil - } - resp, err := mach.Client.ClaimKeys(ctx, &mautrix.ReqClaimKeys{ - OneTimeKeys: request, - Timeout: 10 * 1000, - }) - if err != nil { - return fmt.Errorf("failed to claim keys: %w", err) - } - log := mach.machOrContextLog(ctx) - for userID, user := range resp.OneTimeKeys { - for deviceID, oneTimeKeys := range user { - var oneTimeKey mautrix.OneTimeKey - var keyID id.KeyID - for keyID, oneTimeKey = range oneTimeKeys { - break - } - log := log.With(). - Str("peer_user_id", userID.String()). - Str("peer_device_id", deviceID.String()). - Str("peer_otk_id", keyID.String()). - Logger() - keyAlg, _ := keyID.Parse() - if keyAlg != id.KeyAlgorithmSignedCurve25519 { - log.Warn().Msg("Unexpected key ID algorithm in one-time key response") - continue - } - identity := input[userID][deviceID] - if ok, err := signatures.VerifySignatureJSON(oneTimeKey.RawData, userID, deviceID.String(), identity.SigningKey); err != nil { - log.Error().Err(err).Msg("Failed to verify signature of one-time key") - } else if !ok { - log.Warn().Msg("One-time key has invalid signature from device") - } else if sess, err := mach.account.Internal.NewOutboundSession(identity.IdentityKey, oneTimeKey.Key); err != nil { - log.Error().Err(err).Msg("Failed to create outbound session with claimed one-time key") - } else { - wrapped := wrapSession(sess) - err = mach.CryptoStore.AddSession(ctx, identity.IdentityKey, wrapped) - if err != nil { - log.Error().Err(err).Msg("Failed to store created outbound session") - } else { - log.Debug().Msg("Created new Olm session") - } - } - } - } - return nil -} diff --git a/mautrix-patched/crypto/goolm/README.md b/mautrix-patched/crypto/goolm/README.md deleted file mode 100644 index c5eaa0af..00000000 --- a/mautrix-patched/crypto/goolm/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# go-olm -This is a fork of [DerLukas's goolm](https://codeberg.org/DerLukas/goolm), -a pure Go implementation of libolm. - -The original project is licensed under the MIT license. diff --git a/mautrix-patched/crypto/goolm/account/account.go b/mautrix-patched/crypto/goolm/account/account.go deleted file mode 100644 index 754015d8..00000000 --- a/mautrix-patched/crypto/goolm/account/account.go +++ /dev/null @@ -1,423 +0,0 @@ -// account packages an account which stores the identity, one time keys and fallback keys. -package account - -import ( - "encoding/base64" - "encoding/json" - "fmt" - - "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - accountPickleVersionLibOLM uint32 = 4 - MaxOneTimeKeys int = 100 //maximum number of stored one time keys per Account -) - -// Account stores an account for end to end encrypted messaging via the olm protocol. -// An Account can not be used to en/decrypt messages. However it can be used to contruct new olm sessions, which in turn do the en/decryption. -// There is no tracking of sessions in an account. -type Account struct { - IdKeys struct { - Ed25519 crypto.Ed25519KeyPair `json:"ed25519,omitempty"` - Curve25519 crypto.Curve25519KeyPair `json:"curve25519,omitempty"` - } `json:"identity_keys"` - OTKeys []crypto.OneTimeKey `json:"one_time_keys"` - CurrentFallbackKey crypto.OneTimeKey `json:"current_fallback_key,omitempty"` - PrevFallbackKey crypto.OneTimeKey `json:"prev_fallback_key,omitempty"` - NextOneTimeKeyID uint32 `json:"next_one_time_key_id,omitempty"` - NumFallbackKeys uint8 `json:"number_fallback_keys"` -} - -// Ensure that Account adheres to the olm.Account interface. -var _ olm.Account = (*Account)(nil) - -// AccountFromPickled loads the Account details from a pickled base64 string. The input is decrypted with the supplied key. -func AccountFromPickled(pickled, key []byte) (*Account, error) { - if len(pickled) == 0 { - return nil, fmt.Errorf("accountFromPickled: %w", olm.ErrEmptyInput) - } - a := &Account{} - return a, a.Unpickle(pickled, key) -} - -// NewAccount creates a new Account. -func NewAccount() (*Account, error) { - a := &Account{} - kPEd25519, err := crypto.Ed25519GenerateKey() - if err != nil { - return nil, err - } - a.IdKeys.Ed25519 = kPEd25519 - kPCurve25519, err := crypto.Curve25519GenerateKey() - if err != nil { - return nil, err - } - a.IdKeys.Curve25519 = kPCurve25519 - return a, nil -} - -// IdentityKeysJSON returns the public parts of the identity keys for the Account in a JSON string. -func (a *Account) IdentityKeysJSON() ([]byte, error) { - res := struct { - Ed25519 string `json:"ed25519"` - Curve25519 string `json:"curve25519"` - }{} - ed25519, curve25519, err := a.IdentityKeys() - if err != nil { - return nil, err - } - res.Ed25519 = string(ed25519) - res.Curve25519 = string(curve25519) - return json.Marshal(res) -} - -// IdentityKeys returns the public parts of the Ed25519 and Curve25519 identity keys for the Account. -func (a *Account) IdentityKeys() (id.Ed25519, id.Curve25519, error) { - ed25519 := id.Ed25519(base64.RawStdEncoding.EncodeToString(a.IdKeys.Ed25519.PublicKey)) - curve25519 := id.Curve25519(base64.RawStdEncoding.EncodeToString(a.IdKeys.Curve25519.PublicKey)) - return ed25519, curve25519, nil -} - -// Sign returns the base64-encoded signature of a message using the Ed25519 key -// for this Account. -func (a *Account) Sign(message []byte) ([]byte, error) { - if len(message) == 0 { - return nil, fmt.Errorf("sign: %w", olm.ErrEmptyInput) - } else if signature, err := a.IdKeys.Ed25519.Sign(message); err != nil { - return nil, err - } else { - return []byte(base64.RawStdEncoding.EncodeToString(signature)), nil - } -} - -// OneTimeKeys returns the public parts of the unpublished one time keys of the Account. -// -// The returned data is a map with the mapping of key id to base64-encoded Curve25519 key. -func (a *Account) OneTimeKeys() (map[string]id.Curve25519, error) { - oneTimeKeys := make(map[string]id.Curve25519) - for _, curKey := range a.OTKeys { - if !curKey.Published { - oneTimeKeys[curKey.KeyIDEncoded()] = curKey.Key.PublicKey.B64Encoded() - } - } - return oneTimeKeys, nil -} - -// MarkKeysAsPublished marks the current set of one time keys and the fallback key as being -// published. -func (a *Account) MarkKeysAsPublished() { - for keyIndex := range a.OTKeys { - if !a.OTKeys[keyIndex].Published { - a.OTKeys[keyIndex].Published = true - } - } - a.CurrentFallbackKey.Published = true -} - -// GenOneTimeKeys generates a number of new one time keys. If the total number -// of keys stored by this Account exceeds MaxOneTimeKeys then the older -// keys are discarded. -func (a *Account) GenOneTimeKeys(num uint) error { - for i := uint(0); i < num; i++ { - key := crypto.OneTimeKey{ - Published: false, - ID: a.NextOneTimeKeyID, - } - newKP, err := crypto.Curve25519GenerateKey() - if err != nil { - return err - } - key.Key = newKP - a.NextOneTimeKeyID++ - a.OTKeys = append([]crypto.OneTimeKey{key}, a.OTKeys...) - } - if len(a.OTKeys) > MaxOneTimeKeys { - a.OTKeys = a.OTKeys[:MaxOneTimeKeys] - } - return nil -} - -// NewOutboundSession creates a new outbound session to a -// given curve25519 identity Key and one time key. -func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (olm.Session, error) { - if len(theirIdentityKey) == 0 || len(theirOneTimeKey) == 0 { - return nil, fmt.Errorf("outbound session: %w", olm.ErrEmptyInput) - } - theirIdentityKeyDecoded, err := base64.RawStdEncoding.DecodeString(string(theirIdentityKey)) - if err != nil { - return nil, err - } - theirOneTimeKeyDecoded, err := base64.RawStdEncoding.DecodeString(string(theirOneTimeKey)) - if err != nil { - return nil, err - } - return session.NewOutboundOlmSession(a.IdKeys.Curve25519, theirIdentityKeyDecoded, theirOneTimeKeyDecoded) -} - -// NewInboundSession creates a new in-bound session for sending/receiving -// messages from an incoming PRE_KEY message. Returns error on failure. -func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { - return a.NewInboundSessionFrom(nil, oneTimeKeyMsg) -} - -// NewInboundSessionFrom creates a new inbound session from an incoming PRE_KEY message. -func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (olm.Session, error) { - if len(oneTimeKeyMsg) == 0 { - return nil, fmt.Errorf("inbound session: %w", olm.ErrEmptyInput) - } - var theirIdentityKeyDecoded *crypto.Curve25519PublicKey - if theirIdentityKey != nil { - theirIdentityKeyDecodedByte, err := base64.RawStdEncoding.DecodeString(string(*theirIdentityKey)) - if err != nil { - return nil, err - } - theirIdentityKeyCurve := crypto.Curve25519PublicKey(theirIdentityKeyDecodedByte) - theirIdentityKeyDecoded = &theirIdentityKeyCurve - } - - return session.NewInboundOlmSession(theirIdentityKeyDecoded, []byte(oneTimeKeyMsg), a.searchOTKForOur, a.IdKeys.Curve25519) -} - -func (a *Account) searchOTKForOur(toFind crypto.Curve25519PublicKey) *crypto.OneTimeKey { - for curIndex := range a.OTKeys { - if a.OTKeys[curIndex].Key.PublicKey.Equal(toFind) { - return &a.OTKeys[curIndex] - } - } - if a.NumFallbackKeys >= 1 && a.CurrentFallbackKey.Key.PublicKey.Equal(toFind) { - return &a.CurrentFallbackKey - } - if a.NumFallbackKeys >= 2 && a.PrevFallbackKey.Key.PublicKey.Equal(toFind) { - return &a.PrevFallbackKey - } - return nil -} - -// RemoveOneTimeKeys removes the one time key in this Account which matches the one time key in the session s. -func (a *Account) RemoveOneTimeKeys(s olm.Session) error { - toFind := s.(*session.OlmSession).BobOneTimeKey - for curIndex := range a.OTKeys { - if a.OTKeys[curIndex].Key.PublicKey.Equal(toFind) { - //Remove and return - a.OTKeys[curIndex] = a.OTKeys[len(a.OTKeys)-1] - a.OTKeys = a.OTKeys[:len(a.OTKeys)-1] - return nil - } - } - return nil - //if the key is a fallback or prevFallback, don't remove it -} - -// GenFallbackKey generates a new fallback key. The old fallback key is stored -// in a.PrevFallbackKey overwriting any previous PrevFallbackKey. -func (a *Account) GenFallbackKey() error { - a.PrevFallbackKey = a.CurrentFallbackKey - key := crypto.OneTimeKey{ - Published: false, - ID: a.NextOneTimeKeyID, - } - newKP, err := crypto.Curve25519GenerateKey() - if err != nil { - return err - } - key.Key = newKP - a.NextOneTimeKeyID++ - if a.NumFallbackKeys < 2 { - a.NumFallbackKeys++ - } - a.CurrentFallbackKey = key - return nil -} - -// FallbackKey returns the public part of the current fallback key of the Account. -// The returned data is a map with the mapping of key id to base64-encoded Curve25519 key. -func (a *Account) FallbackKey() map[string]id.Curve25519 { - keys := make(map[string]id.Curve25519) - if a.NumFallbackKeys >= 1 { - keys[a.CurrentFallbackKey.KeyIDEncoded()] = a.CurrentFallbackKey.Key.PublicKey.B64Encoded() - } - return keys -} - -//FallbackKeyJSON returns the public part of the current fallback key of the Account as a JSON string. -// -//The returned JSON is of format: -/* - { - curve25519: { - "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo" - } - } -*/ -func (a *Account) FallbackKeyJSON() ([]byte, error) { - res := make(map[string]map[string]id.Curve25519) - fbk := a.FallbackKey() - res["curve25519"] = fbk - return json.Marshal(res) -} - -// FallbackKeyUnpublished returns the public part of the current fallback key of the Account only if it is unpublished. -// The returned data is a map with the mapping of key id to base64-encoded Curve25519 key. -func (a *Account) FallbackKeyUnpublished() map[string]id.Curve25519 { - keys := make(map[string]id.Curve25519) - if a.NumFallbackKeys >= 1 && !a.CurrentFallbackKey.Published { - keys[a.CurrentFallbackKey.KeyIDEncoded()] = a.CurrentFallbackKey.Key.PublicKey.B64Encoded() - } - return keys -} - -//FallbackKeyUnpublishedJSON returns the public part of the current fallback key, only if it is unpublished, of the Account as a JSON string. -// -//The returned JSON is of format: -/* - { - curve25519: { - "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo" - } - } -*/ -func (a *Account) FallbackKeyUnpublishedJSON() ([]byte, error) { - res := make(map[string]map[string]id.Curve25519) - fbk := a.FallbackKeyUnpublished() - res["curve25519"] = fbk - return json.Marshal(res) -} - -// ForgetOldFallbackKey resets the previous fallback key in the account. -func (a *Account) ForgetOldFallbackKey() { - if a.NumFallbackKeys >= 2 { - a.NumFallbackKeys = 1 - a.PrevFallbackKey = crypto.OneTimeKey{} - } -} - -// Unpickle decodes the base64 encoded string and decrypts the result with the key. -// The decrypted value is then passed to UnpickleLibOlm. -func (a *Account) Unpickle(pickled, key []byte) error { - decrypted, err := libolmpickle.Unpickle(key, pickled) - if err != nil { - return err - } - return a.UnpickleLibOlm(decrypted) -} - -// UnpickleLibOlm unpickles the unencryted value and populates the [Account] accordingly. -func (a *Account) UnpickleLibOlm(buf []byte) error { - decoder := libolmpickle.NewDecoder(buf) - pickledVersion, err := decoder.ReadUInt32() - if err != nil { - return err - } else if pickledVersion != accountPickleVersionLibOLM && pickledVersion != 3 && pickledVersion != 2 { - return fmt.Errorf("unpickle account: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) - } else if err = a.IdKeys.Ed25519.UnpickleLibOlm(decoder); err != nil { // read the ed25519 key pair - return err - } else if err = a.IdKeys.Curve25519.UnpickleLibOlm(decoder); err != nil { // read curve25519 key pair - return err - } - - otkCount, err := decoder.ReadUInt32() - if err != nil { - return err - } - - a.OTKeys = make([]crypto.OneTimeKey, otkCount) - for i := uint32(0); i < otkCount; i++ { - if err := a.OTKeys[i].UnpickleLibOlm(decoder); err != nil { - return err - } - } - - if pickledVersion <= 2 { - // version 2 did not have fallback keys - a.NumFallbackKeys = 0 - } else if pickledVersion == 3 { - // version 3 used the published flag to indicate how many fallback keys - // were present (we'll have to assume that the keys were published) - if err = a.CurrentFallbackKey.UnpickleLibOlm(decoder); err != nil { - return err - } else if err = a.PrevFallbackKey.UnpickleLibOlm(decoder); err != nil { - return err - } - if a.CurrentFallbackKey.Published { - if a.PrevFallbackKey.Published { - a.NumFallbackKeys = 2 - } else { - a.NumFallbackKeys = 1 - } - } else { - a.NumFallbackKeys = 0 - } - } else { - // Read number of fallback keys - a.NumFallbackKeys, err = decoder.ReadUInt8() - if err != nil { - return err - } - for i := 0; i < int(a.NumFallbackKeys); i++ { - switch i { - case 0: - if err = a.CurrentFallbackKey.UnpickleLibOlm(decoder); err != nil { - return err - } - case 1: - if err = a.PrevFallbackKey.UnpickleLibOlm(decoder); err != nil { - return err - } - default: - // Just drain any remaining fallback keys - if err = (&crypto.OneTimeKey{}).UnpickleLibOlm(decoder); err != nil { - return err - } - } - } - } - - //Read next onetime key ID - a.NextOneTimeKeyID, err = decoder.ReadUInt32() - return err -} - -// Pickle returns a base64 encoded and with key encrypted pickled account using PickleLibOlm(). -func (a *Account) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - return libolmpickle.Pickle(key, a.PickleLibOlm()) -} - -// PickleLibOlm pickles the [Account] and returns the raw bytes. -func (a *Account) PickleLibOlm() []byte { - encoder := libolmpickle.NewEncoder() - encoder.WriteUInt32(accountPickleVersionLibOLM) - a.IdKeys.Ed25519.PickleLibOlm(encoder) - a.IdKeys.Curve25519.PickleLibOlm(encoder) - - // One-Time Keys - encoder.WriteUInt32(uint32(len(a.OTKeys))) - for _, curOTKey := range a.OTKeys { - curOTKey.PickleLibOlm(encoder) - } - - // Fallback Keys - encoder.WriteUInt8(a.NumFallbackKeys) - if a.NumFallbackKeys >= 1 { - a.CurrentFallbackKey.PickleLibOlm(encoder) - if a.NumFallbackKeys >= 2 { - a.PrevFallbackKey.PickleLibOlm(encoder) - } - } - encoder.WriteUInt32(a.NextOneTimeKeyID) - return encoder.Bytes() -} - -// MaxNumberOfOneTimeKeys returns the largest number of one time keys this -// Account can store. -func (a *Account) MaxNumberOfOneTimeKeys() uint { - return uint(MaxOneTimeKeys) -} diff --git a/mautrix-patched/crypto/goolm/account/account_data_test.go b/mautrix-patched/crypto/goolm/account/account_data_test.go deleted file mode 100644 index 739421ff..00000000 --- a/mautrix-patched/crypto/goolm/account/account_data_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package account_test - -import "maunium.net/go/mautrix/crypto/goolm/crypto" - -var pickledDataFromLibOlm = []byte("b3jGWBenkTv6DJt90OX+H1ecoXQwihBjhdJHkAft49wS7ubT3Z0ta46p9PCnfKs+fOHeKhJzgfFcD5yCoatcpRzMHRri6V1dG/wMIu8nYvPPMZ8Dy5YlMBRGz0cpnOAhVoUzo/HtvyN8kgoYnZLzorVYepIqQcsLZAiG6qlztXepEflwNG619Rrk/zWYae5RBtxz9Cl0KCTj8cjY5J/SEKU+SCnj4n16wa+RfYXuLK/kBlE30uSWqQBInlLLYiSqOGjr8M0x+3A0eG0gYA+Aohwl5MbjQnDniTbQeg1gh3VwWZ6kJCgRpLnT0j6oc6V4HjP0JjseHe0rBr6W9o88sl6wGmVEr2ZjlvcD6hoCK21A98UZF0GTwHrX0zV7OQtn5cmys3A1xdgcBAo/GXte1d2HzBXSmgrnXExK3Ij+BkZoQSuEFWSUCLjCUFQohK8TfraLZ5+9sOaV/5KaUxqdBTi6HUqoYymCHxzG7olo3hlh+GJ+iOy9tnofqDirISDIIL7KJ2zNJxYHWNZrAVNxHF3rPSrBw9Zl6M2Scm9PdDqnPgGZ+MSCrCnT6UrrfmWurPahnXwdvPED9rykLtcy3aKFsB+RIezeoNdq/4d8sYVwFWd0w9HTXEYG1YY/Km2LiK/exaC98agPN4GXakCUHVZSfz59IZ3bH8jQdtZw2BPkVfNCUoTuUFzIk0LS3AtudCTiUaFdul9/Phj7TlvyvIKH8GUFRiV46fxMHJ9U0HGg6VAKtDR5qkB/nB1X8SWTmmZblR+jGOQvE6VCXSEoCdSyjYK+xtwlZsMHoFoci+NN/uxnrfoMd0D+TpNOyNFwdjn9hoS8kmqvMEyhae0Q5N4O7YwHiH/jZ9ruFTCMK10TeyFN3yxKiRKhiJkgd9bGnmHz25dm9EDkR4i1pXUFuSZCO7WPUX4aLiNMwcltW01EiTdvE7e2jgaoRguXI8gimvOZ/d8kKZ8QIgKURZZHXmud93MOXL3sAy/aBU8dBMt+E5mVeoGM2fns8o5D9Yx3gZ6CgkzmzWinfj82qyc459OcyeyV/gugEt3FI28UBMRfghIV0juOGTAjkh6G3wIyZVk2G4rG0mYONrQQhmgKf06szNQXFBHQ2Pju4pY+QEAng3D2CfXFV6S2bUVeXN0fk46afsV84WPwYg77DWTuR81Ck2arbIcKGsSpMrETMY65rtEoMAcXLzmWgPsIXdo7k+aR4mWmcxjW5a10Wxc1knOi39x0M6gnYGbhmj6IxmalzVFOjG1ZtkFL5fs59nK42aP/JZ0SdtTJjJA0PkbEFL3YOmVtRUmizVtZk63JYuyCgw36XLscTb3VWVynLONYa1RPyRLaz8L5FkTVySCFb8gP9KtDipBpPdGIeD0MGRijAPLweB4iDkM6zv9Yu6dMijZgSR0g6LmjQZPcm1YfI9AK2ht86oKJfvpj+UdYkK+wKKNzJKjKN08+mIKYbsumpbgKqx13d8sawKC4EfGAJHXsadat77Kp/ECCvhh7/i6gqWBHD0+I2LGiuQTr/Vd6OxAGmtyFOzdSGsfWm0cq78Lc6og7HTg3n7TbnEfJMaQktAI7vQYqnsvZV/KnfZ1elfPubFaFiHmCJzfkuk4X4y6r5A6FxpuEltvrHRtecQ6FHLHsBSZrUg7Dei9urMonphUfEj4rsVOMlB3ZKiQ0unmWmacmFKkHm+WhpQtzLS57/iuGCdKiL8qWBiCz80bCQXQp1iwdScZ+pZQ5pwVABH0sr9YfQEz6+oMh3Kp5LiXJAy7kNEs20h4oMP1bc/gN+F5cRubHrz3sXHWpAXF/pNw862Lj7rL0PPOZdomgHSpmKybaQyJxemlxOP9eFw2r2aym/6jc4nQoR+1Mu1ijaroJ8MgZSwTKmru+xgJXnwLx8i76iRlze2F00iNOMg/pRtFQmWh/zLsKukFtIi2PgPKo8xNRQgYvB76x0jLaX3cllpu/pL4LIM2q0p+V9+FPBPCeinkDA7jQzy397vlOSbdECuPYaj2JmkH4zKbxdDlZffgfjWCLDFkqPc0Ixz8O1k24yfkqwG792anEValGM/Hnhdh3a24y4dTV28eo1SoJ6pD1yrjP2RNvgeqs2xEbKOxPywmmOjq0zc805cXBTjDOVyeSFiiY3yJ2GN33KXv67svXw/Ky0Nl9Epbk6xbSB5b76HPAjJ1gZXEUE2zeRTVVOAWzrCERUUjcccz1ozde//rOjzEt+3fxdrq/oglOMW5Ge7ddo/a5lDRs10G3KHreT1sZz9NmA0U7VOQDwwosmt1AYB1LPg3HM2mwi8ahbZf62K1o/W450RWuG072u1unrCYBNYYArN4lnE11L5LvxctFT8qwbBPD1rHAs+Pr4GKQkTWOmUM7wS59GGWE3UEDrntj1Our5NNto7bjK05h33GRF+Vge1+8EfZ+eL2aikjeq/5dU/cN2aw5v3FCkrXzXbIX4YQMw0nu+MbbqXzmPAa7ibS5z0puTYiVs/iMC0ElMSbp9l65iosfVE+kjlF5QDVJaZoW1rTJ8ACo3zOIH6tv515OPvcxDl08nqw/eH37g/bridClsFlJu8aS8Up6Pxzx7hIjNukoDG/wm9qwN/tGgOhZaotzSQEmJTHy7rWc/tamPEJvzZ0Ev99FAlu816Q/HSSk//y8ZriufU5kvYgz0jVeotTShi5LQK30EDTZtjSxE2eJjpRwAmyD6iQwQQiQkVj5inBB6FbF9u1iVhgVsoQ52YEN5id809NSFasmjJ+szJm0E0e6WMfU3tX1j4nPiIdqV7XF8E01pTIZRBmJVUIeiq7XBb5fARjFBveFwwC/Ck3XykYz7CVQJsGOfk6VqlOzzcKhDOivDsVUPbtWJZgP3qC3sJp0dZV1c1BcDHUVbi8HC81F4zobEQGUOyTFsfeiiLUOHvBsveLt87EYpYe2rwjdnEVN5IU3NG2spMc0C7DeEiovv6GCdWgpoHHwPknS42Yv1ltjoDSrlgVF6nXyFXfVWN9CkZoOsEvoUEXGrHnLMT2RPJIWwZFGWXnHs+WKkEfFJbXJvUfTMlPM+hPOIaurwkKmJqh3cCzIGezzWFfSqM7Lv9Fth9FR1QiUcyK2eg1yc7F3lpicxf86RdcbNZD8Wep1uLA0/5zQYmiHA2ZUBFgpN0KanmykSWMHirsBXF4ixwsduRvo4YqqgIMLDrQInJMt5xvHTwHhKMgSOdpReDwM8zYYBV7ukon3cUEo8pwKuXCLs++DNU40bIyP7bpB4rL2Bm0ojNxsTsQwHO/vqXIDPPsyUEtd6J/JuL364XSP9yE5lnk2g5j3rA7uaq/DpA7m1PO+dE0nZcduFH+5OMC/tMZK1PK0Uc3z7LwIaaOzLBraqm16PdJjLCo5YcQgL5GqXN20sznKrRApqq17gdNayaZoEqG1cO0HzmDcr39Ombh/KrbtYFPkBlNbs/9tjgSOgZ5/aWb/gA6lYzspsKdJN0JAD88uYYBY2+GqdJRB86OP+DRHVRrpHOuLkcWNKILmVN5WFljoQXBvtER2IML4GHZytus+E6o2HCq+5Sh5dJlCBKRHIbzs9XPlEWbcE6Z6inuR5zbVYb97VcOhaM83ZjZZGVfdyqX9QagQ7k7eP2Ifju7vZkhz+HHa1v94HeYRGEKZvPRv2nPuzD1bOauwmhu0WKzYXXoSL40XdvSMNhGlKI65uxh6O1DoIJ8fiB3cFZjK2Li1gYMBzw7Mt8R2sSks3iKuiePX0JRTl0cBoiX7RmZuOlJet1HnUhbliS9vncRGZ/aRAL6VpQ5066/FoOq9ahWK+aSmxidcuRHqyIHhqJtUJrP/44vwDWBdZljgu4ysQvv3Fd+reJ1T4BZlY2jWJK1YCtMQMR2TeEaD7c6a7FtoexX3wuWDEe7er55nE2dW8uRjrptSdpAHFXHnmHmjzhP5Mr+MMAbDcK+3YrnsKYnmSdQP1EYPQ8dyIuc07BpypOZLWFiZpRcLwx2mAp6wFULrPptCWfPCcOgzghBa9l8eVDm/zsfZcP1FP6/J8uSbAI5YUz61gcR8VG1DKCX2e5kIk2xoPB0DlRfltx8NlT2VjLz5xKyyyaq0GpE0EfUXu0q4s2Q9tpk") -var expectedEd25519KeyPairPickleLibOLM = crypto.Ed25519KeyPair{ - PublicKey: []byte{237, 217, 234, 95, 181, 217, 229, 96, 41, 51, 153, 83, 191, 158, 47, 242, 100, 163, 120, 171, 15, 117, 176, 58, 70, 181, 5, 53, 64, 26, 99, 55}, - PrivateKey: []byte{232, 245, 108, 122, 156, 40, 107, 206, 71, 27, 156, 60, 52, 126, 39, 215, 255, 217, 81, 248, 206, 228, 153, 244, 31, 114, 88, 127, 207, 250, 255, 122, 196, 207, 3, 96, 142, 227, 172, 88, 13, 230, 140, 125, 200, 220, 19, 127, 144, 79, 32, 249, 135, 238, 3, 205, 227, 73, 250, 219, 223, 248, 175, 20}, -} -var expectedCurve25519KeyPairPickleLibOLM = crypto.Curve25519KeyPair{ - PublicKey: []byte{56, 193, 217, 134, 124, 49, 9, 185, 241, 26, 246, 132, 245, 34, 222, 189, 199, 201, 136, 80, 185, 153, 132, 240, 194, 48, 30, 157, 74, 1, 243, 0}, - PrivateKey: []byte{80, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, -} -var expectedOTKeysPickleLibOLM = []crypto.OneTimeKey{ - {ID: 42, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{41, 72, 49, 87, 49, 27, 143, 250, 203, 35, 151, 49, 248, 200, 99, 225, 101, 68, 203, 251, 132, 115, 253, 59, 21, 61, 111, 58, 252, 200, 85, 61}, - PrivateKey: []byte{80, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43}, - }, - }, - {ID: 41, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{123, 42, 55, 123, 233, 87, 88, 76, 17, 249, 112, 97, 226, 213, 73, 239, 49, 217, 168, 220, 180, 182, 176, 231, 77, 138, 92, 58, 62, 185, 250, 12}, - PrivateKey: []byte{80, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42}, - }, - }, - {ID: 40, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{139, 80, 115, 105, 78, 90, 82, 35, 21, 248, 232, 10, 8, 237, 95, 201, 73, 219, 244, 105, 35, 184, 225, 56, 164, 142, 79, 59, 178, 51, 150, 69}, - PrivateKey: []byte{80, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41}, - }, - }, - {ID: 39, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{176, 111, 229, 19, 195, 233, 77, 12, 228, 241, 254, 193, 139, 127, 150, 20, 182, 36, 103, 30, 207, 5, 35, 93, 60, 81, 53, 133, 216, 4, 81, 94}, - PrivateKey: []byte{80, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40}, - }, - }, - {ID: 38, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{137, 106, 140, 51, 49, 76, 42, 164, 198, 184, 58, 9, 246, 119, 84, 88, 196, 199, 189, 145, 145, 141, 209, 29, 68, 64, 171, 23, 126, 11, 220, 122}, - PrivateKey: []byte{80, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39, 39}, - }, - }, - {ID: 37, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{38, 99, 240, 40, 17, 97, 91, 79, 105, 102, 81, 153, 12, 175, 81, 4, 132, 171, 246, 96, 10, 162, 71, 175, 241, 23, 22, 129, 38, 15, 230, 67}, - PrivateKey: []byte{80, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38}, - }, - }, - {ID: 36, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{205, 28, 163, 27, 148, 116, 82, 169, 230, 7, 184, 192, 76, 177, 196, 129, 62, 32, 76, 145, 247, 56, 220, 180, 74, 193, 205, 178, 158, 209, 168, 123}, - PrivateKey: []byte{80, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37}, - }, - }, - {ID: 35, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{195, 125, 80, 132, 106, 120, 250, 0, 145, 191, 116, 179, 167, 91, 65, 10, 121, 19, 12, 51, 78, 229, 170, 110, 37, 37, 109, 65, 221, 126, 168, 5}, - PrivateKey: []byte{80, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36, 36}, - }, - }, - {ID: 34, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{93, 0, 88, 212, 33, 219, 129, 18, 103, 142, 90, 217, 6, 84, 99, 224, 41, 78, 245, 65, 65, 70, 116, 194, 23, 28, 21, 40, 220, 202, 139, 8}, - PrivateKey: []byte{80, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35, 35}, - }, - }, - {ID: 33, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{158, 245, 92, 234, 230, 162, 236, 226, 172, 246, 255, 113, 231, 162, 211, 19, 141, 244, 36, 127, 235, 47, 38, 209, 7, 107, 245, 147, 161, 89, 246, 53}, - PrivateKey: []byte{80, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34}, - }, - }, - {ID: 32, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{69, 20, 138, 120, 68, 160, 34, 99, 205, 177, 138, 147, 96, 118, 36, 239, 206, 11, 118, 75, 170, 216, 193, 108, 24, 65, 0, 131, 226, 73, 22, 18}, - PrivateKey: []byte{80, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33}, - }, - }, - {ID: 31, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{201, 153, 194, 8, 6, 146, 167, 134, 209, 163, 215, 61, 114, 191, 150, 68, 205, 106, 37, 144, 32, 216, 19, 210, 139, 169, 221, 28, 160, 193, 196, 71}, - PrivateKey: []byte{80, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}, - }, - }, - {ID: 30, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{211, 29, 161, 172, 192, 112, 209, 226, 113, 120, 177, 145, 108, 134, 92, 21, 31, 29, 162, 237, 77, 179, 96, 247, 123, 246, 47, 40, 238, 242, 206, 53}, - PrivateKey: []byte{80, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31}, - }, - }, - {ID: 29, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{197, 144, 16, 124, 25, 208, 46, 163, 33, 56, 116, 172, 53, 106, 42, 217, 240, 152, 165, 10, 82, 218, 96, 237, 211, 254, 229, 209, 5, 154, 52, 21}, - PrivateKey: []byte{80, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, - }, - }, - {ID: 28, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{42, 188, 228, 224, 227, 132, 230, 252, 175, 213, 113, 132, 226, 151, 138, 166, 213, 151, 235, 1, 4, 81, 45, 80, 27, 140, 195, 234, 136, 163, 245, 96}, - PrivateKey: []byte{80, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29}, - }, - }, - {ID: 27, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{153, 0, 67, 133, 177, 241, 105, 219, 32, 58, 135, 239, 145, 124, 122, 32, 137, 109, 40, 177, 54, 85, 46, 69, 231, 253, 146, 150, 228, 172, 9, 66}, - PrivateKey: []byte{80, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, - }, - }, - {ID: 26, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{3, 79, 232, 39, 90, 120, 71, 216, 193, 102, 132, 48, 91, 225, 8, 229, 99, 206, 128, 110, 9, 161, 75, 204, 86, 250, 54, 185, 152, 163, 144, 124}, - PrivateKey: []byte{80, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27}, - }, - }, - {ID: 25, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{96, 21, 62, 175, 244, 249, 33, 134, 162, 32, 142, 56, 215, 27, 12, 30, 229, 118, 63, 40, 45, 120, 204, 134, 111, 95, 21, 150, 112, 60, 187, 111}, - PrivateKey: []byte{80, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26}, - }, - }, - {ID: 24, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{103, 239, 218, 49, 88, 55, 161, 63, 238, 39, 114, 106, 175, 158, 59, 43, 39, 112, 239, 175, 29, 174, 75, 172, 9, 84, 230, 109, 214, 77, 170, 124}, - PrivateKey: []byte{80, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25}, - }, - }, - {ID: 23, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{35, 148, 228, 98, 0, 124, 196, 15, 5, 63, 73, 127, 52, 126, 165, 175, 186, 35, 196, 89, 94, 233, 56, 60, 103, 125, 67, 47, 29, 132, 206, 13}, - PrivateKey: []byte{80, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24}, - }, - }, - {ID: 22, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{94, 143, 132, 227, 112, 122, 177, 213, 30, 87, 21, 85, 0, 193, 221, 87, 111, 100, 99, 15, 50, 68, 92, 146, 222, 179, 182, 58, 136, 235, 74, 44}, - PrivateKey: []byte{80, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23}, - }, - }, - {ID: 21, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{232, 20, 27, 90, 55, 105, 146, 28, 107, 129, 73, 107, 1, 35, 70, 190, 227, 54, 169, 214, 160, 99, 150, 180, 37, 109, 115, 211, 84, 115, 91, 73}, - PrivateKey: []byte{80, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22}, - }, - }, - {ID: 20, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{245, 105, 178, 42, 165, 43, 232, 76, 48, 163, 5, 3, 42, 123, 59, 208, 74, 227, 36, 112, 77, 212, 203, 152, 81, 228, 226, 69, 45, 101, 182, 65}, - PrivateKey: []byte{80, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21}, - }, - }, - {ID: 19, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{16, 18, 85, 33, 104, 88, 95, 252, 135, 25, 55, 255, 240, 198, 30, 251, 163, 44, 150, 111, 155, 150, 143, 163, 242, 186, 142, 145, 59, 14, 161, 50}, - PrivateKey: []byte{80, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20}, - }, - }, - {ID: 18, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{32, 138, 232, 106, 32, 165, 39, 122, 146, 194, 126, 235, 84, 72, 127, 106, 83, 32, 219, 45, 201, 36, 226, 133, 201, 67, 168, 199, 112, 73, 166, 68}, - PrivateKey: []byte{80, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19}, - }, - }, - {ID: 17, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{10, 231, 214, 54, 36, 71, 42, 193, 204, 235, 148, 182, 60, 82, 228, 215, 61, 218, 146, 65, 227, 136, 233, 11, 223, 88, 95, 113, 47, 84, 169, 53}, - PrivateKey: []byte{80, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18}, - }, - }, - {ID: 16, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{226, 60, 255, 91, 122, 150, 74, 95, 227, 250, 237, 107, 205, 242, 56, 123, 52, 25, 65, 125, 69, 255, 101, 60, 201, 140, 196, 213, 196, 75, 109, 92}, - PrivateKey: []byte{80, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17}, - }, - }, - {ID: 15, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{171, 229, 71, 9, 133, 66, 150, 143, 73, 156, 11, 216, 148, 7, 153, 129, 237, 207, 228, 193, 55, 183, 156, 178, 132, 85, 154, 43, 19, 29, 170, 127}, - PrivateKey: []byte{80, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, - }, - }, - {ID: 14, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{53, 241, 2, 154, 223, 221, 222, 131, 114, 196, 111, 189, 26, 210, 20, 48, 39, 57, 199, 192, 2, 239, 213, 135, 232, 160, 92, 214, 18, 18, 205, 93}, - PrivateKey: []byte{80, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15}, - }, - }, - {ID: 13, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{92, 15, 157, 2, 49, 70, 253, 32, 39, 210, 54, 167, 55, 95, 255, 118, 76, 52, 184, 76, 185, 217, 31, 84, 7, 118, 1, 117, 53, 78, 216, 91}, - PrivateKey: []byte{80, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14}, - }, - }, - {ID: 12, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{67, 94, 86, 147, 61, 140, 71, 173, 0, 97, 202, 174, 242, 37, 198, 173, 214, 104, 89, 37, 204, 136, 32, 62, 166, 165, 56, 194, 242, 26, 79, 12}, - PrivateKey: []byte{80, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13}, - }, - }, - {ID: 11, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{147, 197, 91, 58, 183, 17, 72, 41, 244, 222, 191, 70, 195, 238, 110, 223, 135, 107, 108, 43, 154, 144, 50, 20, 222, 69, 42, 214, 69, 181, 0, 82}, - PrivateKey: []byte{80, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12}, - }, - }, - {ID: 10, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{116, 144, 19, 88, 33, 120, 92, 138, 174, 218, 192, 222, 96, 249, 46, 250, 4, 197, 250, 196, 243, 68, 183, 210, 218, 107, 206, 138, 121, 226, 189, 104}, - PrivateKey: []byte{80, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11}, - }, - }, - {ID: 9, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{140, 220, 222, 205, 238, 56, 126, 139, 40, 172, 222, 189, 235, 73, 50, 238, 125, 114, 73, 193, 80, 87, 86, 82, 205, 247, 206, 222, 164, 151, 1, 110}, - PrivateKey: []byte{80, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10}, - }, - }, - {ID: 8, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{129, 168, 225, 128, 194, 202, 63, 189, 162, 243, 79, 88, 251, 222, 173, 19, 132, 217, 193, 192, 171, 149, 159, 128, 244, 136, 216, 28, 2, 175, 141, 7}, - PrivateKey: []byte{80, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}, - }, - }, - {ID: 7, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{44, 161, 77, 24, 61, 118, 178, 112, 31, 10, 14, 217, 0, 66, 161, 88, 134, 88, 53, 74, 93, 62, 211, 217, 87, 203, 122, 143, 239, 1, 24, 121}, - PrivateKey: []byte{80, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8}, - }, - }, - {ID: 6, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{167, 86, 75, 53, 54, 151, 106, 235, 48, 47, 54, 144, 180, 160, 209, 24, 78, 99, 57, 76, 109, 162, 233, 213, 170, 121, 37, 203, 178, 212, 130, 0}, - PrivateKey: []byte{80, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7}, - }, - }, - {ID: 5, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{194, 48, 135, 21, 239, 220, 32, 235, 254, 154, 245, 120, 129, 44, 108, 62, 246, 57, 62, 197, 170, 228, 107, 136, 155, 186, 29, 25, 57, 65, 172, 88}, - PrivateKey: []byte{80, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6}, - }, - }, - {ID: 4, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{85, 201, 136, 56, 1, 248, 140, 74, 234, 124, 137, 178, 244, 178, 37, 163, 73, 220, 116, 243, 236, 92, 198, 246, 111, 99, 227, 90, 106, 115, 9, 70}, - PrivateKey: []byte{80, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}, - }, - }, - {ID: 3, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{38, 244, 146, 200, 126, 125, 48, 184, 222, 106, 254, 236, 231, 113, 26, 128, 84, 137, 162, 163, 97, 54, 213, 96, 254, 23, 55, 178, 114, 105, 93, 83}, - PrivateKey: []byte{80, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4}, - }, - }, - {ID: 2, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{149, 218, 194, 83, 219, 185, 224, 51, 177, 226, 224, 190, 219, 150, 131, 5, 183, 52, 226, 205, 114, 116, 219, 156, 227, 175, 66, 165, 132, 8, 24, 82}, - PrivateKey: []byte{80, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, - }, - }, - {ID: 1, - Key: crypto.Curve25519KeyPair{ - PublicKey: []byte{215, 133, 170, 227, 69, 234, 37, 45, 63, 251, 88, 239, 181, 64, 54, 203, 166, 87, 83, 33, 234, 207, 136, 145, 71, 153, 36, 239, 125, 151, 69, 106}, - PrivateKey: []byte{80, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, - }, - }, -} diff --git a/mautrix-patched/crypto/goolm/account/account_test.go b/mautrix-patched/crypto/goolm/account/account_test.go deleted file mode 100644 index a593ffa7..00000000 --- a/mautrix-patched/crypto/goolm/account/account_test.go +++ /dev/null @@ -1,366 +0,0 @@ -package account_test - -import ( - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix/crypto/goolm/account" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/signatures" -) - -func TestAccount(t *testing.T) { - firstAccount, err := account.NewAccount() - assert.NoError(t, err) - err = firstAccount.GenFallbackKey() - assert.NoError(t, err) - err = firstAccount.GenOneTimeKeys(2) - assert.NoError(t, err) - encryptionKey := []byte("testkey") - - //now pickle account in JSON format - pickled, err := firstAccount.Pickle(encryptionKey) - assert.NoError(t, err) - - //now unpickle into new Account - unpickledAccount, err := account.AccountFromPickled(pickled, encryptionKey) - assert.NoError(t, err) - - //check if accounts are the same - assert.Equal(t, firstAccount.NextOneTimeKeyID, unpickledAccount.NextOneTimeKeyID) - assert.Equal(t, firstAccount.CurrentFallbackKey, unpickledAccount.CurrentFallbackKey) - assert.Equal(t, firstAccount.PrevFallbackKey, unpickledAccount.PrevFallbackKey) - assert.Equal(t, firstAccount.OTKeys, unpickledAccount.OTKeys) - assert.Equal(t, firstAccount.IdKeys, unpickledAccount.IdKeys) - - // Ensure that all of the keys are unpublished right now - otks, err := firstAccount.OneTimeKeys() - assert.NoError(t, err) - assert.Len(t, otks, 2) - assert.Len(t, firstAccount.FallbackKeyUnpublished(), 1) - - // Now, publish the key and make sure that they are published - firstAccount.MarkKeysAsPublished() - - assert.Len(t, firstAccount.FallbackKeyUnpublished(), 0) - assert.Len(t, firstAccount.FallbackKey(), 1) - otks, err = firstAccount.OneTimeKeys() - assert.NoError(t, err) - assert.Len(t, otks, 0) -} - -func TestAccountPickleJSON(t *testing.T) { - key := []byte("test key") - - // Generating new values when struct changed - /* - newAccount, _ := account.NewAccount() - pickled, _ := newAccount.Pickle(key) - fmt.Println(string(pickled)) - jsonDataNew, _ := newAccount.IdentityKeysJSON() - fmt.Println(string(jsonDataNew)) - */ - - pickledData := []byte("RLlGsxqzjwMQDVS/ZUXHUwXVSdPXB5SE82erFmwvUN0appArMAIqgsscMhE2yRULGe16vlGvP9xrSOI+fjWi69PzoQAv5VhoZj/3PRgPOTLRX1f+covGNZCMEzkcfjszi1zWvryk5UGQS2Nw3M7b8uYuTKJ+2wG4YO7EjpF3aPlS/RjHjnQT+jefjjM4GzcZddP6xgrKiCmJKuJcTkJKFK5VcyiT7Tt7EiUsViJnWgdwM3RWiDVk3w") - account, err := account.AccountFromPickled(pickledData, key) - assert.NoError(t, err) - expectedJSON := `{"ed25519":"QtURQr6XjRqmpwscm107OIcVvLrZY8CXUnt+O6DJR38","curve25519":"n2cLWJYguFz/xBaHTWcpY8WrEByVTcns/OoxaKOMgT0"}` - jsonData, err := account.IdentityKeysJSON() - assert.NoError(t, err) - assert.Equal(t, expectedJSON, string(jsonData)) -} - -func TestSessions(t *testing.T) { - aliceAccount, err := account.NewAccount() - assert.NoError(t, err) - err = aliceAccount.GenOneTimeKeys(5) - assert.NoError(t, err) - bobAccount, err := account.NewAccount() - assert.NoError(t, err) - err = bobAccount.GenOneTimeKeys(5) - assert.NoError(t, err) - aliceSession, err := aliceAccount.NewOutboundSession(bobAccount.IdKeys.Curve25519.B64Encoded(), bobAccount.OTKeys[2].Key.B64Encoded()) - assert.NoError(t, err) - plaintext := []byte("test message") - msgType, crypttext, err := aliceSession.Encrypt(plaintext) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - - bobSession, err := bobAccount.NewInboundSession(string(crypttext)) - assert.NoError(t, err) - decodedText, err := bobSession.Decrypt(string(crypttext), msgType) - assert.NoError(t, err) - assert.Equal(t, plaintext, decodedText) -} - -func TestAccountPickle(t *testing.T) { - pickleKey := []byte("secret_key") - account, err := account.AccountFromPickled(pickledDataFromLibOlm, pickleKey) - assert.NoError(t, err) - assert.Equal(t, expectedEd25519KeyPairPickleLibOLM, account.IdKeys.Ed25519) - assert.Equal(t, expectedCurve25519KeyPairPickleLibOLM, account.IdKeys.Curve25519) - assert.EqualValues(t, 42, account.NextOneTimeKeyID) - assert.Equal(t, account.OTKeys, expectedOTKeysPickleLibOLM) - assert.EqualValues(t, 0, account.NumFallbackKeys) - - targetPickled, err := account.Pickle(pickleKey) - assert.NoError(t, err) - assert.Equal(t, pickledDataFromLibOlm, targetPickled) -} - -func TestOldAccountPickle(t *testing.T) { - // this uses the old pickle format, which did not use enough space - // for the Ed25519 key. We should reject it. - pickled := []byte("x3h9er86ygvq56pM1yesdAxZou4ResPQC9Rszk/fhEL9JY/umtZ2N/foL/SUgVXS" + - "v0IxHHZTafYjDdzJU9xr8dQeBoOTGfV9E/lCqDGBnIlu7SZndqjEKXtzGyQr4sP4" + - "K/A/8TOu9iK2hDFszy6xETiousHnHgh2ZGbRUh4pQx+YMm8ZdNZeRnwFGLnrWyf9" + - "O5TmXua1FcU") - pickleKey := []byte("") - account, err := account.NewAccount() - assert.NoError(t, err) - err = account.Unpickle(pickled, pickleKey) - assert.ErrorIs(t, err, olm.ErrUnknownOlmPickleVersion) -} - -func TestLoopback(t *testing.T) { - accountA, err := account.NewAccount() - assert.NoError(t, err) - - accountB, err := account.NewAccount() - assert.NoError(t, err) - err = accountB.GenOneTimeKeys(42) - assert.NoError(t, err) - - aliceSession, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), accountB.OTKeys[0].Key.B64Encoded()) - assert.NoError(t, err) - - plainText := []byte("Hello, World") - msgType, message1, err := aliceSession.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - - bobSession, err := accountB.NewInboundSession(string(message1)) - assert.NoError(t, err) - // Check that the inbound session matches the message it was created from. - sessionIsOK, err := bobSession.MatchesInboundSessionFrom("", string(message1)) - assert.NoError(t, err) - assert.True(t, sessionIsOK, "session was not detected to be valid") - - // Check that the inbound session matches the key this message is supposed to be from. - aIDKey := accountA.IdKeys.Curve25519.PublicKey.B64Encoded() - sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(aIDKey), string(message1)) - assert.NoError(t, err) - assert.True(t, sessionIsOK, "session is sad to be not from a but it should") - - // Check that the inbound session isn't from a different user. - bIDKey := accountB.IdKeys.Curve25519.PublicKey.B64Encoded() - sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(bIDKey), string(message1)) - assert.NoError(t, err) - assert.False(t, sessionIsOK, "session is sad to be from b but is from a") - - // Check that we can decrypt the message. - decryptedMessage, err := bobSession.Decrypt(string(message1), msgType) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage) - - msgTyp2, message2, err := bobSession.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgTyp2) - - decryptedMessage2, err := aliceSession.Decrypt(string(message2), msgTyp2) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage2) - - //decrypting again should fail, as the chain moved on - _, err = aliceSession.Decrypt(string(message2), msgTyp2) - assert.Error(t, err) - assert.ErrorIs(t, err, olm.ErrMessageKeyNotFound) - - //compare sessionIDs - assert.Equal(t, aliceSession.ID(), bobSession.ID()) -} - -func TestMoreMessages(t *testing.T) { - accountA, err := account.NewAccount() - assert.NoError(t, err) - - accountB, err := account.NewAccount() - assert.NoError(t, err) - err = accountB.GenOneTimeKeys(42) - assert.NoError(t, err) - - aliceSession, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), accountB.OTKeys[0].Key.B64Encoded()) - assert.NoError(t, err) - - plainText := []byte("Hello, World") - msgType, message1, err := aliceSession.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - - bobSession, err := accountB.NewInboundSession(string(message1)) - assert.NoError(t, err) - decryptedMessage, err := bobSession.Decrypt(string(message1), msgType) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage) - - for i := 0; i < 8; i++ { - //alice sends, bob reveices - msgType, message, err := aliceSession.Encrypt(plainText) - assert.NoError(t, err) - if i == 0 { - //The first time should still be a preKeyMessage as bob has not yet send a message to alice - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - } else { - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - } - - decryptedMessage, err := bobSession.Decrypt(string(message), msgType) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage) - - //now bob sends, alice receives - msgType, message, err = bobSession.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - - decryptedMessage, err = aliceSession.Decrypt(string(message), msgType) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage) - } -} - -func TestFallbackKey(t *testing.T) { - accountA, err := account.NewAccount() - assert.NoError(t, err) - - accountB, err := account.NewAccount() - assert.NoError(t, err) - err = accountB.GenFallbackKey() - assert.NoError(t, err) - fallBackKeys := accountB.FallbackKeyUnpublished() - var fallbackKey id.Curve25519 - for _, fbKey := range fallBackKeys { - fallbackKey = fbKey - } - aliceSession, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), fallbackKey) - assert.NoError(t, err) - - plainText := []byte("Hello, World") - msgType, message1, err := aliceSession.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - - bobSession, err := accountB.NewInboundSession(string(message1)) - assert.NoError(t, err) - // Check that the inbound session matches the message it was created from. - sessionIsOK, err := bobSession.MatchesInboundSessionFrom("", string(message1)) - assert.NoError(t, err) - assert.True(t, sessionIsOK, "session was not detected to be valid") - - // Check that the inbound session matches the key this message is supposed to be from. - aIDKey := accountA.IdKeys.Curve25519.PublicKey.B64Encoded() - sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(aIDKey), string(message1)) - assert.NoError(t, err) - assert.True(t, sessionIsOK, "session is sad to be not from a but it should") - - // Check that the inbound session isn't from a different user. - bIDKey := accountB.IdKeys.Curve25519.PublicKey.B64Encoded() - sessionIsOK, err = bobSession.MatchesInboundSessionFrom(string(bIDKey), string(message1)) - assert.NoError(t, err) - assert.False(t, sessionIsOK, "session is sad to be from b but is from a") - - // Check that we can decrypt the message. - decryptedMessage, err := bobSession.Decrypt(string(message1), msgType) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage) - - // create a new fallback key for B (the old fallback should still be usable) - err = accountB.GenFallbackKey() - assert.NoError(t, err) - // start another session and encrypt a message - aliceSession2, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), fallbackKey) - assert.NoError(t, err) - - msgType2, message2, err := aliceSession2.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType2) - - // bobSession should not be valid for the message2 - // Check that the inbound session matches the message it was created from. - sessionIsOK, err = bobSession.MatchesInboundSessionFrom("", string(message2)) - assert.NoError(t, err) - assert.False(t, sessionIsOK, "session was detected to be valid but should not") - - bobSession2, err := accountB.NewInboundSession(string(message2)) - assert.NoError(t, err) - // Check that the inbound session matches the message it was created from. - sessionIsOK, err = bobSession2.MatchesInboundSessionFrom("", string(message2)) - assert.NoError(t, err) - assert.True(t, sessionIsOK, "session was not detected to be valid") - - // Check that the inbound session matches the key this message is supposed to be from. - sessionIsOK, err = bobSession2.MatchesInboundSessionFrom(string(aIDKey), string(message2)) - assert.NoError(t, err) - assert.True(t, sessionIsOK, "session is sad to be not from a but it should") - - // Check that the inbound session isn't from a different user. - sessionIsOK, err = bobSession2.MatchesInboundSessionFrom(string(bIDKey), string(message2)) - assert.NoError(t, err) - assert.False(t, sessionIsOK, "session is sad to be from b but is from a") - - // Check that we can decrypt the message. - decryptedMessage2, err := bobSession2.Decrypt(string(message2), msgType2) - assert.NoError(t, err) - assert.Equal(t, plainText, decryptedMessage2) - - //Forget the old fallback key -- creating a new session should fail now - accountB.ForgetOldFallbackKey() - // start another session and encrypt a message - aliceSession3, err := accountA.NewOutboundSession(accountB.IdKeys.Curve25519.B64Encoded(), fallbackKey) - assert.NoError(t, err) - msgType3, message3, err := aliceSession3.Encrypt(plainText) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType3) - _, err = accountB.NewInboundSession(string(message3)) - assert.ErrorIs(t, err, olm.ErrBadMessageKeyID) -} - -func TestOldV3AccountPickle(t *testing.T) { - pickledData := []byte("0mSqVn3duHffbhaTbFgW+4JPlcRoqT7z0x4mQ72N+g+eSAk5sgcWSoDzKpMazgcB" + - "46ItEpChthVHTGRA6PD3dly0dUs4ji7VtWTa+1tUv1UbxP92uYf1Ae3fomX0yAoH" + - "OjSrz1+RmuXr+At8jsmsf260sKvhB6LnI3qYsrw6AAtpgk5d5xZd66sLxvvYUuai" + - "+SmmcmT0bHosLTuDiiB9amBvPKkUKtKZmaEAl5ULrgnJygp1/FnwzVfSrw6PBSX6" + - "ZaUEZHZGX1iI6/WjbHqlTQeOQjtaSsPaL5XXpteS9dFsuaANAj+8ks7Ut2Hwg/JP" + - "Ih/ERYBwiMh9Mt3zSAG0NkvgUkcdipKxoSNZ6t+TkqZrN6jG6VCbx+4YpJO24iJb" + - "ShZy8n79aePIgIsxX94ycsTq1ic38sCRSkWGVbCSRkPloHW7ZssLHA") - pickleKey := []byte("") - expectedFallbackJSON := []byte("{\"curve25519\":{\"AAAAAQ\":\"dr98y6VOWt6lJaQgFVZeWY2ky76mga9MEMbdItJTdng\"}}") - expectedUnpublishedFallbackJSON := []byte("{\"curve25519\":{}}") - - account, err := account.AccountFromPickled(pickledData, pickleKey) - assert.NoError(t, err) - fallbackJSON, err := account.FallbackKeyJSON() - assert.NoError(t, err) - assert.Equal(t, expectedFallbackJSON, fallbackJSON) - fallbackJSONUnpublished, err := account.FallbackKeyUnpublishedJSON() - assert.NoError(t, err) - assert.Equal(t, expectedUnpublishedFallbackJSON, fallbackJSONUnpublished) -} - -func TestAccountSign(t *testing.T) { - accountA, err := account.NewAccount() - assert.NoError(t, err) - plainText := []byte("Hello, World") - signatureB64, err := accountA.Sign(plainText) - assert.NoError(t, err) - signature, err := base64.RawStdEncoding.DecodeString(string(signatureB64)) - assert.NoError(t, err) - - verified, err := signatures.VerifySignature(plainText, accountA.IdKeys.Ed25519.B64Encoded(), signature) - assert.NoError(t, err) - assert.True(t, verified) -} diff --git a/mautrix-patched/crypto/goolm/account/register.go b/mautrix-patched/crypto/goolm/account/register.go deleted file mode 100644 index ec392d7e..00000000 --- a/mautrix-patched/crypto/goolm/account/register.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package account - -import ( - "maunium.net/go/mautrix/crypto/olm" -) - -func Register() { - olm.InitNewAccount = func() (olm.Account, error) { - return NewAccount() - } - olm.InitBlankAccount = func() olm.Account { - return &Account{} - } - olm.InitNewAccountFromPickled = func(pickled, key []byte) (olm.Account, error) { - return AccountFromPickled(pickled, key) - } -} diff --git a/mautrix-patched/crypto/goolm/aessha2/aessha2.go b/mautrix-patched/crypto/goolm/aessha2/aessha2.go deleted file mode 100644 index f1b7ba75..00000000 --- a/mautrix-patched/crypto/goolm/aessha2/aessha2.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -// Package aessha2 implements the m.megolm.v1.aes-sha2 encryption algorithm -// described in [Section 10.12.4.3] in the Spec -// -// [Section 10.12.4.3]: https://spec.matrix.org/v1.12/client-server-api/#mmegolmv1aes-sha2 -package aessha2 - -import ( - "crypto/hmac" - "crypto/sha256" - "fmt" - "io" - - "golang.org/x/crypto/hkdf" - - "maunium.net/go/mautrix/crypto/aescbc" -) - -type AESSHA2 struct { - aesKey, hmacKey, iv []byte -} - -func NewAESSHA2(secret, info []byte) (AESSHA2, error) { - kdf := hkdf.New(sha256.New, secret, nil, info) - keymatter := make([]byte, 80) - _, err := io.ReadFull(kdf, keymatter) - return AESSHA2{ - keymatter[:32], // AES Key - keymatter[32:64], // HMAC Key - keymatter[64:], // IV - }, err -} - -func (a *AESSHA2) Encrypt(plaintext []byte) ([]byte, error) { - return aescbc.Encrypt(a.aesKey, a.iv, plaintext) -} - -func (a *AESSHA2) Decrypt(ciphertext []byte) ([]byte, error) { - return aescbc.Decrypt(a.aesKey, a.iv, ciphertext) -} - -func (a *AESSHA2) MAC(ciphertext []byte) ([]byte, error) { - hash := hmac.New(sha256.New, a.hmacKey) - _, err := hash.Write(ciphertext) - return hash.Sum(nil), err -} - -func (a *AESSHA2) VerifyMAC(ciphertext, theirMAC []byte, macLength int) (bool, error) { - if macLength > 32 { - panic(fmt.Sprintf("invalid mac length: %d", macLength)) - } else if len(theirMAC) != macLength { - return false, fmt.Errorf("unexpected input MAC length: %d != %d", len(theirMAC), macLength) - } else if mac, err := a.MAC(ciphertext); err != nil { - return false, err - } else { - return hmac.Equal(mac[:len(theirMAC)], theirMAC), nil - } -} diff --git a/mautrix-patched/crypto/goolm/aessha2/aessha2_test.go b/mautrix-patched/crypto/goolm/aessha2/aessha2_test.go deleted file mode 100644 index b7bd70b7..00000000 --- a/mautrix-patched/crypto/goolm/aessha2/aessha2_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package aessha2_test - -import ( - "crypto/aes" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" -) - -func TestCipherAESSha256(t *testing.T) { - key := []byte("test key") - cipher, err := aessha2.NewAESSHA2(key, []byte("testKDFinfo")) - assert.NoError(t, err) - message := []byte("this is a random message for testing the implementation") - //increase to next block size - for len(message)%aes.BlockSize != 0 { - message = append(message, []byte("-")...) - } - encrypted, err := cipher.Encrypt([]byte(message)) - assert.NoError(t, err) - mac, err := cipher.MAC(encrypted) - assert.NoError(t, err) - - _, err = cipher.VerifyMAC(encrypted, mac[:4], 8) - assert.Error(t, err) - - verified, err := cipher.VerifyMAC(encrypted, mac[:8], 8) - assert.NoError(t, err) - assert.True(t, verified, "signature verification failed") - - resultPlainText, err := cipher.Decrypt(encrypted) - assert.NoError(t, err) - assert.Equal(t, message, resultPlainText) -} diff --git a/mautrix-patched/crypto/goolm/crypto/curve25519.go b/mautrix-patched/crypto/goolm/crypto/curve25519.go deleted file mode 100644 index e6f4d765..00000000 --- a/mautrix-patched/crypto/goolm/crypto/curve25519.go +++ /dev/null @@ -1,127 +0,0 @@ -package crypto - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/base64" - - "golang.org/x/crypto/curve25519" - - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/id" -) - -const ( - Curve25519PrivateKeyLength = curve25519.ScalarSize //The length of the private key. - Curve25519PublicKeyLength = 32 -) - -// Curve25519KeyPair stores both parts of a curve25519 key. -type Curve25519KeyPair struct { - PrivateKey Curve25519PrivateKey `json:"private,omitempty"` - PublicKey Curve25519PublicKey `json:"public,omitempty"` -} - -// Curve25519GenerateKey creates a new curve25519 key pair. -func Curve25519GenerateKey() (Curve25519KeyPair, error) { - privateKeyByte := make([]byte, Curve25519PrivateKeyLength) - if _, err := rand.Read(privateKeyByte); err != nil { - return Curve25519KeyPair{}, err - } - - privateKey := Curve25519PrivateKey(privateKeyByte) - publicKey, err := privateKey.PubKey() - return Curve25519KeyPair{ - PrivateKey: Curve25519PrivateKey(privateKey), - PublicKey: Curve25519PublicKey(publicKey), - }, err -} - -// Curve25519GenerateFromPrivate creates a new curve25519 key pair with the private key given. -func Curve25519GenerateFromPrivate(private Curve25519PrivateKey) (Curve25519KeyPair, error) { - publicKey, err := private.PubKey() - return Curve25519KeyPair{ - PrivateKey: private, - PublicKey: Curve25519PublicKey(publicKey), - }, err -} - -// B64Encoded returns a base64 encoded string of the public key. -func (c Curve25519KeyPair) B64Encoded() id.Curve25519 { - return c.PublicKey.B64Encoded() -} - -// SharedSecret returns the shared secret between the key pair and the given public key. -func (c Curve25519KeyPair) SharedSecret(pubKey Curve25519PublicKey) ([]byte, error) { - // Note: the standard library checks that the output is non-zero - return c.PrivateKey.SharedSecret(pubKey) -} - -// PickleLibOlm pickles the key pair into the encoder. -func (c Curve25519KeyPair) PickleLibOlm(encoder *libolmpickle.Encoder) { - c.PublicKey.PickleLibOlm(encoder) - if len(c.PrivateKey) == Curve25519PrivateKeyLength { - encoder.Write(c.PrivateKey) - } else { - encoder.WriteEmptyBytes(Curve25519PrivateKeyLength) - } -} - -// UnpickleLibOlm decodes the unencryted value and populates the key pair accordingly. It returns the number of bytes read. -func (c *Curve25519KeyPair) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - if err := c.PublicKey.UnpickleLibOlm(decoder); err != nil { - return err - } else if privKey, err := decoder.ReadBytesOrNil(Curve25519PrivateKeyLength); err != nil { - return err - } else { - c.PrivateKey = privKey - return nil - } -} - -// Curve25519PrivateKey represents the private key for curve25519 usage -type Curve25519PrivateKey []byte - -// Equal compares the private key to the given private key. -func (c Curve25519PrivateKey) Equal(x Curve25519PrivateKey) bool { - return subtle.ConstantTimeCompare(c, x) == 1 -} - -// PubKey returns the public key derived from the private key. -func (c Curve25519PrivateKey) PubKey() (Curve25519PublicKey, error) { - return curve25519.X25519(c, curve25519.Basepoint) -} - -// SharedSecret returns the shared secret between the private key and the given public key. -func (c Curve25519PrivateKey) SharedSecret(pubKey Curve25519PublicKey) ([]byte, error) { - return curve25519.X25519(c, pubKey) -} - -// Curve25519PublicKey represents the public key for curve25519 usage -type Curve25519PublicKey []byte - -// Equal compares the public key to the given public key. -func (c Curve25519PublicKey) Equal(x Curve25519PublicKey) bool { - return subtle.ConstantTimeCompare(c, x) == 1 -} - -// B64Encoded returns a base64 encoded string of the public key. -func (c Curve25519PublicKey) B64Encoded() id.Curve25519 { - return id.Curve25519(base64.RawStdEncoding.EncodeToString(c)) -} - -// PickleLibOlm pickles the public key into the encoder. -func (c Curve25519PublicKey) PickleLibOlm(encoder *libolmpickle.Encoder) { - if len(c) == Curve25519PublicKeyLength { - encoder.Write(c) - } else { - encoder.WriteEmptyBytes(Curve25519PublicKeyLength) - } -} - -// UnpickleLibOlm decodes the unencryted value and populates the public key accordingly. It returns the number of bytes read. -func (c *Curve25519PublicKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - pubkey, err := decoder.ReadBytesOrNil(Curve25519PublicKeyLength) - *c = pubkey - return err -} diff --git a/mautrix-patched/crypto/goolm/crypto/curve25519_test.go b/mautrix-patched/crypto/goolm/crypto/curve25519_test.go deleted file mode 100644 index 2550f15e..00000000 --- a/mautrix-patched/crypto/goolm/crypto/curve25519_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package crypto_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -const curve25519KeyPairPickleLength = crypto.Curve25519PublicKeyLength + // Public Key - crypto.Curve25519PrivateKeyLength // Private Key - -func TestCurve25519(t *testing.T) { - firstKeypair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - secondKeypair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - sharedSecretFromFirst, err := firstKeypair.SharedSecret(secondKeypair.PublicKey) - assert.NoError(t, err) - sharedSecretFromSecond, err := secondKeypair.SharedSecret(firstKeypair.PublicKey) - assert.NoError(t, err) - assert.Equal(t, sharedSecretFromFirst, sharedSecretFromSecond, "shared secret not equal") - fromPrivate, err := crypto.Curve25519GenerateFromPrivate(firstKeypair.PrivateKey) - assert.NoError(t, err) - assert.Equal(t, fromPrivate, firstKeypair) - _, err = secondKeypair.SharedSecret(make([]byte, crypto.Curve25519PublicKeyLength)) - assert.Error(t, err) -} - -func TestCurve25519Case1(t *testing.T) { - alicePrivate := []byte{ - 0x77, 0x07, 0x6D, 0x0A, 0x73, 0x18, 0xA5, 0x7D, - 0x3C, 0x16, 0xC1, 0x72, 0x51, 0xB2, 0x66, 0x45, - 0xDF, 0x4C, 0x2F, 0x87, 0xEB, 0xC0, 0x99, 0x2A, - 0xB1, 0x77, 0xFB, 0xA5, 0x1D, 0xB9, 0x2C, 0x2A, - } - alicePublic := []byte{ - 0x85, 0x20, 0xF0, 0x09, 0x89, 0x30, 0xA7, 0x54, - 0x74, 0x8B, 0x7D, 0xDC, 0xB4, 0x3E, 0xF7, 0x5A, - 0x0D, 0xBF, 0x3A, 0x0D, 0x26, 0x38, 0x1A, 0xF4, - 0xEB, 0xA4, 0xA9, 0x8E, 0xAA, 0x9B, 0x4E, 0x6A, - } - bobPrivate := []byte{ - 0x5D, 0xAB, 0x08, 0x7E, 0x62, 0x4A, 0x8A, 0x4B, - 0x79, 0xE1, 0x7F, 0x8B, 0x83, 0x80, 0x0E, 0xE6, - 0x6F, 0x3B, 0xB1, 0x29, 0x26, 0x18, 0xB6, 0xFD, - 0x1C, 0x2F, 0x8B, 0x27, 0xFF, 0x88, 0xE0, 0xEB, - } - bobPublic := []byte{ - 0xDE, 0x9E, 0xDB, 0x7D, 0x7B, 0x7D, 0xC1, 0xB4, - 0xD3, 0x5B, 0x61, 0xC2, 0xEC, 0xE4, 0x35, 0x37, - 0x3F, 0x83, 0x43, 0xC8, 0x5B, 0x78, 0x67, 0x4D, - 0xAD, 0xFC, 0x7E, 0x14, 0x6F, 0x88, 0x2B, 0x4F, - } - expectedAgreement := []byte{ - 0x4A, 0x5D, 0x9D, 0x5B, 0xA4, 0xCE, 0x2D, 0xE1, - 0x72, 0x8E, 0x3B, 0xF4, 0x80, 0x35, 0x0F, 0x25, - 0xE0, 0x7E, 0x21, 0xC9, 0x47, 0xD1, 0x9E, 0x33, - 0x76, 0xF0, 0x9B, 0x3C, 0x1E, 0x16, 0x17, 0x42, - } - aliceKeyPair := crypto.Curve25519KeyPair{ - PrivateKey: alicePrivate, - PublicKey: alicePublic, - } - bobKeyPair := crypto.Curve25519KeyPair{ - PrivateKey: bobPrivate, - PublicKey: bobPublic, - } - agreementFromAlice, err := aliceKeyPair.SharedSecret(bobKeyPair.PublicKey) - assert.NoError(t, err) - assert.Equal(t, expectedAgreement, agreementFromAlice, "expected agreement does not match agreement from Alice's view") - agreementFromBob, err := bobKeyPair.SharedSecret(aliceKeyPair.PublicKey) - assert.NoError(t, err) - assert.Equal(t, expectedAgreement, agreementFromBob, "expected agreement does not match agreement from Bob's view") -} - -func TestCurve25519Pickle(t *testing.T) { - //create keypair - keyPair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - - encoder := libolmpickle.NewEncoder() - keyPair.PickleLibOlm(encoder) - assert.Len(t, encoder.Bytes(), curve25519KeyPairPickleLength) - - unpickledKeyPair := crypto.Curve25519KeyPair{} - err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) - assert.NoError(t, err) - assert.Equal(t, keyPair, unpickledKeyPair) -} - -func TestCurve25519PicklePubKeyOnly(t *testing.T) { - //create keypair - keyPair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - - //Remove privateKey - keyPair.PrivateKey = nil - - encoder := libolmpickle.NewEncoder() - keyPair.PickleLibOlm(encoder) - assert.Len(t, encoder.Bytes(), curve25519KeyPairPickleLength) - - unpickledKeyPair := crypto.Curve25519KeyPair{} - err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) - assert.NoError(t, err) - assert.Equal(t, keyPair, unpickledKeyPair) -} - -func TestCurve25519PicklePrivKeyOnly(t *testing.T) { - //create keypair - keyPair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - //Remove public - keyPair.PublicKey = nil - encoder := libolmpickle.NewEncoder() - keyPair.PickleLibOlm(encoder) - assert.Len(t, encoder.Bytes(), curve25519KeyPairPickleLength) - unpickledKeyPair := crypto.Curve25519KeyPair{} - err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) - assert.NoError(t, err) - assert.Equal(t, keyPair, unpickledKeyPair) -} diff --git a/mautrix-patched/crypto/goolm/crypto/doc.go b/mautrix-patched/crypto/goolm/crypto/doc.go deleted file mode 100644 index 5bdb01d8..00000000 --- a/mautrix-patched/crypto/goolm/crypto/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package crpyto provides the nessesary encryption methods for olm/megolm -package crypto diff --git a/mautrix-patched/crypto/goolm/crypto/ed25519.go b/mautrix-patched/crypto/goolm/crypto/ed25519.go deleted file mode 100644 index c0e7505b..00000000 --- a/mautrix-patched/crypto/goolm/crypto/ed25519.go +++ /dev/null @@ -1,164 +0,0 @@ -package crypto - -import ( - "bytes" - "encoding/base64" - - "filippo.io/edwards25519" - - "maunium.net/go/mautrix/crypto/ed25519" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/id" -) - -const ( - Ed25519SignatureSize = ed25519.SignatureSize //The length of a signature -) - -// Ed25519GenerateKey creates a new ed25519 key pair. -func Ed25519GenerateKey() (Ed25519KeyPair, error) { - publicKey, privateKey, err := ed25519.GenerateKey(nil) - return Ed25519KeyPair{ - PrivateKey: Ed25519PrivateKey(privateKey), - PublicKey: Ed25519PublicKey(publicKey), - }, err -} - -// Ed25519GenerateFromPrivate creates a new ed25519 key pair with the private key given. -func Ed25519GenerateFromPrivate(privKey Ed25519PrivateKey) Ed25519KeyPair { - return Ed25519KeyPair{ - PrivateKey: privKey, - PublicKey: privKey.PubKey(), - } -} - -// Ed25519GenerateFromSeed creates a new ed25519 key pair with a given seed. -func Ed25519GenerateFromSeed(seed []byte) Ed25519KeyPair { - privKey := Ed25519PrivateKey(ed25519.NewKeyFromSeed(seed)) - return Ed25519KeyPair{ - PrivateKey: privKey, - PublicKey: privKey.PubKey(), - } -} - -// Ed25519KeyPair stores both parts of a ed25519 key. -type Ed25519KeyPair struct { - PrivateKey Ed25519PrivateKey `json:"private,omitempty"` - PublicKey Ed25519PublicKey `json:"public,omitempty"` -} - -// B64Encoded returns a base64 encoded string of the public key. -func (c Ed25519KeyPair) B64Encoded() id.Ed25519 { - return id.Ed25519(base64.RawStdEncoding.EncodeToString(c.PublicKey)) -} - -// Sign returns the signature for the message. -func (c Ed25519KeyPair) Sign(message []byte) ([]byte, error) { - return c.PrivateKey.Sign(message) -} - -// Verify checks the signature of the message against the givenSignature -func (c Ed25519KeyPair) Verify(message, givenSignature []byte) bool { - return c.PublicKey.Verify(message, givenSignature) -} - -// PickleLibOlm pickles the key pair into the encoder. -func (c Ed25519KeyPair) PickleLibOlm(encoder *libolmpickle.Encoder) { - c.PublicKey.PickleLibOlm(encoder) - if len(c.PrivateKey) == ed25519.PrivateKeySize { - encoder.Write(c.PrivateKey) - } else { - encoder.WriteEmptyBytes(ed25519.PrivateKeySize) - } -} - -// UnpickleLibOlm unpickles the unencryted value and populates the key pair accordingly. -func (c *Ed25519KeyPair) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - if err := c.PublicKey.UnpickleLibOlm(decoder); err != nil { - return err - } else if privKey, err := decoder.ReadBytesOrNil(ed25519.PrivateKeySize); err != nil { - return err - } else { - c.PrivateKey = privKey - return nil - } -} - -// Curve25519PrivateKey represents the private key for ed25519 usage. This is just a wrapper. -type Ed25519PrivateKey ed25519.PrivateKey - -// Equal compares the private key to the given private key. -func (c Ed25519PrivateKey) Equal(x Ed25519PrivateKey) bool { - return ed25519.PrivateKey(c).Equal(ed25519.PrivateKey(x)) -} - -// PubKey returns the public key derived from the private key. -func (c Ed25519PrivateKey) PubKey() Ed25519PublicKey { - publicKey := ed25519.PrivateKey(c).Public() - return Ed25519PublicKey(publicKey.([]byte)) -} - -// Sign returns the signature for the message. -func (c Ed25519PrivateKey) Sign(message []byte) ([]byte, error) { - return ed25519.PrivateKey(c).Sign(nil, message, &ed25519.Options{}) -} - -// Ed25519PublicKey represents the public key for ed25519 usage. This is just a wrapper. -type Ed25519PublicKey ed25519.PublicKey - -// Equal compares the public key to the given public key. -func (c Ed25519PublicKey) Equal(x Ed25519PublicKey) bool { - return ed25519.PublicKey(c).Equal(ed25519.PublicKey(x)) -} - -// B64Encoded returns a base64 encoded string of the public key. -func (c Ed25519PublicKey) B64Encoded() id.Curve25519 { - return id.Curve25519(base64.RawStdEncoding.EncodeToString(c)) -} - -// Verify checks the signature of the message against the givenSignature using strict verification. -// In addition to the standard library ed25519 verification which checks signature malleability, -// this also rejects non-canonical and small-order public keys. -func (c Ed25519PublicKey) Verify(message, givenSignature []byte) bool { - if len(givenSignature) != Ed25519SignatureSize || !c.IsValidKey() { - return false - } - - return ed25519.Verify(ed25519.PublicKey(c), message, givenSignature) -} - -func (c Ed25519PublicKey) IsValidKey() bool { - if len(c) != ed25519.PublicKeySize { - return false - } - pubPoint, err := (&edwards25519.Point{}).SetBytes(c) - if err != nil { - return false - } - // Reject non-canonical public keys - if !bytes.Equal(pubPoint.Bytes(), c) { - return false - } - // Reject small-order public keys - if new(edwards25519.Point).MultByCofactor(pubPoint).Equal(edwards25519.NewIdentityPoint()) == 1 { - return false - } - return true -} - -// PickleLibOlm pickles the public key into the encoder. -func (c Ed25519PublicKey) PickleLibOlm(encoder *libolmpickle.Encoder) { - if len(c) == ed25519.PublicKeySize { - encoder.Write(c) - } else { - encoder.WriteEmptyBytes(ed25519.PublicKeySize) - } -} - -// UnpickleLibOlm unpickles the unencryted value and populates the public key -// accordingly. -func (c *Ed25519PublicKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - key, err := decoder.ReadBytesOrNil(ed25519.PublicKeySize) - *c = key - return err -} diff --git a/mautrix-patched/crypto/goolm/crypto/ed25519_test.go b/mautrix-patched/crypto/goolm/crypto/ed25519_test.go deleted file mode 100644 index 610b8f3e..00000000 --- a/mautrix-patched/crypto/goolm/crypto/ed25519_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package crypto_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/ed25519" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -const ed25519KeyPairPickleLength = ed25519.PublicKeySize + // PublicKey - ed25519.PrivateKeySize // Private Key - -func TestEd25519(t *testing.T) { - keypair, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - message := []byte("test message") - signature, err := keypair.Sign(message) - require.NoError(t, err) - assert.True(t, keypair.Verify(message, signature)) -} - -func TestEd25519Case1(t *testing.T) { - //64 bytes for ed25519 package - keyPair, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - message := []byte("Hello, World") - - keyPair2 := crypto.Ed25519GenerateFromPrivate(keyPair.PrivateKey) - assert.Equal(t, keyPair, keyPair2, "not equal key pairs") - signature, err := keyPair.Sign(message) - require.NoError(t, err) - verified := keyPair.Verify(message, signature) - assert.True(t, verified, "message did not verify although it should") - - //Now change the message and verify again - message = append(message, []byte("a")...) - verified = keyPair.Verify(message, signature) - assert.False(t, verified, "message did verify although it should not") -} - -func TestEd25519Pickle(t *testing.T) { - //create keypair - keyPair, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - encoder := libolmpickle.NewEncoder() - keyPair.PickleLibOlm(encoder) - assert.Len(t, encoder.Bytes(), ed25519KeyPairPickleLength) - - unpickledKeyPair := crypto.Ed25519KeyPair{} - err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) - assert.NoError(t, err) - assert.Equal(t, keyPair, unpickledKeyPair) -} - -func TestEd25519PicklePubKeyOnly(t *testing.T) { - //create keypair - keyPair, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - //Remove privateKey - keyPair.PrivateKey = nil - encoder := libolmpickle.NewEncoder() - keyPair.PickleLibOlm(encoder) - assert.Len(t, encoder.Bytes(), ed25519KeyPairPickleLength) - - unpickledKeyPair := crypto.Ed25519KeyPair{} - err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) - assert.NoError(t, err) - assert.Equal(t, keyPair, unpickledKeyPair) -} - -func TestEd25519PicklePrivKeyOnly(t *testing.T) { - //create keypair - keyPair, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - //Remove public - keyPair.PublicKey = nil - encoder := libolmpickle.NewEncoder() - keyPair.PickleLibOlm(encoder) - assert.Len(t, encoder.Bytes(), ed25519KeyPairPickleLength) - - unpickledKeyPair := crypto.Ed25519KeyPair{} - err = unpickledKeyPair.UnpickleLibOlm(libolmpickle.NewDecoder(encoder.Bytes())) - assert.NoError(t, err) - assert.Equal(t, keyPair, unpickledKeyPair) -} diff --git a/mautrix-patched/crypto/goolm/crypto/one_time_key.go b/mautrix-patched/crypto/goolm/crypto/one_time_key.go deleted file mode 100644 index 888b1749..00000000 --- a/mautrix-patched/crypto/goolm/crypto/one_time_key.go +++ /dev/null @@ -1,46 +0,0 @@ -package crypto - -import ( - "encoding/base64" - "encoding/binary" - - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -// OneTimeKey stores the information about a one time key. -type OneTimeKey struct { - ID uint32 `json:"id"` - Published bool `json:"published"` - Key Curve25519KeyPair `json:"key,omitempty"` -} - -// Equal compares the one time key to the given one. -func (otk OneTimeKey) Equal(other OneTimeKey) bool { - return otk.ID == other.ID && - otk.Published == other.Published && - otk.Key.PrivateKey.Equal(other.Key.PrivateKey) && - otk.Key.PublicKey.Equal(other.Key.PublicKey) -} - -// PickleLibOlm pickles the key pair into the encoder. -func (c OneTimeKey) PickleLibOlm(encoder *libolmpickle.Encoder) { - encoder.WriteUInt32(c.ID) - encoder.WriteBool(c.Published) - c.Key.PickleLibOlm(encoder) -} - -// UnpickleLibOlm unpickles the unencryted value and populates the [OneTimeKey] -// accordingly. -func (c *OneTimeKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) (err error) { - if c.ID, err = decoder.ReadUInt32(); err != nil { - return - } else if c.Published, err = decoder.ReadBool(); err != nil { - return - } - return c.Key.UnpickleLibOlm(decoder) -} - -// KeyIDEncoded returns the base64 encoded key ID. -func (c OneTimeKey) KeyIDEncoded() string { - return base64.RawStdEncoding.EncodeToString(binary.BigEndian.AppendUint32(nil, c.ID)) -} diff --git a/mautrix-patched/crypto/goolm/goolmbase64/base64.go b/mautrix-patched/crypto/goolm/goolmbase64/base64.go deleted file mode 100644 index 58ee26f7..00000000 --- a/mautrix-patched/crypto/goolm/goolmbase64/base64.go +++ /dev/null @@ -1,22 +0,0 @@ -package goolmbase64 - -import ( - "encoding/base64" -) - -// These methods should only be used for raw byte operations, never with string conversion - -func Decode(input []byte) ([]byte, error) { - decoded := make([]byte, base64.RawStdEncoding.DecodedLen(len(input))) - writtenBytes, err := base64.RawStdEncoding.Decode(decoded, input) - if err != nil { - return nil, err - } - return decoded[:writtenBytes], nil -} - -func Encode(input []byte) []byte { - encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(input))) - base64.RawStdEncoding.Encode(encoded, input) - return encoded -} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/encoder.go b/mautrix-patched/crypto/goolm/libolmpickle/encoder.go deleted file mode 100644 index 63e7b09b..00000000 --- a/mautrix-patched/crypto/goolm/libolmpickle/encoder.go +++ /dev/null @@ -1,40 +0,0 @@ -package libolmpickle - -import ( - "bytes" - "encoding/binary" - - "go.mau.fi/util/exerrors" -) - -const ( - PickleBoolLength = 1 - PickleUInt8Length = 1 - PickleUInt32Length = 4 -) - -type Encoder struct { - bytes.Buffer -} - -func NewEncoder() *Encoder { return &Encoder{} } - -func (p *Encoder) WriteUInt8(value uint8) { - exerrors.PanicIfNotNil(p.WriteByte(value)) -} - -func (p *Encoder) WriteBool(value bool) { - if value { - exerrors.PanicIfNotNil(p.WriteByte(0x01)) - } else { - exerrors.PanicIfNotNil(p.WriteByte(0x00)) - } -} - -func (p *Encoder) WriteEmptyBytes(count int) { - exerrors.Must(p.Write(make([]byte, count))) -} - -func (p *Encoder) WriteUInt32(value uint32) { - exerrors.PanicIfNotNil(binary.Write(&p.Buffer, binary.BigEndian, value)) -} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go b/mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go deleted file mode 100644 index c7811225..00000000 --- a/mautrix-patched/crypto/goolm/libolmpickle/encoder_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package libolmpickle_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -func TestEncoder(t *testing.T) { - var encoder libolmpickle.Encoder - encoder.WriteUInt32(4) - encoder.WriteUInt8(8) - encoder.WriteBool(false) - encoder.WriteEmptyBytes(10) - encoder.WriteBool(true) - encoder.Write([]byte("test")) - encoder.WriteUInt32(420_000) - assert.Equal(t, []byte{ - 0x00, 0x00, 0x00, 0x04, // 4 - 0x08, // 8 - 0x00, // false - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ten empty bytes - 0x01, //true - 0x74, 0x65, 0x73, 0x74, // "test" (ASCII) - 0x00, 0x06, 0x68, 0xa0, // 420,000 - }, encoder.Bytes()) -} - -func TestPickleUInt32(t *testing.T) { - values := []uint32{ - 0xffffffff, - 0x00ff00ff, - 0xf0000000, - 0xf00f0000, - } - expected := [][]byte{ - {0xff, 0xff, 0xff, 0xff}, - {0x00, 0xff, 0x00, 0xff}, - {0xf0, 0x00, 0x00, 0x00}, - {0xf0, 0x0f, 0x00, 0x00}, - } - for i, value := range values { - var encoder libolmpickle.Encoder - encoder.WriteUInt32(value) - assert.Equal(t, expected[i], encoder.Bytes()) - } -} - -func TestPickleBool(t *testing.T) { - values := []bool{ - true, - false, - } - expected := [][]byte{ - {0x01}, - {0x00}, - } - for i, value := range values { - var encoder libolmpickle.Encoder - encoder.WriteBool(value) - assert.Equal(t, expected[i], encoder.Bytes()) - } -} - -func TestPickleUInt8(t *testing.T) { - values := []uint8{ - 0xff, - 0x1a, - } - expected := [][]byte{ - {0xff}, - {0x1a}, - } - for i, value := range values { - var encoder libolmpickle.Encoder - encoder.WriteUInt8(value) - assert.Equal(t, expected[i], encoder.Bytes()) - } -} - -func TestPickleBytes(t *testing.T) { - values := [][]byte{ - {0xff, 0xff, 0xff, 0xff}, - {0x00, 0xff, 0x00, 0xff}, - {0xf0, 0x00, 0x00, 0x00}, - } - expected := [][]byte{ - {0xff, 0xff, 0xff, 0xff}, - {0x00, 0xff, 0x00, 0xff}, - {0xf0, 0x00, 0x00, 0x00}, - } - for i, value := range values { - var encoder libolmpickle.Encoder - encoder.Write(value) - assert.Equal(t, expected[i], encoder.Bytes()) - } -} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/pickle.go b/mautrix-patched/crypto/goolm/libolmpickle/pickle.go deleted file mode 100644 index 477e5620..00000000 --- a/mautrix-patched/crypto/goolm/libolmpickle/pickle.go +++ /dev/null @@ -1,53 +0,0 @@ -package libolmpickle - -import ( - "crypto/aes" - "fmt" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/goolmbase64" - "maunium.net/go/mautrix/crypto/olm" -) - -const pickleMACLength = 8 - -var kdfPickle = []byte("Pickle") //used to derive the keys for encryption - -// Pickle encrypts the input with the key and the cipher AESSHA256. The result is then encoded in base64. -// -// The encryption used here is not particularly secure: both the AES key and IV are deterministic based on the pickle key. -// However, pickles are only used locally and the key is usually hardcoded or stored next to the database anyway. -func Pickle(key, plaintext []byte) ([]byte, error) { - if c, err := aessha2.NewAESSHA2(key, kdfPickle); err != nil { - return nil, err - } else if ciphertext, err := c.Encrypt(plaintext); err != nil { - return nil, err - } else if mac, err := c.MAC(ciphertext); err != nil { - return nil, err - } else { - return goolmbase64.Encode(append(ciphertext, mac[:pickleMACLength]...)), nil - } -} - -// Unpickle decodes the input from base64 and decrypts the decoded input with the key and the cipher AESSHA256. -func Unpickle(key, input []byte) ([]byte, error) { - ciphertext, err := goolmbase64.Decode(input) - if err != nil { - return nil, err - } - if len(ciphertext) < pickleMACLength { - return nil, fmt.Errorf("decrypt pickle: input too short") - } - ciphertext, mac := ciphertext[:len(ciphertext)-pickleMACLength], ciphertext[len(ciphertext)-pickleMACLength:] - if len(ciphertext)%aes.BlockSize != 0 { - return nil, fmt.Errorf("decrypt pickle: ciphertext length %d not a multiple of block size", len(ciphertext)) - } else if c, err := aessha2.NewAESSHA2(key, kdfPickle); err != nil { - return nil, err - } else if verified, err := c.VerifyMAC(ciphertext, mac, pickleMACLength); err != nil { - return nil, err - } else if !verified { - return nil, fmt.Errorf("decrypt pickle: %w", olm.ErrBadMAC) - } else { - return c.Decrypt(ciphertext) - } -} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go b/mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go deleted file mode 100644 index 0720e008..00000000 --- a/mautrix-patched/crypto/goolm/libolmpickle/pickle_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package libolmpickle - -import ( - "crypto/aes" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestEncoding(t *testing.T) { - key := []byte("test key") - input := []byte("test") - //pad marshaled to get block size - toEncrypt := input - if len(input)%aes.BlockSize != 0 { - padding := aes.BlockSize - len(input)%aes.BlockSize - toEncrypt = make([]byte, len(input)+padding) - copy(toEncrypt, input) - } - encoded, err := Pickle(key, toEncrypt) - assert.NoError(t, err) - - decoded, err := Unpickle(key, encoded) - assert.NoError(t, err) - assert.Equal(t, toEncrypt, decoded) -} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/unpickle.go b/mautrix-patched/crypto/goolm/libolmpickle/unpickle.go deleted file mode 100644 index 84ad43ea..00000000 --- a/mautrix-patched/crypto/goolm/libolmpickle/unpickle.go +++ /dev/null @@ -1,58 +0,0 @@ -package libolmpickle - -import ( - "bytes" - "encoding/binary" - "fmt" -) - -func isZeroByteSlice(data []byte) bool { - for _, b := range data { - if b != 0 { - return false - } - } - return true -} - -type Decoder struct { - buf bytes.Buffer -} - -func NewDecoder(buf []byte) *Decoder { - return &Decoder{buf: *bytes.NewBuffer(buf)} -} - -func (d *Decoder) ReadUInt8() (uint8, error) { - return d.buf.ReadByte() -} - -func (d *Decoder) ReadBool() (bool, error) { - val, err := d.buf.ReadByte() - return val != 0x00, err -} - -func (d *Decoder) ReadBytesOrNil(length int) (data []byte, err error) { - data, err = d.ReadBytes(length) - if err == nil && isZeroByteSlice(data) { - data = nil - } - return -} - -func (d *Decoder) ReadBytes(length int) (data []byte, err error) { - data = d.buf.Next(length) - if len(data) != length { - return nil, fmt.Errorf("only %d in buffer, expected %d", len(data), length) - } - return -} - -func (d *Decoder) ReadUInt32() (uint32, error) { - data := d.buf.Next(4) - if len(data) != 4 { - return 0, fmt.Errorf("only %d bytes is buffer, expected 4 for uint32", len(data)) - } else { - return binary.BigEndian.Uint32(data), nil - } -} diff --git a/mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go b/mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go deleted file mode 100644 index 30355a76..00000000 --- a/mautrix-patched/crypto/goolm/libolmpickle/unpickle_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package libolmpickle_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -func TestUnpickleUInt32(t *testing.T) { - expected := []uint32{ - 0xffffffff, - 0x00ff00ff, - 0xf0000000, - } - values := [][]byte{ - {0xff, 0xff, 0xff, 0xff}, - {0x00, 0xff, 0x00, 0xff}, - {0xf0, 0x00, 0x00, 0x00}, - } - for curIndex := range values { - decoder := libolmpickle.NewDecoder(values[curIndex]) - response, err := decoder.ReadUInt32() - assert.NoError(t, err) - assert.Equal(t, expected[curIndex], response) - } -} - -func TestUnpickleBool(t *testing.T) { - expected := []bool{ - true, - false, - true, - } - values := [][]byte{ - {0x01}, - {0x00}, - {0x02}, - } - for curIndex := range values { - decoder := libolmpickle.NewDecoder(values[curIndex]) - response, err := decoder.ReadBool() - assert.NoError(t, err) - assert.Equal(t, expected[curIndex], response) - } -} - -func TestUnpickleUInt8(t *testing.T) { - expected := []uint8{ - 0xff, - 0x1a, - } - values := [][]byte{ - {0xff}, - {0x1a}, - } - for curIndex := range values { - decoder := libolmpickle.NewDecoder(values[curIndex]) - response, err := decoder.ReadUInt8() - assert.NoError(t, err) - assert.Equal(t, expected[curIndex], response) - } -} - -func TestUnpickleBytes(t *testing.T) { - values := [][]byte{ - {0xff, 0xff, 0xff, 0xff}, - {0x00, 0xff, 0x00, 0xff}, - {0xf0, 0x00, 0x00, 0x00}, - } - expected := [][]byte{ - {0xff, 0xff, 0xff, 0xff}, - {0x00, 0xff, 0x00, 0xff}, - {0xf0, 0x00, 0x00, 0x00}, - } - for curIndex := range values { - decoder := libolmpickle.NewDecoder(values[curIndex]) - response, err := decoder.ReadBytes(4) - assert.NoError(t, err) - assert.Equal(t, expected[curIndex], response) - } -} diff --git a/mautrix-patched/crypto/goolm/main.go b/mautrix-patched/crypto/goolm/main.go deleted file mode 100644 index 55674305..00000000 --- a/mautrix-patched/crypto/goolm/main.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package goolm is a pure Go implementation of libolm. Libolm is a cryptographic library used for end-to-end encryption in Matrix and written in C++. -// With goolm there is no need to use cgo when building Matrix clients in go. -/* -This package contains the possible errors which can occur as well as some simple functions. All the 'action' happens in the subdirectories. -*/ -package goolm diff --git a/mautrix-patched/crypto/goolm/megolm/megolm.go b/mautrix-patched/crypto/goolm/megolm/megolm.go deleted file mode 100644 index 12029d53..00000000 --- a/mautrix-patched/crypto/goolm/megolm/megolm.go +++ /dev/null @@ -1,209 +0,0 @@ -// megolm provides the ratchet used by the megolm protocol -package megolm - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "fmt" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/goolmbase64" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/goolm/message" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - protocolVersion = 3 - RatchetParts = 4 // number of ratchet parts - RatchetPartLength = 256 / 8 // length of each ratchet part in bytes -) - -var megolmKeysKDFInfo = []byte("MEGOLM_KEYS") - -// hasKeySeed are the seed for the different ratchet parts -var hashKeySeeds [RatchetParts][]byte = [RatchetParts][]byte{ - {0x00}, - {0x01}, - {0x02}, - {0x03}, -} - -// Ratchet represents the megolm ratchet as described in -// -// https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/megolm.md -type Ratchet struct { - Data [RatchetParts * RatchetPartLength]byte `json:"data"` - Counter uint32 `json:"counter"` -} - -// New creates a new ratchet with counter set to counter and the ratchet data set to data. -func New(counter uint32, data [RatchetParts * RatchetPartLength]byte) (*Ratchet, error) { - m := &Ratchet{ - Counter: counter, - Data: data, - } - return m, nil -} - -// NewWithRandom creates a new ratchet with counter set to counter an the data filled with random values. -func NewWithRandom(counter uint32) (*Ratchet, error) { - var data [RatchetParts * RatchetPartLength]byte - _, err := rand.Read(data[:]) - if err != nil { - return nil, err - } - return New(counter, data) -} - -// rehashPart rehases the part of the ratchet data with the base defined as from storing into the target to. -func (m *Ratchet) rehashPart(from, to int) { - hash := hmac.New(sha256.New, m.Data[from*RatchetPartLength:from*RatchetPartLength+RatchetPartLength]) - hash.Write(hashKeySeeds[to]) - copy(m.Data[to*RatchetPartLength:], hash.Sum(nil)) -} - -// Advance advances the ratchet one step. -func (m *Ratchet) Advance() { - var mask uint32 = 0x00FFFFFF - var h int - m.Counter++ - - // figure out how much we need to rekey - for h < RatchetParts { - if (m.Counter & mask) == 0 { - break - } - h++ - mask >>= 8 - } - - // now update R(h)...R(3) based on R(h) - for i := RatchetParts - 1; i >= h; i-- { - m.rehashPart(h, i) - } -} - -// AdvanceTo advances the ratchet so that the ratchet counter = target -func (m *Ratchet) AdvanceTo(target uint32) { - //starting with R0, see if we need to update each part of the hash - for j := 0; j < RatchetParts; j++ { - shift := uint32((RatchetParts - j - 1) * 8) - mask := (^uint32(0)) << shift - - // how many times do we need to rehash this part? - // '& 0xff' ensures we handle integer wraparound correctly - steps := ((target >> shift) - (m.Counter >> shift)) & uint32(0xff) - - if steps == 0 { - /* - deal with the edge case where m.Counter is slightly larger - than target. This should only happen for R(0), and implies - that target has wrapped around and we need to advance R(0) - 256 times. - */ - if target < m.Counter { - steps = 0x100 - } else { - continue - } - } - // for all but the last step, we can just bump R(j) without regard to R(j+1)...R(3). - for steps > 1 { - m.rehashPart(j, j) - steps-- - } - /* - on the last step we also need to bump R(j+1)...R(3). - - (Theoretically, we could skip bumping R(j+2) if we're going to bump - R(j+1) again, but the code to figure that out is a bit baroque and - doesn't save us much). - */ - for k := 3; k >= j; k-- { - m.rehashPart(j, k) - } - m.Counter = target & mask - } -} - -// Encrypt encrypts the message in a message.GroupMessage with MAC and signature. -// The output is base64 encoded. -func (r *Ratchet) Encrypt(plaintext []byte, key crypto.Ed25519KeyPair) ([]byte, error) { - cipher, err := aessha2.NewAESSHA2(r.Data[:], megolmKeysKDFInfo) - if err != nil { - return nil, fmt.Errorf("cipher encrypt: %w", err) - } - - message := &message.GroupMessage{} - message.Version = protocolVersion - message.MessageIndex = r.Counter - message.Ciphertext, err = cipher.Encrypt(plaintext) - if err != nil { - return nil, err - } - //creating the MAC and signing is done in encode - output, err := message.EncodeAndMACAndSign(cipher, key) - if err != nil { - return nil, err - } - r.Advance() - return output, nil -} - -// SessionSharingMessage creates a message in the session sharing format. -func (r Ratchet) SessionSharingMessage(key crypto.Ed25519KeyPair) ([]byte, error) { - m := message.MegolmSessionSharing{} - m.Counter = r.Counter - m.RatchetData = r.Data - encoded, err := m.EncodeAndSign(key) - return goolmbase64.Encode(encoded), err -} - -// SessionExportMessage creates a message in the session export format. -func (r Ratchet) SessionExportMessage(key crypto.Ed25519PublicKey) ([]byte, error) { - m := message.MegolmSessionExport{} - m.Counter = r.Counter - m.RatchetData = r.Data - m.PublicKey = key - encoded := m.Encode() - return goolmbase64.Encode(encoded), nil -} - -// Decrypt decrypts the ciphertext and verifies the MAC but not the signature. -func (r Ratchet) Decrypt(ciphertext []byte, msg *message.GroupMessage) ([]byte, error) { - //verify mac - cipher, err := aessha2.NewAESSHA2(r.Data[:], megolmKeysKDFInfo) - if err != nil { - return nil, err - } - verifiedMAC, err := msg.VerifyMACInline(cipher, ciphertext) - if err != nil { - return nil, err - } - if !verifiedMAC { - return nil, fmt.Errorf("decrypt: %w", olm.ErrBadMAC) - } - - return cipher.Decrypt(msg.Ciphertext) -} - -// UnpickleLibOlm decodes the unencryted value and populates the Ratchet accordingly. It returns the number of bytes read. -func (r *Ratchet) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - ratchetData, err := decoder.ReadBytes(RatchetParts * RatchetPartLength) - if err != nil { - return err - } - copy(r.Data[:], ratchetData) - - r.Counter, err = decoder.ReadUInt32() - return err -} - -// PickleLibOlm pickles the ratchet into the encoder. -func (r Ratchet) PickleLibOlm(encoder *libolmpickle.Encoder) { - encoder.Write(r.Data[:]) - encoder.WriteUInt32(r.Counter) -} diff --git a/mautrix-patched/crypto/goolm/megolm/megolm_test.go b/mautrix-patched/crypto/goolm/megolm/megolm_test.go deleted file mode 100644 index a6f7c1a7..00000000 --- a/mautrix-patched/crypto/goolm/megolm/megolm_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package megolm_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/megolm" -) - -var startData [megolm.RatchetParts * megolm.RatchetPartLength]byte - -func init() { - startValue := []byte("0123456789ABCDEF0123456789ABCDEF") - copy(startData[:], startValue) - copy(startData[32:], startValue) - copy(startData[64:], startValue) - copy(startData[96:], startValue) -} - -func TestAdvance(t *testing.T) { - m, err := megolm.New(0, startData) - assert.NoError(t, err) - - expectedData := [megolm.RatchetParts * megolm.RatchetPartLength]byte{ - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, - 0xba, 0x9c, 0xd9, 0x55, 0x74, 0x1d, 0x1c, 0x16, 0x23, 0x23, 0xec, 0x82, 0x5e, 0x7c, 0x5c, 0xe8, - 0x89, 0xbb, 0xb4, 0x23, 0xa1, 0x8f, 0x23, 0x82, 0x8f, 0xb2, 0x09, 0x0d, 0x6e, 0x2a, 0xf8, 0x6a, - } - m.Advance() - assert.Equal(t, m.Data[:], expectedData[:], "result after advancing the ratchet is not as expected") - - //repeat with complex advance - m.Data = startData - expectedData = [megolm.RatchetParts * megolm.RatchetPartLength]byte{ - 0x54, 0x02, 0x2d, 0x7d, 0xc0, 0x29, 0x8e, 0x16, 0x37, 0xe2, 0x1c, 0x97, 0x15, 0x30, 0x92, 0xf9, - 0x33, 0xc0, 0x56, 0xff, 0x74, 0xfe, 0x1b, 0x92, 0x2d, 0x97, 0x1f, 0x24, 0x82, 0xc2, 0x85, 0x9c, - 0x70, 0x04, 0xc0, 0x1e, 0xe4, 0x9b, 0xd6, 0xef, 0xe0, 0x07, 0x35, 0x25, 0xaf, 0x9b, 0x16, 0x32, - 0xc5, 0xbe, 0x72, 0x6d, 0x12, 0x34, 0x9c, 0xc5, 0xbd, 0x47, 0x2b, 0xdc, 0x2d, 0xf6, 0x54, 0x0f, - 0x31, 0x12, 0x59, 0x11, 0x94, 0xfd, 0xa6, 0x17, 0xe5, 0x68, 0xc6, 0x83, 0x10, 0x1e, 0xae, 0xcd, - 0x7e, 0xdd, 0xd6, 0xde, 0x1f, 0xbc, 0x07, 0x67, 0xae, 0x34, 0xda, 0x1a, 0x09, 0xa5, 0x4e, 0xab, - 0xba, 0x9c, 0xd9, 0x55, 0x74, 0x1d, 0x1c, 0x16, 0x23, 0x23, 0xec, 0x82, 0x5e, 0x7c, 0x5c, 0xe8, - 0x89, 0xbb, 0xb4, 0x23, 0xa1, 0x8f, 0x23, 0x82, 0x8f, 0xb2, 0x09, 0x0d, 0x6e, 0x2a, 0xf8, 0x6a, - } - m.AdvanceTo(0x1000000) - assert.Equal(t, m.Data[:], expectedData[:], "result after advancing the ratchet is not as expected") - - expectedData = [megolm.RatchetParts * megolm.RatchetPartLength]byte{ - 0x54, 0x02, 0x2d, 0x7d, 0xc0, 0x29, 0x8e, 0x16, 0x37, 0xe2, 0x1c, 0x97, 0x15, 0x30, 0x92, 0xf9, - 0x33, 0xc0, 0x56, 0xff, 0x74, 0xfe, 0x1b, 0x92, 0x2d, 0x97, 0x1f, 0x24, 0x82, 0xc2, 0x85, 0x9c, - 0x55, 0x58, 0x8d, 0xf5, 0xb7, 0xa4, 0x88, 0x78, 0x42, 0x89, 0x27, 0x86, 0x81, 0x64, 0x58, 0x9f, - 0x36, 0x63, 0x44, 0x7b, 0x51, 0xed, 0xc3, 0x59, 0x5b, 0x03, 0x6c, 0xa6, 0x04, 0xc4, 0x6d, 0xcd, - 0x5c, 0x54, 0x85, 0x0b, 0xfa, 0x98, 0xa1, 0xfd, 0x79, 0xa9, 0xdf, 0x1c, 0xbe, 0x8f, 0xc5, 0x68, - 0x19, 0x37, 0xd3, 0x0c, 0x85, 0xc8, 0xc3, 0x1f, 0x7b, 0xb8, 0x28, 0x81, 0x6c, 0xf9, 0xff, 0x3b, - 0x95, 0x6c, 0xbf, 0x80, 0x7e, 0x65, 0x12, 0x6a, 0x49, 0x55, 0x8d, 0x45, 0xc8, 0x4a, 0x2e, 0x4c, - 0xd5, 0x6f, 0x03, 0xe2, 0x44, 0x16, 0xb9, 0x8e, 0x1c, 0xfd, 0x97, 0xc2, 0x06, 0xaa, 0x90, 0x7a, - } - m.AdvanceTo(0x1041506) - assert.Equal(t, m.Data[:], expectedData[:], "result after advancing the ratchet is not as expected") -} - -func TestAdvanceWraparound(t *testing.T) { - m, err := megolm.New(0xffffffff, startData) - assert.NoError(t, err) - m.AdvanceTo(0x1000000) - assert.EqualValues(t, 0x1000000, m.Counter, "counter not correct") - - m2, err := megolm.New(0, startData) - assert.NoError(t, err) - m2.AdvanceTo(0x2000000) - assert.EqualValues(t, 0x2000000, m2.Counter, "counter not correct") - assert.Equal(t, m.Data, m2.Data, "result after wrapping the ratchet is not as expected") -} - -func TestAdvanceOverflowByOne(t *testing.T) { - m, err := megolm.New(0xffffffff, startData) - assert.NoError(t, err) - m.AdvanceTo(0x0) - assert.EqualValues(t, 0x0, m.Counter, "counter not correct") - - m2, err := megolm.New(0xffffffff, startData) - assert.NoError(t, err) - m2.Advance() - assert.EqualValues(t, 0x0, m2.Counter, "counter not correct") - assert.Equal(t, m.Data, m2.Data, "result after wrapping the ratchet is not as expected") -} - -func TestAdvanceOverflow(t *testing.T) { - m, err := megolm.New(0x1, startData) - assert.NoError(t, err) - m.AdvanceTo(0x80000000) - m.AdvanceTo(0x0) - assert.EqualValues(t, 0x0, m.Counter, "counter not correct") - - m2, err := megolm.New(0x1, startData) - assert.NoError(t, err) - m2.AdvanceTo(0x0) - assert.EqualValues(t, 0x0, m2.Counter, "counter not correct") - assert.Equal(t, m.Data, m2.Data, "result after wrapping the ratchet is not as expected") -} diff --git a/mautrix-patched/crypto/goolm/message/decoder.go b/mautrix-patched/crypto/goolm/message/decoder.go deleted file mode 100644 index b06756a9..00000000 --- a/mautrix-patched/crypto/goolm/message/decoder.go +++ /dev/null @@ -1,33 +0,0 @@ -package message - -import ( - "bytes" - "encoding/binary" - "fmt" - - "maunium.net/go/mautrix/crypto/olm" -) - -type Decoder struct { - *bytes.Buffer -} - -func NewDecoder(buf []byte) *Decoder { - return &Decoder{bytes.NewBuffer(buf)} -} - -func (d *Decoder) ReadVarInt() (uint64, error) { - return binary.ReadUvarint(d) -} - -func (d *Decoder) ReadVarBytes() ([]byte, error) { - if n, err := d.ReadVarInt(); err != nil { - return nil, err - } else if n > uint64(d.Len()) { - return nil, fmt.Errorf("%w: var bytes length says %d, but only %d bytes left", olm.ErrInputToSmall, n, d.Available()) - } else { - out := make([]byte, n) - _, err = d.Read(out) - return out, err - } -} diff --git a/mautrix-patched/crypto/goolm/message/encoder.go b/mautrix-patched/crypto/goolm/message/encoder.go deleted file mode 100644 index 95ab6d41..00000000 --- a/mautrix-patched/crypto/goolm/message/encoder.go +++ /dev/null @@ -1,24 +0,0 @@ -package message - -import "encoding/binary" - -type Encoder struct { - buf []byte -} - -func (e *Encoder) Bytes() []byte { - return e.buf -} - -func (e *Encoder) PutByte(val byte) { - e.buf = append(e.buf, val) -} - -func (e *Encoder) PutVarInt(val uint64) { - e.buf = binary.AppendUvarint(e.buf, val) -} - -func (e *Encoder) PutVarBytes(data []byte) { - e.PutVarInt(uint64(len(data))) - e.buf = append(e.buf, data...) -} diff --git a/mautrix-patched/crypto/goolm/message/encoder_test.go b/mautrix-patched/crypto/goolm/message/encoder_test.go deleted file mode 100644 index 1fe2ebdb..00000000 --- a/mautrix-patched/crypto/goolm/message/encoder_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package message_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/message" -) - -func TestEncodeInt(t *testing.T) { - var ints []uint32 - var expected [][]byte - ints = append(ints, 7) - expected = append(expected, []byte{0b00000111}) - ints = append(ints, 127) - expected = append(expected, []byte{0b01111111}) - ints = append(ints, 128) - expected = append(expected, []byte{0b10000000, 0b00000001}) - ints = append(ints, 16383) - expected = append(expected, []byte{0b11111111, 0b01111111}) - for curIndex := range ints { - var encoder message.Encoder - encoder.PutVarInt(uint64(ints[curIndex])) - assert.Equal(t, expected[curIndex], encoder.Bytes()) - } -} - -func TestEncodeString(t *testing.T) { - var strings [][]byte - var expected [][]byte - curTest := []byte("test") - strings = append(strings, curTest) - res := []byte{ - 0b00000100, //varint length of string - } - res = append(res, curTest...) //Add string itself - expected = append(expected, res) - curTest = []byte("this is a long message with a length of 127 so that the varint of the length is just one byte. just needs some padding---------") - strings = append(strings, curTest) - res = []byte{ - 0b01111111, //varint length of string - } - res = append(res, curTest...) //Add string itself - expected = append(expected, res) - curTest = []byte("this is an even longer message with a length between 128 and 16383 so that the varint of the length needs two byte. just needs some padding again ---------") - strings = append(strings, curTest) - res = []byte{ - 0b10011011, //varint length of string - 0b00000001, //varint length of string - } - res = append(res, curTest...) //Add string itself - expected = append(expected, res) - for curIndex := range strings { - var encoder message.Encoder - encoder.PutVarBytes(strings[curIndex]) - assert.Equal(t, expected[curIndex], encoder.Bytes()) - } -} diff --git a/mautrix-patched/crypto/goolm/message/group_message.go b/mautrix-patched/crypto/goolm/message/group_message.go deleted file mode 100644 index 48e7329f..00000000 --- a/mautrix-patched/crypto/goolm/message/group_message.go +++ /dev/null @@ -1,109 +0,0 @@ -package message - -import ( - "fmt" - "io" - "math" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - messageIndexTag = 0x08 - cipherTextTag = 0x12 - countMACBytesGroupMessage = 8 -) - -// GroupMessage represents a message in the group message format. -type GroupMessage struct { - Version byte `json:"version"` - MessageIndex uint32 `json:"index"` - Ciphertext []byte `json:"ciphertext"` - HasMessageIndex bool `json:"has_index"` -} - -// Decodes decodes the input and populates the corresponding fileds. MAC and signature are ignored but have to be present. -func (r *GroupMessage) Decode(input []byte) (err error) { - r.Version = 0 - r.MessageIndex = 0 - r.Ciphertext = nil - if len(input) < countMACBytesGroupMessage+crypto.Ed25519SignatureSize { - return fmt.Errorf("%w (%d bytes)", olm.ErrInputToSmall, len(input)) - } - - decoder := NewDecoder(input[:len(input)-countMACBytesGroupMessage-crypto.Ed25519SignatureSize]) - r.Version, err = decoder.ReadByte() // First byte is the version - if err != nil { - return - } - if r.Version != protocolVersion { - return fmt.Errorf("GroupMessage.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) - } - - for { - // Read Key - if curKey, err := decoder.ReadVarInt(); err != nil { - if err == io.EOF { - // No more keys to read - return nil - } - return err - } else if (curKey & 0b111) == 0 { - // The value is of type varint - if value, err := decoder.ReadVarInt(); err != nil { - return err - } else if curKey == messageIndexTag { - if value > math.MaxUint32 { - return fmt.Errorf("GroupMessage.Decode: message index %d exceeds uint32 limit", value) - } - r.MessageIndex = uint32(value) - r.HasMessageIndex = true - } - } else if (curKey & 0b111) == 2 { - // The value is of type string - if value, err := decoder.ReadVarBytes(); err != nil { - return err - } else if curKey == cipherTextTag { - r.Ciphertext = value - } - } else { - return fmt.Errorf("GroupMessage.Decode: unexpected proto key %d", curKey) - } - } -} - -// EncodeAndMACAndSign encodes the message, creates the mac with the key and the cipher and signs the message. -// If macKey or cipher is nil, no mac is appended. If signKey is nil, no signature is appended. -func (r *GroupMessage) EncodeAndMACAndSign(cipher aessha2.AESSHA2, signKey crypto.Ed25519KeyPair) ([]byte, error) { - var encoder Encoder - encoder.PutByte(r.Version) - encoder.PutVarInt(messageIndexTag) - encoder.PutVarInt(uint64(r.MessageIndex)) - encoder.PutVarInt(cipherTextTag) - encoder.PutVarBytes(r.Ciphertext) - mac, err := cipher.MAC(encoder.Bytes()) - if err != nil { - return nil, err - } - ciphertextWithMAC := append(encoder.Bytes(), mac[:countMACBytesGroupMessage]...) - signature, err := signKey.Sign(ciphertextWithMAC) - return append(ciphertextWithMAC, signature...), err -} - -// VerifySignature verifies the signature taken from the message to the calculated signature of the message. -func (r *GroupMessage) VerifySignatureInline(key crypto.Ed25519PublicKey, message []byte) bool { - signature := message[len(message)-crypto.Ed25519SignatureSize:] - message = message[:len(message)-crypto.Ed25519SignatureSize] - return key.Verify(message, signature) -} - -// VerifyMACInline verifies the MAC taken from the message to the calculated MAC of the message. -func (r *GroupMessage) VerifyMACInline(cipher aessha2.AESSHA2, message []byte) (bool, error) { - startMAC := len(message) - countMACBytesGroupMessage - crypto.Ed25519SignatureSize - endMAC := startMAC + countMACBytesGroupMessage - suplMac := message[startMAC:endMAC] - message = message[:startMAC] - return cipher.VerifyMAC(message, suplMac, countMACBytesMessage) -} diff --git a/mautrix-patched/crypto/goolm/message/group_message_test.go b/mautrix-patched/crypto/goolm/message/group_message_test.go deleted file mode 100644 index 272138c4..00000000 --- a/mautrix-patched/crypto/goolm/message/group_message_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package message_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/message" -) - -func TestGroupMessageDecode(t *testing.T) { - messageRaw := []byte("\x03\x08\xC8\x01\x12\x0aciphertexthmacsha2") - signature := []byte("signature1234567891234567890123412345678912345678912345678901234") - messageRaw = append(messageRaw, signature...) - expectedMessageIndex := uint32(200) - expectedCipherText := []byte("ciphertext") - - msg := message.GroupMessage{} - err := msg.Decode(messageRaw) - assert.NoError(t, err) - assert.EqualValues(t, 3, msg.Version) - assert.Equal(t, expectedMessageIndex, msg.MessageIndex) - assert.Equal(t, expectedCipherText, msg.Ciphertext) -} - -func TestGroupMessageEncode(t *testing.T) { - hmacsha256 := []byte("hmacsha2") - sign := []byte("signature") - msg := message.GroupMessage{ - Version: 3, - MessageIndex: 200, - Ciphertext: []byte("ciphertext"), - } - - cipher, err := aessha2.NewAESSHA2(nil, nil) - require.NoError(t, err) - encoded, err := msg.EncodeAndMACAndSign(cipher, crypto.Ed25519GenerateFromSeed(make([]byte, 32))) - assert.NoError(t, err) - encoded = append(encoded, hmacsha256...) - encoded = append(encoded, sign...) - expected := []byte{ - 0x03, // Version - 0x08, - 0xC8, // 200 - 0x01, - 0x12, - 0x0a, - } - expected = append(expected, []byte("ciphertext")...) - expected = append(expected, []byte{ - 0x6f, 0x95, 0x35, 0x51, 0xdc, 0xdb, 0xcb, 0x03, 0x0b, 0x22, 0xa2, 0xa7, 0xa1, 0xb7, 0x4f, 0x1a, - 0xa3, 0xe9, 0x5c, 0x05, 0x5d, 0x56, 0xdc, 0x5b, 0x87, 0x73, 0x05, 0x42, 0x2a, 0x59, 0x9a, 0x9a, - 0x26, 0x7a, 0x8d, 0xba, 0x65, 0xb2, 0x17, 0x65, 0x51, 0x6f, 0x37, 0xf3, 0x8f, 0xa1, 0x70, 0xd0, - 0xc4, 0x06, 0x05, 0xdc, 0x17, 0x71, 0x5e, 0x63, 0x84, 0xbe, 0xec, 0x7b, 0xa0, 0xc4, 0x08, 0xb8, - 0x9b, 0xc5, 0x08, 0x16, 0xad, 0xe5, 0x43, 0x0c, - }...) - expected = append(expected, []byte("hmacsha2signature")...) - assert.Equal(t, expected, encoded) -} diff --git a/mautrix-patched/crypto/goolm/message/message.go b/mautrix-patched/crypto/goolm/message/message.go deleted file mode 100644 index de2a93a4..00000000 --- a/mautrix-patched/crypto/goolm/message/message.go +++ /dev/null @@ -1,103 +0,0 @@ -package message - -import ( - "fmt" - "io" - "math" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - ratchetKeyTag = 0x0A - counterTag = 0x10 - cipherTextKeyTag = 0x22 - countMACBytesMessage = 8 -) - -// GroupMessage represents a message in the message format. -type Message struct { - Version byte `json:"version"` - HasCounter bool `json:"has_counter"` - Counter uint32 `json:"counter"` - RatchetKey crypto.Curve25519PublicKey `json:"ratchet_key"` - Ciphertext []byte `json:"ciphertext"` -} - -// Decodes decodes the input and populates the corresponding fileds. MAC is ignored but has to be present. -func (r *Message) Decode(input []byte) (err error) { - r.Version = 0 - r.HasCounter = false - r.Counter = 0 - r.RatchetKey = nil - r.Ciphertext = nil - if len(input) < countMACBytesMessage { - return fmt.Errorf("%w (%d bytes)", olm.ErrInputToSmall, len(input)) - } - - decoder := NewDecoder(input[:len(input)-countMACBytesMessage]) - r.Version, err = decoder.ReadByte() // first byte is always version - if err != nil { - return - } - if r.Version != protocolVersion { - return fmt.Errorf("Message.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) - } - - for { - // Read Key - if curKey, err := decoder.ReadVarInt(); err != nil { - if err == io.EOF { - // No more keys to read - return nil - } - return err - } else if (curKey & 0b111) == 0 { - // The value is of type varint - if value, err := decoder.ReadVarInt(); err != nil { - return err - } else if curKey == counterTag { - // TODO add support for 64-bit counters like vodozemac - if value > math.MaxUint32 { - return fmt.Errorf("Message.Decode: counter value %d exceeds uint32 limit", value) - } - r.Counter = uint32(value) - r.HasCounter = true - } - } else if (curKey & 0b111) == 2 { - // The value is of type string - if value, err := decoder.ReadVarBytes(); err != nil { - return err - } else if curKey == ratchetKeyTag { - r.RatchetKey = value - } else if curKey == cipherTextKeyTag { - r.Ciphertext = value - } - } else { - return fmt.Errorf("Message.Decode: unexpected proto key %d", curKey) - } - } -} - -// EncodeAndMAC encodes the message and creates the MAC with the key and the cipher. -// If key or cipher is nil, no MAC is appended. -func (r *Message) EncodeAndMAC(cipher aessha2.AESSHA2) ([]byte, error) { - var encoder Encoder - encoder.PutByte(r.Version) - encoder.PutVarInt(ratchetKeyTag) - encoder.PutVarBytes(r.RatchetKey) - encoder.PutVarInt(counterTag) - encoder.PutVarInt(uint64(r.Counter)) - encoder.PutVarInt(cipherTextKeyTag) - encoder.PutVarBytes(r.Ciphertext) - mac, err := cipher.MAC(encoder.Bytes()) - return append(encoder.Bytes(), mac[:countMACBytesMessage]...), err -} - -// VerifyMACInline verifies the MAC taken from the message to the calculated MAC of the message. -func (r *Message) VerifyMACInline(cipher aessha2.AESSHA2, message []byte) (bool, error) { - givenMAC := message[len(message)-countMACBytesMessage:] - return cipher.VerifyMAC(message[:len(message)-countMACBytesMessage], givenMAC, countMACBytesMessage) -} diff --git a/mautrix-patched/crypto/goolm/message/message_test.go b/mautrix-patched/crypto/goolm/message/message_test.go deleted file mode 100644 index f3aa7108..00000000 --- a/mautrix-patched/crypto/goolm/message/message_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package message_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/message" -) - -func TestMessageDecode(t *testing.T) { - messageRaw := []byte("\x03\x10\x01\n\nratchetkey\"\nciphertexthmacsha2") - expectedRatchetKey := []byte("ratchetkey") - expectedCipherText := []byte("ciphertext") - - msg := message.Message{} - err := msg.Decode(messageRaw) - assert.NoError(t, err) - assert.EqualValues(t, 3, msg.Version) - assert.True(t, msg.HasCounter) - assert.EqualValues(t, 1, msg.Counter) - assert.Equal(t, expectedCipherText, msg.Ciphertext) - assert.EqualValues(t, expectedRatchetKey, msg.RatchetKey) -} - -func TestMessageEncode(t *testing.T) { - expectedRaw := []byte("\x03\n\nratchetkey\x10\x01\"\nciphertext\x95\x95\x92\x72\x04\x70\x56\xcdhmacsha2") - hmacsha256 := []byte("hmacsha2") - msg := message.Message{ - Version: 3, - Counter: 1, - RatchetKey: []byte("ratchetkey"), - Ciphertext: []byte("ciphertext"), - } - cipher, err := aessha2.NewAESSHA2(nil, nil) - assert.NoError(t, err) - encoded, err := msg.EncodeAndMAC(cipher) - assert.NoError(t, err) - encoded = append(encoded, hmacsha256...) - assert.Equal(t, expectedRaw, encoded) -} diff --git a/mautrix-patched/crypto/goolm/message/prekey_message.go b/mautrix-patched/crypto/goolm/message/prekey_message.go deleted file mode 100644 index 97d9d43d..00000000 --- a/mautrix-patched/crypto/goolm/message/prekey_message.go +++ /dev/null @@ -1,114 +0,0 @@ -package message - -import ( - "fmt" - "io" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - oneTimeKeyIDTag = 0x0A - baseKeyTag = 0x12 - identityKeyTag = 0x1A - messageTag = 0x22 -) - -type PreKeyMessage struct { - Version byte `json:"version"` - IdentityKey crypto.Curve25519PublicKey `json:"id_key"` - BaseKey crypto.Curve25519PublicKey `json:"base_key"` - OneTimeKey crypto.Curve25519PublicKey `json:"one_time_key"` - Message []byte `json:"message"` -} - -// TODO deduplicate constant with one in session/olm_session.go -const ( - protocolVersion = 0x3 -) - -// Decodes decodes the input and populates the corresponding fileds. -func (r *PreKeyMessage) Decode(input []byte) (err error) { - r.Version = 0 - r.IdentityKey = nil - r.BaseKey = nil - r.OneTimeKey = nil - r.Message = nil - if len(input) == 0 { - return olm.ErrInputToSmall - } - - decoder := NewDecoder(input) - r.Version, err = decoder.ReadByte() // first byte is always version - if err != nil { - if err == io.EOF { - return olm.ErrInputToSmall - } - return - } - if r.Version != protocolVersion { - return fmt.Errorf("PreKeyMessage.Decode: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, r.Version, protocolVersion) - } - - for { - // Read Key - if curKey, err := decoder.ReadVarInt(); err != nil { - if err == io.EOF { - return nil - } - return err - } else if (curKey & 0b111) == 0 { - // The value is of type varint - if _, err = decoder.ReadVarInt(); err != nil { - if err == io.EOF { - return olm.ErrInputToSmall - } - return err - } - } else if (curKey & 0b111) == 2 { - // The value is of type string - if value, err := decoder.ReadVarBytes(); err != nil { - if err == io.EOF { - return olm.ErrInputToSmall - } - return err - } else { - switch curKey { - case oneTimeKeyIDTag: - r.OneTimeKey = value - case baseKeyTag: - r.BaseKey = value - case identityKeyTag: - r.IdentityKey = value - case messageTag: - r.Message = value - } - } - } else { - return fmt.Errorf("PreKeyMessage.Decode: unexpected proto key %d", curKey) - } - } -} - -func (r *PreKeyMessage) CheckFields() bool { - return len(r.IdentityKey) == crypto.Curve25519PrivateKeyLength && - len(r.Message) != 0 && - len(r.BaseKey) == crypto.Curve25519PrivateKeyLength && - len(r.OneTimeKey) == crypto.Curve25519PrivateKeyLength -} - -// Encode encodes the message. -func (r *PreKeyMessage) Encode() ([]byte, error) { - var encoder Encoder - encoder.PutByte(r.Version) - encoder.PutVarInt(oneTimeKeyIDTag) - encoder.PutVarBytes(r.OneTimeKey) - encoder.PutVarInt(identityKeyTag) - encoder.PutVarBytes(r.IdentityKey) - encoder.PutVarInt(baseKeyTag) - encoder.PutVarBytes(r.BaseKey) - encoder.PutVarInt(messageTag) - encoder.PutVarBytes(r.Message) - return encoder.Bytes(), nil -} diff --git a/mautrix-patched/crypto/goolm/message/prekey_message_test.go b/mautrix-patched/crypto/goolm/message/prekey_message_test.go deleted file mode 100644 index fb620346..00000000 --- a/mautrix-patched/crypto/goolm/message/prekey_message_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package message_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/message" -) - -func TestPreKeyMessageDecode(t *testing.T) { - //Keys are 32 bytes to pass field check - //Added a tag for an integer of 0 just for checkes - messageRaw := []byte("\x03\x0a\x20onetimeKey.-.-.-.-.-.-.-.-.-.-.-\x1a\x20idKeywithlendth32bytes-.-.-.-.-.\x12\x20baseKey-.-.-.-.-.-.-.-.-.-.-.-.-\x22\x07message\x00\x00") - expectedOneTimeKey := []byte("onetimeKey.-.-.-.-.-.-.-.-.-.-.-") - expectedIdKey := []byte("idKeywithlendth32bytes-.-.-.-.-.") - expectedbaseKey := []byte("baseKey-.-.-.-.-.-.-.-.-.-.-.-.-") - expectedmessage := []byte("message") - - msg := message.PreKeyMessage{} - err := msg.Decode(messageRaw) - assert.NoError(t, err) - assert.EqualValues(t, 3, msg.Version) - assert.EqualValues(t, expectedOneTimeKey, msg.OneTimeKey) - assert.EqualValues(t, expectedIdKey, msg.IdentityKey) - assert.EqualValues(t, expectedbaseKey, msg.BaseKey) - assert.Equal(t, expectedmessage, msg.Message) - assert.True(t, msg.CheckFields(), "field check failed") -} - -func TestPreKeyMessageEncode(t *testing.T) { - expectedRaw := []byte("\x03\x0a\x0aonetimeKey\x1a\x05idKey\x12\x07baseKey\x22\x07message") - msg := message.PreKeyMessage{ - Version: 3, - IdentityKey: []byte("idKey"), - BaseKey: []byte("baseKey"), - OneTimeKey: []byte("onetimeKey"), - Message: []byte("message"), - } - encoded, err := msg.Encode() - assert.NoError(t, err) - assert.Equal(t, expectedRaw, encoded) -} diff --git a/mautrix-patched/crypto/goolm/message/session_export.go b/mautrix-patched/crypto/goolm/message/session_export.go deleted file mode 100644 index 188974e8..00000000 --- a/mautrix-patched/crypto/goolm/message/session_export.go +++ /dev/null @@ -1,47 +0,0 @@ -package message - -import ( - "encoding/binary" - "fmt" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - sessionExportVersion = 0x01 -) - -// MegolmSessionExport represents a message in the session export format. -type MegolmSessionExport struct { - Counter uint32 `json:"counter"` - RatchetData [128]byte `json:"data"` - PublicKey crypto.Ed25519PublicKey `json:"public_key"` -} - -// Encode returns the encoded message in the correct format. -func (s MegolmSessionExport) Encode() []byte { - output := make([]byte, 165) - output[0] = sessionExportVersion - binary.BigEndian.PutUint32(output[1:], s.Counter) - copy(output[5:], s.RatchetData[:]) - copy(output[133:], s.PublicKey) - return output -} - -// Decode populates the struct with the data encoded in input. -func (s *MegolmSessionExport) Decode(input []byte) error { - if len(input) != 165 { - return fmt.Errorf("decrypt: %w", olm.ErrBadInput) - } - if input[0] != sessionExportVersion { - return fmt.Errorf("decrypt: %w", olm.ErrUnknownOlmPickleVersion) - } - s.Counter = binary.BigEndian.Uint32(input[1:5]) - copy(s.RatchetData[:], input[5:133]) - s.PublicKey = input[133:] - if !s.PublicKey.IsValidKey() { - return fmt.Errorf("MegolmSessionExport.Decode: invalid Ed25519 public key") - } - return nil -} diff --git a/mautrix-patched/crypto/goolm/message/session_sharing.go b/mautrix-patched/crypto/goolm/message/session_sharing.go deleted file mode 100644 index d04ef15a..00000000 --- a/mautrix-patched/crypto/goolm/message/session_sharing.go +++ /dev/null @@ -1,50 +0,0 @@ -package message - -import ( - "encoding/binary" - "fmt" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - sessionSharingVersion = 0x02 -) - -// MegolmSessionSharing represents a message in the session sharing format. -type MegolmSessionSharing struct { - Counter uint32 `json:"counter"` - RatchetData [128]byte `json:"data"` - PublicKey crypto.Ed25519PublicKey `json:"-"` //only used when decrypting messages -} - -// Encode returns the encoded message in the correct format with the signature by key appended. -func (s MegolmSessionSharing) EncodeAndSign(key crypto.Ed25519KeyPair) ([]byte, error) { - output := make([]byte, 229) - output[0] = sessionSharingVersion - binary.BigEndian.PutUint32(output[1:], s.Counter) - copy(output[5:], s.RatchetData[:]) - copy(output[133:], key.PublicKey) - signature, err := key.Sign(output[:165]) - copy(output[165:], signature) - return output, err -} - -// VerifyAndDecode verifies the input and populates the struct with the data encoded in input. -func (s *MegolmSessionSharing) VerifyAndDecode(input []byte) error { - if len(input) != 229 { - return fmt.Errorf("verify: %w", olm.ErrBadInput) - } - publicKey := crypto.Ed25519PublicKey(input[133:165]) - if !publicKey.Verify(input[:165], input[165:]) { - return fmt.Errorf("verify: %w", olm.ErrBadVerification) - } - s.PublicKey = publicKey - if input[0] != sessionSharingVersion { - return fmt.Errorf("verify: %w", olm.ErrUnknownOlmPickleVersion) - } - s.Counter = binary.BigEndian.Uint32(input[1:5]) - copy(s.RatchetData[:], input[5:133]) - return nil -} diff --git a/mautrix-patched/crypto/goolm/pk/pk_test.go b/mautrix-patched/crypto/goolm/pk/pk_test.go deleted file mode 100644 index d4d81e69..00000000 --- a/mautrix-patched/crypto/goolm/pk/pk_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package pk_test - -import ( - "crypto/ed25519" - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/pk" -) - -func TestSigning(t *testing.T) { - seed := []byte{ - 0x77, 0x07, 0x6D, 0x0A, 0x73, 0x18, 0xA5, 0x7D, - 0x3C, 0x16, 0xC1, 0x72, 0x51, 0xB2, 0x66, 0x45, - 0xDF, 0x4C, 0x2F, 0x87, 0xEB, 0xC0, 0x99, 0x2A, - 0xB1, 0x77, 0xFB, 0xA5, 0x1D, 0xB9, 0x2C, 0x2A, - } - message := []byte("We hold these truths to be self-evident, that all men are created equal, that they are endowed by their Creator with certain unalienable Rights, that among these are Life, Liberty and the pursuit of Happiness.") - signing, _ := pk.NewSigningFromSeed(seed) - signature, err := signing.Sign(message) - assert.NoError(t, err) - signatureDecoded, err := base64.RawStdEncoding.DecodeString(string(signature)) - assert.NoError(t, err) - pubKeyEncoded := signing.PublicKey() - pubKeyDecoded, err := base64.RawStdEncoding.DecodeString(string(pubKeyEncoded)) - assert.NoError(t, err) - pubKey := crypto.Ed25519PublicKey(pubKeyDecoded) - - verified := pubKey.Verify(message, signatureDecoded) - assert.True(t, verified, "signature did not verify") - - copy(signatureDecoded[0:], []byte("m")) - verified = pubKey.Verify(message, signatureDecoded) - assert.False(t, verified, "signature verified with wrong message") -} - -func TestStrictVerifyRejectsSmallOrderKey(t *testing.T) { - message := []byte("test message for small-order key check") - - pubKey := make(crypto.Ed25519PublicKey, 32) - pubKey[0] = 0x01 - - signature := make([]byte, 64) - signature[0] = 0x01 - - assert.True(t, ed25519.Verify(ed25519.PublicKey(pubKey), message, signature), - "standard library accepts small-order keys") - - assert.False(t, pubKey.Verify(message, signature), - "strict verify rejects small-order keys") -} diff --git a/mautrix-patched/crypto/goolm/pk/register.go b/mautrix-patched/crypto/goolm/pk/register.go deleted file mode 100644 index cd4ce9e8..00000000 --- a/mautrix-patched/crypto/goolm/pk/register.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pk - -import "maunium.net/go/mautrix/crypto/olm" - -func Register() { - olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { - return NewSigningFromSeed(seed) - } - olm.InitNewPKSigning = func() (olm.PKSigning, error) { - return NewSigning() - } -} diff --git a/mautrix-patched/crypto/goolm/pk/signing.go b/mautrix-patched/crypto/goolm/pk/signing.go deleted file mode 100644 index 8841548e..00000000 --- a/mautrix-patched/crypto/goolm/pk/signing.go +++ /dev/null @@ -1,71 +0,0 @@ -package pk - -import ( - "crypto/rand" - "fmt" - - "github.com/tidwall/sjson" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/goolmbase64" - "maunium.net/go/mautrix/id" -) - -// Signing is used for signing a pk -type Signing struct { - keyPair crypto.Ed25519KeyPair - seed []byte -} - -// NewSigningFromSeed constructs a new Signing based on a seed. -func NewSigningFromSeed(seed []byte) (*Signing, error) { - s := &Signing{} - s.seed = seed - s.keyPair = crypto.Ed25519GenerateFromSeed(seed) - return s, nil -} - -// NewSigning returns a Signing based on a random seed -func NewSigning() (*Signing, error) { - seed := make([]byte, 32) - _, err := rand.Read(seed) - if err != nil { - return nil, err - } - return NewSigningFromSeed(seed) -} - -// Seed returns the seed of the key pair. -func (s Signing) Seed() []byte { - return s.seed -} - -// PublicKey returns the public key of the key pair base 64 encoded. -func (s Signing) PublicKey() id.Ed25519 { - return s.keyPair.B64Encoded() -} - -// Sign returns the signature of the message base64 encoded. -func (s Signing) Sign(message []byte) ([]byte, error) { - signature, err := s.keyPair.Sign(message) - return goolmbase64.Encode(signature), err -} - -// SignJSON creates a signature for the given object after encoding it to -// canonical JSON. -func (s Signing) SignJSON(obj any) (string, error) { - objJSON, err := canonicaljson.Marshal(obj) - if err != nil { - return "", err - } - objJSON, _ = sjson.DeleteBytes(objJSON, "unsigned") - objJSON, _ = sjson.DeleteBytes(objJSON, "signatures") - // This is probably not necessary - err = canonicaljson.Canonicalize(&objJSON) - if err != nil { - return "", fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) - } - signature, err := s.Sign(objJSON) - return string(signature), err -} diff --git a/mautrix-patched/crypto/goolm/ratchet/chain.go b/mautrix-patched/crypto/goolm/ratchet/chain.go deleted file mode 100644 index 3089e19e..00000000 --- a/mautrix-patched/crypto/goolm/ratchet/chain.go +++ /dev/null @@ -1,178 +0,0 @@ -package ratchet - -import ( - "crypto/hmac" - "crypto/sha256" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -const ( - chainKeySeed = 0x02 - messageKeyLength = 32 -) - -// chainKey wraps the index and the public key -type chainKey struct { - Index uint32 `json:"index"` - Key crypto.Curve25519PublicKey `json:"key"` -} - -// advance advances the chain -func (c *chainKey) advance() { - hash := hmac.New(sha256.New, c.Key) - hash.Write([]byte{chainKeySeed}) - c.Key = hash.Sum(nil) - c.Index++ -} - -// UnpickleLibOlm unpickles the unencryted value and populates the chain key accordingly. -func (r *chainKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - err := r.Key.UnpickleLibOlm(decoder) - if err != nil { - return err - } - r.Index, err = decoder.ReadUInt32() - return err -} - -// PickleLibOlm pickles the chain key into the encoder. -func (r chainKey) PickleLibOlm(encoder *libolmpickle.Encoder) { - r.Key.PickleLibOlm(encoder) - encoder.WriteUInt32(r.Index) -} - -func (r chainKey) createMessageKeys() messageKey { - hash := hmac.New(sha256.New, r.Key) - hash.Write([]byte{messageKeySeed}) - return messageKey{ - Key: hash.Sum(nil), - Index: r.Index, - } -} - -// senderChain is a chain for sending messages -type senderChain struct { - RKey crypto.Curve25519KeyPair `json:"ratchet_key"` - CKey chainKey `json:"chain_key"` - IsSet bool `json:"set"` -} - -// newSenderChain returns a sender chain initialized with chainKey and ratchet key pair. -func newSenderChain(key crypto.Curve25519PublicKey, ratchet crypto.Curve25519KeyPair) *senderChain { - return &senderChain{ - RKey: ratchet, - CKey: chainKey{ - Index: 0, - Key: key, - }, - IsSet: true, - } -} - -// advance advances the chain -func (s *senderChain) advance() { - s.CKey.advance() -} - -// ratchetKey returns the ratchet key pair. -func (s senderChain) ratchetKey() crypto.Curve25519KeyPair { - return s.RKey -} - -// chainKey returns the current chainKey. -func (s senderChain) chainKey() chainKey { - return s.CKey -} - -// UnpickleLibOlm unpickles the unencryted value and populates the sender chain -// accordingly. -func (r *senderChain) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - if err := r.RKey.UnpickleLibOlm(decoder); err != nil { - return err - } - return r.CKey.UnpickleLibOlm(decoder) -} - -// PickleLibOlm pickles the sender chain into the encoder. -func (r senderChain) PickleLibOlm(encoder *libolmpickle.Encoder) { - if r.IsSet { - encoder.WriteUInt32(1) // Length of the sender chain (1 if set) - r.RKey.PickleLibOlm(encoder) - r.CKey.PickleLibOlm(encoder) - } else { - encoder.WriteUInt32(0) - } -} - -// senderChain is a chain for receiving messages -type receiverChain struct { - RKey crypto.Curve25519PublicKey `json:"ratchet_key"` - CKey chainKey `json:"chain_key"` -} - -// newReceiverChain returns a receiver chain initialized with chainKey and ratchet public key. -func newReceiverChain(chain crypto.Curve25519PublicKey, ratchet crypto.Curve25519PublicKey) *receiverChain { - return &receiverChain{ - RKey: ratchet, - CKey: chainKey{ - Index: 0, - Key: chain, - }, - } -} - -// advance advances the chain -func (s *receiverChain) advance() { - s.CKey.advance() -} - -// ratchetKey returns the ratchet public key. -func (s receiverChain) ratchetKey() crypto.Curve25519PublicKey { - return s.RKey -} - -// chainKey returns the current chainKey. -func (s receiverChain) chainKey() chainKey { - return s.CKey -} - -// UnpickleLibOlm unpickles the unencryted value and populates the chain accordingly. -func (r *receiverChain) UnpickleLibOlm(decoder *libolmpickle.Decoder) error { - if err := r.RKey.UnpickleLibOlm(decoder); err != nil { - return err - } - return r.CKey.UnpickleLibOlm(decoder) -} - -// PickleLibOlm pickles the receiver chain into the encoder. -func (r receiverChain) PickleLibOlm(encoder *libolmpickle.Encoder) { - r.RKey.PickleLibOlm(encoder) - r.CKey.PickleLibOlm(encoder) -} - -// messageKey wraps the index and the key of a message -type messageKey struct { - Index uint32 `json:"index"` - Key []byte `json:"key"` -} - -// UnpickleLibOlm unpickles the unencryted value and populates the message key -// accordingly. -func (m *messageKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) (err error) { - if m.Key, err = decoder.ReadBytes(messageKeyLength); err != nil { - return - } - m.Index, err = decoder.ReadUInt32() - return -} - -// PickleLibOlm pickles the message key into the encoder. -func (m messageKey) PickleLibOlm(encoder *libolmpickle.Encoder) { - if len(m.Key) != messageKeyLength { - panic("messageKey.PickleLibOlm: key length is not 32 bytes") - } - encoder.Write(m.Key) - encoder.WriteUInt32(m.Index) -} diff --git a/mautrix-patched/crypto/goolm/ratchet/olm.go b/mautrix-patched/crypto/goolm/ratchet/olm.go deleted file mode 100644 index 72b914c9..00000000 --- a/mautrix-patched/crypto/goolm/ratchet/olm.go +++ /dev/null @@ -1,393 +0,0 @@ -// Package ratchet provides the ratchet used by the olm protocol -package ratchet - -import ( - "crypto/sha256" - "fmt" - "io" - "slices" - - "golang.org/x/crypto/hkdf" - - "maunium.net/go/mautrix/crypto/goolm/aessha2" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/goolm/message" - "maunium.net/go/mautrix/crypto/olm" -) - -const ( - maxReceiverChains = 5 - maxSkippedMessageKeys = 40 - protocolVersion = 3 - messageKeySeed = 0x01 - - maxMessageGap = 2000 - sharedKeyLength = 32 -) - -var olmKeysKDFInfo = []byte("OLM_KEYS") - -// KdfInfo has the infos used for the kdf -var KdfInfo = struct { - Root []byte - Ratchet []byte -}{ - Root: []byte("OLM_ROOT"), - Ratchet: []byte("OLM_RATCHET"), -} - -// Ratchet represents the olm ratchet as described in -// -// https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/olm.md -type Ratchet struct { - // The root key is used to generate chain keys from the ephemeral keys. - // A new root_key is derived each time a new chain is started. - RootKey crypto.Curve25519PublicKey `json:"root_key"` - - // The sender chain is used to send messages. Each time a new ephemeral - // key is received from the remote server we generate a new sender chain - // with a new ephemeral key when we next send a message. - SenderChains senderChain `json:"sender_chain"` - - // The receiver chain is used to decrypt received messages. We store the - // last few chains so we can decrypt any out of order messages we haven't - // received yet. - // New chains are prepended for easier access. - ReceiverChains []receiverChain `json:"receiver_chains"` - - // Storing the keys of missed messages for future use. - // The order of the elements is not important. - // TODO move this inside receiver chains to match vodozemac - // Due to being global, this is currently capped at a total of 40, instead of 5x40 like vodozemac - SkippedMessageKeys []skippedMessageKey `json:"skipped_message_keys"` -} - -// New creates a new ratchet, setting the kdfInfos and cipher. -func New() *Ratchet { - return &Ratchet{} -} - -// InitializeAsBob initializes this ratchet from a receiving point of view (only first message). -func (r *Ratchet) InitializeAsBob(sharedSecret []byte, theirRatchetKey crypto.Curve25519PublicKey) error { - derivedSecretsReader := hkdf.New(sha256.New, sharedSecret, nil, KdfInfo.Root) - derivedSecrets := make([]byte, 2*sharedKeyLength) - if _, err := io.ReadFull(derivedSecretsReader, derivedSecrets); err != nil { - return err - } - r.RootKey = derivedSecrets[0:sharedKeyLength] - newReceiverChain := newReceiverChain(derivedSecrets[sharedKeyLength:], theirRatchetKey) - r.ReceiverChains = append([]receiverChain{*newReceiverChain}, r.ReceiverChains...) - if len(r.ReceiverChains) > maxReceiverChains { - r.ReceiverChains = r.ReceiverChains[:maxReceiverChains] - } - return nil -} - -// InitializeAsAlice initializes this ratchet from a sending point of view (only first message). -func (r *Ratchet) InitializeAsAlice(sharedSecret []byte, ourRatchetKey crypto.Curve25519KeyPair) error { - derivedSecretsReader := hkdf.New(sha256.New, sharedSecret, nil, KdfInfo.Root) - derivedSecrets := make([]byte, 2*sharedKeyLength) - if _, err := io.ReadFull(derivedSecretsReader, derivedSecrets); err != nil { - return err - } - r.RootKey = derivedSecrets[0:sharedKeyLength] - newSenderChain := newSenderChain(derivedSecrets[sharedKeyLength:], ourRatchetKey) - r.SenderChains = *newSenderChain - return nil -} - -// Encrypt encrypts the message in a message.Message with MAC. -func (r *Ratchet) Encrypt(plaintext []byte) ([]byte, error) { - var err error - if !r.SenderChains.IsSet { - newRatchetKey, err := crypto.Curve25519GenerateKey() - if err != nil { - return nil, err - } - newChainKey, err := r.advanceRootKey(newRatchetKey, r.ReceiverChains[0].ratchetKey()) - if err != nil { - return nil, err - } - newSenderChain := newSenderChain(newChainKey, newRatchetKey) - r.SenderChains = *newSenderChain - } - - messageKey := r.SenderChains.chainKey().createMessageKeys() - r.SenderChains.advance() - - cipher, err := aessha2.NewAESSHA2(messageKey.Key, olmKeysKDFInfo) - if err != nil { - return nil, err - } - encryptedText, err := cipher.Encrypt(plaintext) - if err != nil { - return nil, fmt.Errorf("cipher encrypt: %w", err) - } - - message := &message.Message{} - message.Version = protocolVersion - message.Counter = messageKey.Index - message.RatchetKey = r.SenderChains.ratchetKey().PublicKey - message.Ciphertext = encryptedText - //creating the mac is done in encode - return message.EncodeAndMAC(cipher) -} - -// Decrypt decrypts the ciphertext and verifies the MAC. -func (r *Ratchet) Decrypt(input []byte) ([]byte, error) { - message := &message.Message{} - //The mac is not verified here, as we do not know the key yet - err := message.Decode(input) - if err != nil { - return nil, err - } - if message.Version != protocolVersion { - return nil, fmt.Errorf("decrypt: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, message.Version, protocolVersion) - } - if !message.HasCounter || len(message.RatchetKey) != crypto.Curve25519PublicKeyLength || len(message.Ciphertext) == 0 { - return nil, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) - } - var receiverChainFromMessage *receiverChain - for curChainIndex := range r.ReceiverChains { - if r.ReceiverChains[curChainIndex].ratchetKey().Equal(message.RatchetKey) { - receiverChainFromMessage = &r.ReceiverChains[curChainIndex] - break - } - } - if receiverChainFromMessage == nil { - //Advancing the chain is done in this method - return r.decryptForNewChain(message, input) - } else if receiverChainFromMessage.chainKey().Index > message.Counter { - // No need to advance the chain - // Chain already advanced beyond the key for this message - // Check if the message keys are in the skipped key list. - for idx, smk := range r.SkippedMessageKeys { - if message.Counter != smk.MKey.Index { - continue - } - // TODO this check will be unnecessary when skipped message keys are moved inside receiver chains - if !smk.RKey.Equal(message.RatchetKey) { - continue - } - - // Found the key for this message. Check the MAC. - if cipher, err := aessha2.NewAESSHA2(smk.MKey.Key, olmKeysKDFInfo); err != nil { - return nil, err - } else if verified, err := message.VerifyMACInline(cipher, input); err != nil { - return nil, err - } else if !verified { - return nil, fmt.Errorf("decrypt from skipped message keys: %w", olm.ErrBadMAC) - } else if result, err := cipher.Decrypt(message.Ciphertext); err != nil { - return nil, fmt.Errorf("cipher decrypt: %w", err) - } else { - // Remove the key from the skipped keys now that we've - // decoded the message it corresponds to. - r.SkippedMessageKeys = slices.Delete(r.SkippedMessageKeys, idx, idx+1) - return result, nil - } - } - return nil, fmt.Errorf("decrypt: %w", olm.ErrMessageKeyNotFound) - } else { - receiverChainCopy := *receiverChainFromMessage - decrypted, skippedKeys, err := decryptForExistingChain(&receiverChainCopy, message, input) - if err != nil { - return nil, err - } - receiverChainFromMessage.CKey = receiverChainCopy.CKey - r.SkippedMessageKeys = append(r.SkippedMessageKeys, skippedKeys...) - if len(r.SkippedMessageKeys) > maxSkippedMessageKeys { - r.SkippedMessageKeys = r.SkippedMessageKeys[len(r.SkippedMessageKeys)-maxSkippedMessageKeys:] - } - return decrypted, nil - } -} - -func (r *Ratchet) getNextRootKey(newRatchetKey crypto.Curve25519KeyPair, oldRatchetKey crypto.Curve25519PublicKey) (rootKey, newChainKey crypto.Curve25519PublicKey, err error) { - sharedSecret, err := newRatchetKey.SharedSecret(oldRatchetKey) - if err != nil { - return - } - derivedSecretsReader := hkdf.New(sha256.New, sharedSecret, r.RootKey, KdfInfo.Ratchet) - derivedSecrets := make([]byte, 2*sharedKeyLength) - if _, err = io.ReadFull(derivedSecretsReader, derivedSecrets); err != nil { - return - } - rootKey = derivedSecrets[:sharedKeyLength] - newChainKey = derivedSecrets[sharedKeyLength:] - return -} - -// advanceRootKey created the next root key and returns the next chainKey -func (r *Ratchet) advanceRootKey(newRatchetKey crypto.Curve25519KeyPair, oldRatchetKey crypto.Curve25519PublicKey) (crypto.Curve25519PublicKey, error) { - rootKey, chainKey, err := r.getNextRootKey(newRatchetKey, oldRatchetKey) - if err != nil { - return nil, err - } - r.RootKey = rootKey - return chainKey, nil -} - -// decryptForExistingChain returns the decrypted message by using the chain. The MAC of the rawMessage is verified. -func decryptForExistingChain(chain *receiverChain, message *message.Message, rawMessage []byte) ([]byte, []skippedMessageKey, error) { - if message.Counter < chain.CKey.Index { - return nil, nil, fmt.Errorf("decrypt: %w", olm.ErrChainTooHigh) - } - // Limit the number of hashes we're prepared to compute - if message.Counter-chain.CKey.Index > maxMessageGap { - return nil, nil, fmt.Errorf("decrypt from existing chain: %w", olm.ErrMsgIndexTooHigh) - } - skippedKeys := make([]skippedMessageKey, 0, min(maxSkippedMessageKeys, max(message.Counter-chain.CKey.Index, 0))) - for chain.CKey.Index < message.Counter { - if message.Counter-chain.CKey.Index <= maxSkippedMessageKeys { - messageKey := chain.chainKey().createMessageKeys() - skippedKey := skippedMessageKey{ - MKey: messageKey, - RKey: chain.ratchetKey(), - } - skippedKeys = append(skippedKeys, skippedKey) - } - chain.advance() - } - messageKey := chain.chainKey().createMessageKeys() - chain.advance() - cipher, err := aessha2.NewAESSHA2(messageKey.Key, olmKeysKDFInfo) - if err != nil { - return nil, nil, err - } - verified, err := message.VerifyMACInline(cipher, rawMessage) - if err != nil { - return nil, nil, err - } - if !verified { - return nil, nil, fmt.Errorf("decrypt from existing chain: %w", olm.ErrBadMAC) - } - res, err := cipher.Decrypt(message.Ciphertext) - return res, skippedKeys, err -} - -// decryptForNewChain returns the decrypted message by creating a new chain and advancing the root key. -func (r *Ratchet) decryptForNewChain(message *message.Message, rawMessage []byte) ([]byte, error) { - // They shouldn't move to a new chain until we've sent them a message - // acknowledging the last one - if !r.SenderChains.IsSet { - return nil, fmt.Errorf("decrypt for new chain: %w", olm.ErrProtocolViolation) - } - // Limit the number of hashes we're prepared to compute - if message.Counter > maxMessageGap { - return nil, fmt.Errorf("decrypt for new chain: %w", olm.ErrMsgIndexTooHigh) - } - - newRootKey, newChainKey, err := r.getNextRootKey(r.SenderChains.ratchetKey(), message.RatchetKey) - if err != nil { - return nil, err - } - newChain := newReceiverChain(newChainKey, message.RatchetKey) - /* - They have started using a new ephemeral ratchet key. - We needed to derive a new set of chain keys. - We can discard our previous ephemeral ratchet key. - We will generate a new key when we send the next message. - */ - - decrypted, skippedKeys, err := decryptForExistingChain(newChain, message, rawMessage) - if err != nil { - return nil, err - } - r.RootKey = newRootKey - r.ReceiverChains = append([]receiverChain{*newChain}, r.ReceiverChains...) - if len(r.ReceiverChains) > maxReceiverChains { - r.ReceiverChains = r.ReceiverChains[:maxReceiverChains] - } - r.SenderChains = senderChain{} - r.SkippedMessageKeys = append(r.SkippedMessageKeys, skippedKeys...) - if len(r.SkippedMessageKeys) > maxSkippedMessageKeys { - r.SkippedMessageKeys = r.SkippedMessageKeys[len(r.SkippedMessageKeys)-maxSkippedMessageKeys:] - } - return decrypted, nil -} - -// UnpickleLibOlm unpickles the unencryted value and populates the [Ratchet] -// accordingly. -func (r *Ratchet) UnpickleLibOlm(decoder *libolmpickle.Decoder, includesChainIndex bool) error { - if err := r.RootKey.UnpickleLibOlm(decoder); err != nil { - return err - } - senderChainsCount, err := decoder.ReadUInt32() - if err != nil { - return err - } else if senderChainsCount > 1000 { - return fmt.Errorf("Ratchet.UnpickleLibOlm: too many sender chains: %d", senderChainsCount) - } - - for i := uint32(0); i < senderChainsCount; i++ { - if i == 0 { - // only the first sender key is stored - err = r.SenderChains.UnpickleLibOlm(decoder) - r.SenderChains.IsSet = true - } else { - // just eat the values - err = (&senderChain{}).UnpickleLibOlm(decoder) - } - if err != nil { - return err - } - } - - receiverChainCount, err := decoder.ReadUInt32() - if err != nil { - return err - } else if receiverChainCount > 1000 { - return fmt.Errorf("Ratchet.UnpickleLibOlm: too many receiver chains: %d", receiverChainCount) - } - r.ReceiverChains = make([]receiverChain, receiverChainCount) - for i := uint32(0); i < receiverChainCount; i++ { - if err := r.ReceiverChains[i].UnpickleLibOlm(decoder); err != nil { - return err - } - } - if len(r.ReceiverChains) > maxReceiverChains { - r.ReceiverChains = r.ReceiverChains[:maxReceiverChains] - } - - skippedMessageKeysCount, err := decoder.ReadUInt32() - if err != nil { - return err - } else if skippedMessageKeysCount > 1000 { - return fmt.Errorf("Ratchet.UnpickleLibOlm: too many skipped message keys: %d", skippedMessageKeysCount) - } - r.SkippedMessageKeys = make([]skippedMessageKey, skippedMessageKeysCount) - for i := uint32(0); i < skippedMessageKeysCount; i++ { - if err := r.SkippedMessageKeys[i].UnpickleLibOlm(decoder); err != nil { - return err - } - } - if len(r.SkippedMessageKeys) > maxSkippedMessageKeys { - r.SkippedMessageKeys = r.SkippedMessageKeys[len(r.SkippedMessageKeys)-maxSkippedMessageKeys:] - } - - // pickle version 0x80000001 includes a chain index; pickle version 1 does not. - if includesChainIndex { - _, err = decoder.ReadUInt32() - return err - } - return nil -} - -// PickleLibOlm pickles the ratchet into the encoder. -func (r Ratchet) PickleLibOlm(encoder *libolmpickle.Encoder) { - r.RootKey.PickleLibOlm(encoder) - r.SenderChains.PickleLibOlm(encoder) - - // Receiver Chains - encoder.WriteUInt32(uint32(len(r.ReceiverChains))) - for _, curChain := range r.ReceiverChains { - curChain.PickleLibOlm(encoder) - } - - // Skipped Message Keys - encoder.WriteUInt32(uint32(len(r.SkippedMessageKeys))) - for _, curChain := range r.SkippedMessageKeys { - curChain.PickleLibOlm(encoder) - } -} diff --git a/mautrix-patched/crypto/goolm/ratchet/olm_test.go b/mautrix-patched/crypto/goolm/ratchet/olm_test.go deleted file mode 100644 index 2bf7ea0a..00000000 --- a/mautrix-patched/crypto/goolm/ratchet/olm_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package ratchet_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/ratchet" -) - -var ( - sharedSecret = []byte("A secret") -) - -func initializeRatchets() (*ratchet.Ratchet, *ratchet.Ratchet, error) { - ratchet.KdfInfo = struct { - Root []byte - Ratchet []byte - }{ - Root: []byte("Olm"), - Ratchet: []byte("OlmRatchet"), - } - aliceRatchet := ratchet.New() - bobRatchet := ratchet.New() - - aliceKey, err := crypto.Curve25519GenerateKey() - if err != nil { - return nil, nil, err - } - - aliceRatchet.InitializeAsAlice(sharedSecret, aliceKey) - bobRatchet.InitializeAsBob(sharedSecret, aliceKey.PublicKey) - return aliceRatchet, bobRatchet, nil -} - -func TestSendReceive(t *testing.T) { - aliceRatchet, bobRatchet, err := initializeRatchets() - assert.NoError(t, err) - - plainText := []byte("Hello Bob") - - //Alice sends Bob a message - encryptedMessage, err := aliceRatchet.Encrypt(plainText) - assert.NoError(t, err) - - decrypted, err := bobRatchet.Decrypt(encryptedMessage) - assert.NoError(t, err) - assert.Equal(t, plainText, decrypted) - - //Bob sends Alice a message - plainText = []byte("Hello Alice") - encryptedMessage, err = bobRatchet.Encrypt(plainText) - assert.NoError(t, err) - decrypted, err = aliceRatchet.Decrypt(encryptedMessage) - assert.NoError(t, err) - assert.Equal(t, plainText, decrypted) -} - -func TestOutOfOrder(t *testing.T) { - aliceRatchet, bobRatchet, err := initializeRatchets() - assert.NoError(t, err) - - plainText1 := []byte("First Message") - plainText2 := []byte("Second Messsage. A bit longer than the first.") - - /* Alice sends Bob two messages and they arrive out of order */ - message1Encrypted, err := aliceRatchet.Encrypt(plainText1) - assert.NoError(t, err) - message2Encrypted, err := aliceRatchet.Encrypt(plainText2) - assert.NoError(t, err) - - decrypted2, err := bobRatchet.Decrypt(message2Encrypted) - assert.NoError(t, err) - decrypted1, err := bobRatchet.Decrypt(message1Encrypted) - assert.NoError(t, err) - assert.Equal(t, plainText1, decrypted1) - assert.Equal(t, plainText2, decrypted2) -} - -func TestMoreMessages(t *testing.T) { - aliceRatchet, bobRatchet, err := initializeRatchets() - assert.NoError(t, err) - plainText := []byte("These 15 bytes") - for i := 0; i < 8; i++ { - messageEncrypted, err := aliceRatchet.Encrypt(plainText) - assert.NoError(t, err) - - decrypted, err := bobRatchet.Decrypt(messageEncrypted) - assert.NoError(t, err) - assert.Equal(t, plainText, decrypted) - } - for i := 0; i < 8; i++ { - messageEncrypted, err := bobRatchet.Encrypt(plainText) - assert.NoError(t, err) - - decrypted, err := aliceRatchet.Decrypt(messageEncrypted) - assert.NoError(t, err) - assert.Equal(t, plainText, decrypted) - } - messageEncrypted, err := aliceRatchet.Encrypt(plainText) - assert.NoError(t, err) - decrypted, err := bobRatchet.Decrypt(messageEncrypted) - assert.NoError(t, err) - assert.Equal(t, plainText, decrypted) -} - -func TestJSONEncoding(t *testing.T) { - aliceRatchet, bobRatchet, err := initializeRatchets() - assert.NoError(t, err) - marshaled, err := json.Marshal(aliceRatchet) - assert.NoError(t, err) - - newRatcher := ratchet.Ratchet{} - err = json.Unmarshal(marshaled, &newRatcher) - assert.NoError(t, err) - - plainText := []byte("These 15 bytes") - - messageEncrypted, err := newRatcher.Encrypt(plainText) - assert.NoError(t, err) - decrypted, err := bobRatchet.Decrypt(messageEncrypted) - assert.NoError(t, err) - assert.Equal(t, plainText, decrypted) -} diff --git a/mautrix-patched/crypto/goolm/ratchet/skipped_message.go b/mautrix-patched/crypto/goolm/ratchet/skipped_message.go deleted file mode 100644 index 2ffaee7b..00000000 --- a/mautrix-patched/crypto/goolm/ratchet/skipped_message.go +++ /dev/null @@ -1,27 +0,0 @@ -package ratchet - -import ( - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" -) - -// skippedMessageKey stores a skipped message key -type skippedMessageKey struct { - RKey crypto.Curve25519PublicKey `json:"ratchet_key"` - MKey messageKey `json:"message_key"` -} - -// UnpickleLibOlm unpickles the unencryted value and populates the skipped -// message keys accordingly. -func (r *skippedMessageKey) UnpickleLibOlm(decoder *libolmpickle.Decoder) (err error) { - if err = r.RKey.UnpickleLibOlm(decoder); err != nil { - return - } - return r.MKey.UnpickleLibOlm(decoder) -} - -// PickleLibOlm pickles the skipped message key into the encoder. -func (r skippedMessageKey) PickleLibOlm(encoder *libolmpickle.Encoder) { - r.RKey.PickleLibOlm(encoder) - r.MKey.PickleLibOlm(encoder) -} diff --git a/mautrix-patched/crypto/goolm/register.go b/mautrix-patched/crypto/goolm/register.go deleted file mode 100644 index 800f567f..00000000 --- a/mautrix-patched/crypto/goolm/register.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package goolm - -import ( - "maunium.net/go/mautrix/crypto/goolm/account" - "maunium.net/go/mautrix/crypto/goolm/pk" - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/olm" -) - -func Register() { - olm.Driver = "goolm" - - olm.GetVersion = func() (major, minor, patch uint8) { - return 3, 2, 15 - } - olm.SetPickleKeyImpl = func(key []byte) { - panic("gob and json encoding is deprecated and not supported with goolm") - } - - account.Register() - pk.Register() - session.Register() -} diff --git a/mautrix-patched/crypto/goolm/session/doc.go b/mautrix-patched/crypto/goolm/session/doc.go deleted file mode 100644 index bc2e8f46..00000000 --- a/mautrix-patched/crypto/goolm/session/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package session provides the different types of sessions for en/decrypting -// of messages -package session diff --git a/mautrix-patched/crypto/goolm/session/megolm_inbound_session.go b/mautrix-patched/crypto/goolm/session/megolm_inbound_session.go deleted file mode 100644 index fd7e0a01..00000000 --- a/mautrix-patched/crypto/goolm/session/megolm_inbound_session.go +++ /dev/null @@ -1,250 +0,0 @@ -package session - -import ( - "encoding/base64" - "fmt" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/goolmbase64" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/goolm/megolm" - "maunium.net/go/mautrix/crypto/goolm/message" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -const ( - megolmInboundSessionPickleVersionLibOlm uint32 = 2 -) - -// MegolmInboundSession stores information about the sessions of receive. -type MegolmInboundSession struct { - Ratchet megolm.Ratchet `json:"ratchet"` - SigningKey crypto.Ed25519PublicKey `json:"signing_key"` - InitialRatchet megolm.Ratchet `json:"initial_ratchet"` - SigningKeyVerified bool `json:"signing_key_verified"` //not used for now -} - -// Ensure that MegolmInboundSession implements the [olm.InboundGroupSession] -// interface. -var _ olm.InboundGroupSession = (*MegolmInboundSession)(nil) - -// NewMegolmInboundSession creates a new MegolmInboundSession from a base64 encoded session sharing message. -func NewMegolmInboundSession(input []byte) (*MegolmInboundSession, error) { - var err error - input, err = goolmbase64.Decode(input) - if err != nil { - return nil, err - } - msg := message.MegolmSessionSharing{} - err = msg.VerifyAndDecode(input) - if err != nil { - return nil, err - } - o := &MegolmInboundSession{} - o.SigningKey = msg.PublicKey - o.SigningKeyVerified = true - ratchet, err := megolm.New(msg.Counter, msg.RatchetData) - if err != nil { - return nil, err - } - o.Ratchet = *ratchet - o.InitialRatchet = *ratchet - return o, nil -} - -// NewMegolmInboundSessionFromExport creates a new MegolmInboundSession from a base64 encoded session export message. -func NewMegolmInboundSessionFromExport(input []byte) (*MegolmInboundSession, error) { - var err error - input, err = goolmbase64.Decode(input) - if err != nil { - return nil, err - } - msg := message.MegolmSessionExport{} - err = msg.Decode(input) - if err != nil { - return nil, err - } - o := &MegolmInboundSession{} - o.SigningKey = msg.PublicKey - ratchet, err := megolm.New(msg.Counter, msg.RatchetData) - if err != nil { - return nil, err - } - o.Ratchet = *ratchet - o.InitialRatchet = *ratchet - return o, nil -} - -// MegolmInboundSessionFromPickled loads the MegolmInboundSession details from a pickled base64 string. The input is decrypted with the supplied key. -func MegolmInboundSessionFromPickled(pickled, key []byte) (*MegolmInboundSession, error) { - if len(pickled) == 0 { - return nil, fmt.Errorf("megolmInboundSessionFromPickled: %w", olm.ErrEmptyInput) - } - a := &MegolmInboundSession{} - err := a.Unpickle(pickled, key) - if err != nil { - return nil, err - } - return a, nil -} - -// getRatchet tries to find the correct ratchet for a messageIndex. -func (o *MegolmInboundSession) getRatchet(messageIndex uint32) (*megolm.Ratchet, error) { - // pick a megolm instance to use. if we are at or beyond the latest ratchet value, use that - if (messageIndex - o.Ratchet.Counter) < uint32(1<<31) { - o.Ratchet.AdvanceTo(messageIndex) - return &o.Ratchet, nil - } - if (messageIndex - o.InitialRatchet.Counter) >= uint32(1<<31) { - // the counter is before our initial ratchet - we can't decode this - return nil, fmt.Errorf("decrypt: %w", olm.ErrUnknownMessageIndex) - } - // otherwise, start from the initial ratchet. Take a copy so that we don't overwrite the initial ratchet - copiedRatchet := o.InitialRatchet - copiedRatchet.AdvanceTo(messageIndex) - return &copiedRatchet, nil - -} - -// Decrypt decrypts a base64 encoded group message. -func (o *MegolmInboundSession) Decrypt(ciphertext []byte) ([]byte, uint, error) { - if len(ciphertext) == 0 { - return nil, 0, olm.ErrEmptyInput - } - if o.SigningKey == nil { - return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) - } - decoded, err := goolmbase64.Decode(ciphertext) - if err != nil { - return nil, 0, err - } - msg := &message.GroupMessage{} - err = msg.Decode(decoded) - if err != nil { - return nil, 0, err - } - if msg.Version != protocolVersion { - return nil, 0, fmt.Errorf("decrypt: %w (got %d, expected %d)", olm.ErrWrongProtocolVersion, msg.Version, protocolVersion) - } - if msg.Ciphertext == nil || !msg.HasMessageIndex { - return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadMessageFormat) - } - - // verify signature - verifiedSignature := msg.VerifySignatureInline(o.SigningKey, decoded) - if !verifiedSignature { - return nil, 0, fmt.Errorf("decrypt: %w", olm.ErrBadSignature) - } - - // Note: this will mutate the latest ratchet state even if decryption fails. - // We don't care because the signature was already checked, plus the initial ratchet is preserved. - targetRatch, err := o.getRatchet(msg.MessageIndex) - if err != nil { - return nil, 0, err - } - - decrypted, err := targetRatch.Decrypt(decoded, msg) - if err != nil { - return nil, 0, err - } - o.SigningKeyVerified = true - return decrypted, uint(msg.MessageIndex), nil - -} - -// ID returns the base64 endoded signing key -func (o *MegolmInboundSession) ID() id.SessionID { - return id.SessionID(base64.RawStdEncoding.EncodeToString(o.SigningKey)) -} - -// Export returns the base64-encoded ratchet key for this session, at the given -// index, in a format which can be used by -// InboundGroupSession.InboundGroupSessionImport(). Encrypts the -// InboundGroupSession using the supplied key. Returns error on failure. -// if we do not have a session key corresponding to the given index (ie, it was -// sent before the session key was shared with us) the error will be -// returned. -func (o *MegolmInboundSession) Export(messageIndex uint32) ([]byte, error) { - ratchet, err := o.getRatchet(messageIndex) - if err != nil { - return nil, err - } - return ratchet.SessionExportMessage(o.SigningKey) -} - -// Unpickle decodes the base64 encoded string and decrypts the result with the key. -// The decrypted value is then passed to UnpickleLibOlm. -func (o *MegolmInboundSession) Unpickle(pickled, key []byte) error { - if len(key) == 0 { - return olm.ErrNoKeyProvided - } else if len(pickled) == 0 { - return olm.ErrEmptyInput - } - decrypted, err := libolmpickle.Unpickle(key, pickled) - if err != nil { - return err - } - return o.UnpickleLibOlm(decrypted) -} - -// UnpickleLibOlm unpickles the unencryted value and populates the [Session] -// accordingly. -func (o *MegolmInboundSession) UnpickleLibOlm(value []byte) error { - decoder := libolmpickle.NewDecoder(value) - pickledVersion, err := decoder.ReadUInt32() - if err != nil { - return err - } - if pickledVersion != megolmInboundSessionPickleVersionLibOlm && pickledVersion != 1 { - return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) - } - - if err = o.InitialRatchet.UnpickleLibOlm(decoder); err != nil { - return err - } else if err = o.Ratchet.UnpickleLibOlm(decoder); err != nil { - return err - } else if err = o.SigningKey.UnpickleLibOlm(decoder); err != nil { - return err - } - - if pickledVersion == 1 { - // pickle v1 had no signing_key_verified field (all keyshares were verified at import time) - o.SigningKeyVerified = true - } else { - o.SigningKeyVerified, err = decoder.ReadBool() - return err - } - return nil -} - -// Pickle returns a base64 encoded and with key encrypted pickled MegolmInboundSession using PickleLibOlm(). -func (o *MegolmInboundSession) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - return libolmpickle.Pickle(key, o.PickleLibOlm()) -} - -// PickleLibOlm pickles the session returning the raw bytes. -func (o *MegolmInboundSession) PickleLibOlm() []byte { - encoder := libolmpickle.NewEncoder() - encoder.WriteUInt32(megolmInboundSessionPickleVersionLibOlm) - o.InitialRatchet.PickleLibOlm(encoder) - o.Ratchet.PickleLibOlm(encoder) - o.SigningKey.PickleLibOlm(encoder) - encoder.WriteBool(o.SigningKeyVerified) - return encoder.Bytes() -} - -// FirstKnownIndex returns the first message index we know how to decrypt. -func (s *MegolmInboundSession) FirstKnownIndex() uint32 { - return s.InitialRatchet.Counter -} - -// IsVerified check if the session has been verified as a valid session. (A -// session is verified either because the original session share was signed, or -// because we have subsequently successfully decrypted a message.) -func (s *MegolmInboundSession) IsVerified() bool { - return s.SigningKeyVerified -} diff --git a/mautrix-patched/crypto/goolm/session/megolm_outbound_session.go b/mautrix-patched/crypto/goolm/session/megolm_outbound_session.go deleted file mode 100644 index e5cf16fc..00000000 --- a/mautrix-patched/crypto/goolm/session/megolm_outbound_session.go +++ /dev/null @@ -1,135 +0,0 @@ -package session - -import ( - "crypto/rand" - "encoding/base64" - "fmt" - - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/goolmbase64" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/goolm/megolm" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -const ( - megolmOutboundSessionPickleVersion byte = 1 - megolmOutboundSessionPickleVersionLibOlm uint32 = 1 -) - -// MegolmOutboundSession stores information about the sessions to send. -type MegolmOutboundSession struct { - Ratchet megolm.Ratchet `json:"ratchet"` - SigningKey crypto.Ed25519KeyPair `json:"signing_key"` -} - -var _ olm.OutboundGroupSession = (*MegolmOutboundSession)(nil) - -// NewMegolmOutboundSession creates a new MegolmOutboundSession. -func NewMegolmOutboundSession() (*MegolmOutboundSession, error) { - o := &MegolmOutboundSession{} - var err error - o.SigningKey, err = crypto.Ed25519GenerateKey() - if err != nil { - return nil, err - } - var randomData [megolm.RatchetParts * megolm.RatchetPartLength]byte - _, err = rand.Read(randomData[:]) - if err != nil { - return nil, err - } - ratchet, err := megolm.New(0, randomData) - if err != nil { - return nil, err - } - o.Ratchet = *ratchet - return o, nil -} - -// MegolmOutboundSessionFromPickled loads the MegolmOutboundSession details from a pickled base64 string. The input is decrypted with the supplied key. -func MegolmOutboundSessionFromPickled(pickled, key []byte) (*MegolmOutboundSession, error) { - if len(pickled) == 0 { - return nil, fmt.Errorf("megolmOutboundSessionFromPickled: %w", olm.ErrEmptyInput) - } - a := &MegolmOutboundSession{} - err := a.Unpickle(pickled, key) - return a, err -} - -// Encrypt encrypts the plaintext as a base64 encoded group message. -func (o *MegolmOutboundSession) Encrypt(plaintext []byte) ([]byte, error) { - if len(plaintext) == 0 { - return nil, olm.ErrEmptyInput - } - encrypted, err := o.Ratchet.Encrypt(plaintext, o.SigningKey) - return goolmbase64.Encode(encrypted), err -} - -// SessionID returns the base64 endoded public signing key -func (o *MegolmOutboundSession) ID() id.SessionID { - return id.SessionID(base64.RawStdEncoding.EncodeToString(o.SigningKey.PublicKey)) -} - -// Unpickle decodes the base64 encoded string and decrypts the result with the key. -// The decrypted value is then passed to UnpickleLibOlm. -func (o *MegolmOutboundSession) Unpickle(pickled, key []byte) error { - if len(key) == 0 { - return olm.ErrNoKeyProvided - } - decrypted, err := libolmpickle.Unpickle(key, pickled) - if err != nil { - return err - } - return o.UnpickleLibOlm(decrypted) -} - -// UnpickleLibOlm unpickles the unencryted value and populates the -// [MegolmOutboundSession] accordingly. -func (o *MegolmOutboundSession) UnpickleLibOlm(buf []byte) error { - decoder := libolmpickle.NewDecoder(buf) - pickledVersion, err := decoder.ReadUInt32() - if err != nil { - return fmt.Errorf("unpickle MegolmOutboundSession: failed to read version: %w", err) - } else if pickledVersion != megolmOutboundSessionPickleVersionLibOlm { - return fmt.Errorf("unpickle MegolmInboundSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) - } - if err = o.Ratchet.UnpickleLibOlm(decoder); err != nil { - return err - } - return o.SigningKey.UnpickleLibOlm(decoder) -} - -// Pickle returns a base64 encoded and with key encrypted pickled MegolmOutboundSession using PickleLibOlm(). -func (o *MegolmOutboundSession) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - return libolmpickle.Pickle(key, o.PickleLibOlm()) -} - -// PickleLibOlm pickles the session returning the raw bytes. -func (o *MegolmOutboundSession) PickleLibOlm() []byte { - encoder := libolmpickle.NewEncoder() - encoder.WriteUInt32(megolmOutboundSessionPickleVersionLibOlm) - o.Ratchet.PickleLibOlm(encoder) - o.SigningKey.PickleLibOlm(encoder) - return encoder.Bytes() -} - -func (o *MegolmOutboundSession) SessionSharingMessage() ([]byte, error) { - return o.Ratchet.SessionSharingMessage(o.SigningKey) -} - -// MessageIndex returns the message index for this session. Each message is -// sent with an increasing index; this returns the index for the next message. -func (s *MegolmOutboundSession) MessageIndex() uint { - return uint(s.Ratchet.Counter) -} - -// Key returns the base64-encoded current ratchet key for this session. -func (s *MegolmOutboundSession) Key() string { - return string(exerrors.Must(s.SessionSharingMessage())) -} diff --git a/mautrix-patched/crypto/goolm/session/megolm_session_test.go b/mautrix-patched/crypto/goolm/session/megolm_session_test.go deleted file mode 100644 index 38aa223b..00000000 --- a/mautrix-patched/crypto/goolm/session/megolm_session_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package session_test - -import ( - "crypto/rand" - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/megolm" - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/olm" -) - -func TestOutboundPickleRoundtrip(t *testing.T) { - pickleKey := []byte("secretKey") - sess, err := session.NewMegolmOutboundSession() - assert.NoError(t, err) - kp, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - sess.SigningKey = kp - pickled, err := sess.Pickle(pickleKey) - assert.NoError(t, err) - - newSession := session.MegolmOutboundSession{} - err = newSession.Unpickle(pickled, pickleKey) - assert.NoError(t, err) - assert.Equal(t, sess.ID(), newSession.ID()) - assert.Equal(t, sess.SigningKey, newSession.SigningKey) - assert.Equal(t, sess.Ratchet, newSession.Ratchet) -} - -func TestInboundPickleRoundtrip(t *testing.T) { - pickleKey := []byte("secretKey") - sess := session.MegolmInboundSession{} - kp, err := crypto.Ed25519GenerateKey() - assert.NoError(t, err) - sess.SigningKey = kp.PublicKey - var randomData [megolm.RatchetParts * megolm.RatchetPartLength]byte - _, err = rand.Read(randomData[:]) - assert.NoError(t, err) - ratchet, err := megolm.New(0, randomData) - assert.NoError(t, err) - sess.Ratchet = *ratchet - pickled, err := sess.Pickle(pickleKey) - assert.NoError(t, err) - - newSession := session.MegolmInboundSession{} - err = newSession.Unpickle(pickled, pickleKey) - assert.NoError(t, err) - assert.Equal(t, sess.ID(), newSession.ID()) - assert.Equal(t, sess.SigningKey, newSession.SigningKey) - assert.Equal(t, sess.Ratchet, newSession.Ratchet) -} - -func TestGroupSendReceive(t *testing.T) { - randomData := []byte( - "0123456789ABDEF0123456789ABCDEF" + - "0123456789ABDEF0123456789ABCDEF" + - "0123456789ABDEF0123456789ABCDEF" + - "0123456789ABDEF0123456789ABCDEF" + - "0123456789ABDEF0123456789ABCDEF" + - "0123456789ABDEF0123456789ABCDEF", - ) - - outboundSession, err := session.NewMegolmOutboundSession() - assert.NoError(t, err) - copy(outboundSession.Ratchet.Data[:], randomData) - assert.EqualValues(t, 0, outboundSession.Ratchet.Counter) - - sessionSharing, err := outboundSession.SessionSharingMessage() - assert.NoError(t, err) - plainText := []byte("Message") - ciphertext, err := outboundSession.Encrypt(plainText) - assert.NoError(t, err) - assert.EqualValues(t, 1, outboundSession.Ratchet.Counter) - - //build inbound session - inboundSession, err := session.NewMegolmInboundSession(sessionSharing) - assert.NoError(t, err) - assert.True(t, inboundSession.SigningKeyVerified) - assert.Equal(t, outboundSession.ID(), inboundSession.ID()) - - //decode message - decoded, _, err := inboundSession.Decrypt(ciphertext) - assert.NoError(t, err) - assert.Equal(t, plainText, decoded) -} - -func TestGroupSessionExportImport(t *testing.T) { - plaintext := []byte("Message") - sessionKey := []byte( - "AgAAAAAwMTIzNDU2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMzQ1Njc4OUFCREVGM" + - "DEyMzQ1Njc4OUFCQ0RFRjAxMjM0NTY3ODlBQkRFRjAxMjM0NTY3ODlBQkNERUYwMTIzND" + - "U2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMw0bdg1BDq4Px/slBow06q8n/B9WBfw" + - "WYyNOB8DlUmXGGwrFmaSb9bR/eY8xgERrxmP07hFmD9uqA2p8PMHdnV5ysmgufE6oLZ5+" + - "8/mWQOW3VVTnDIlnwd8oHUYRuk8TCQ", - ) - message := []byte( - "AwgAEhAcbh6UpbByoyZxufQ+h2B+8XHMjhR69G8F4+qjMaFlnIXusJZX3r8LnRORG9T3D" + - "XFdbVuvIWrLyRfm4i8QRbe8VPwGRFG57B1CtmxanuP8bHtnnYqlwPsD", - ) - - //init inbound - inboundSession, err := session.NewMegolmInboundSession(sessionKey) - assert.NoError(t, err) - assert.True(t, inboundSession.SigningKeyVerified) - - decrypted, _, err := inboundSession.Decrypt(message) - assert.NoError(t, err) - assert.Equal(t, plaintext, decrypted) - - //Export the keys - exported, err := inboundSession.Export(0) - assert.NoError(t, err) - - secondInboundSession, err := session.NewMegolmInboundSessionFromExport(exported) - assert.NoError(t, err) - assert.False(t, secondInboundSession.SigningKeyVerified) - - //decrypt with new session - decrypted, _, err = secondInboundSession.Decrypt(message) - assert.NoError(t, err) - assert.Equal(t, plaintext, decrypted) - assert.True(t, secondInboundSession.SigningKeyVerified) -} - -func TestBadSignatureGroupMessage(t *testing.T) { - plaintext := []byte("Message") - sessionKey := []byte( - "AgAAAAAwMTIzNDU2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMzQ1Njc4OUFCREVGM" + - "DEyMzQ1Njc4OUFCQ0RFRjAxMjM0NTY3ODlBQkRFRjAxMjM0NTY3ODlBQkNERUYwMTIzND" + - "U2Nzg5QUJERUYwMTIzNDU2Nzg5QUJDREVGMDEyMztqJ7zOtqQtYqOo0CpvDXNlMhV3HeJ" + - "DpjrASKGLWdop4lx1cSN3Xv1TgfLPW8rhGiW+hHiMxd36nRuxscNv9k4oJA/KP+o0mi1w" + - "v44StrEJ1wwx9WZHBUIWkQbaBSuBDw", - ) - message := []byte( - "AwgAEhAcbh6UpbByoyZxufQ+h2B+8XHMjhR69G8nP4pNZGl/3QMgrzCZPmP+F2aPLyKPz" + - "xRPBMUkeXRJ6Iqm5NeOdx2eERgTW7P20CM+lL3Xpk+ZUOOPvsSQNaAL", - ) - - //init inbound - inboundSession, err := session.NewMegolmInboundSession(sessionKey) - assert.NoError(t, err) - assert.True(t, inboundSession.SigningKeyVerified) - - decrypted, _, err := inboundSession.Decrypt(message) - assert.NoError(t, err) - assert.Equal(t, plaintext, decrypted) - - //Now twiddle the signature - copy(message[len(message)-1:], []byte("E")) - _, _, err = inboundSession.Decrypt(message) - assert.ErrorIs(t, err, olm.ErrBadSignature) -} - -func TestOutbountPickle(t *testing.T) { - pickledDataFromLibOlm := []byte("icDKYm0b4aO23WgUuOxdpPoxC0UlEOYPVeuduNH3IkpFsmnWx5KuEOpxGiZw5IuB/sSn2RZUCTiJ90IvgC7AClkYGHep9O8lpiqQX73XVKD9okZDCAkBc83eEq0DKYC7HBkGRAU/4T6QPIBBY3UK4QZwULLE/fLsi3j4YZBehMtnlsqgHK0q1bvX4cRznZItUO3TiOp5I+6PnQka6n8eHTyIEh3tCetilD+BKnHvtakE0eHHvG6pjEsMNN/vs7lkB5rV6XkoUKHLTE1dAfFunYEeHEZuKQpbG385dBwaMJXt4JrC0hU5jnv6jWNqAA0Ud9GxRDvkp04") - pickleKey := []byte("secret_key") - sess, err := session.MegolmOutboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) - assert.NoError(t, err) - newPickled, err := sess.Pickle(pickleKey) - assert.NoError(t, err) - assert.Equal(t, pickledDataFromLibOlm, newPickled) - - pickledDataFromLibOlm[len(pickledDataFromLibOlm)-1] = 'a' - pickledDataFromLibOlm[len(pickledDataFromLibOlm)-2] = 'b' - pickledDataFromLibOlm[len(pickledDataFromLibOlm)-3] = 'c' - _, err = session.MegolmOutboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) - assert.ErrorIs(t, err, olm.ErrBadMAC) -} - -func TestInbountPickle(t *testing.T) { - pickledDataFromLibOlm := []byte("1/IPCdtUoQxMba5XT7sjjUW0Hrs7no9duGFnhsEmxzFX2H3qtRc4eaFBRZYXxOBRTGZ6eMgy3IiSrgAQ1gUlSZf5Q4AVKeBkhvN4LZ6hdhQFv91mM+C2C55/4B9/gDjJEbDGiRgLoMqbWPDV+y0F4h0KaR1V1PiTCC7zCi4WdxJQ098nJLgDL4VSsDbnaLcSMO60FOYgRN4KsLaKUGkXiiUBWp4boFMCiuTTOiyH8XlH0e9uWc0vMLyGNUcO8kCbpAnx3v1JTIVan3WGsnGv4K8Qu4M8GAkZewpexrsb2BSNNeLclOV9/cR203Y5KlzXcpiWNXSs8XoB3TLEtHYMnjuakMQfyrcXKIQntg4xPD/+wvfqkcMg9i7pcplQh7X2OK5ylrMZQrZkJ1fAYBGbBz1tykWOjfrZ") - pickleKey := []byte("secret_key") - sess, err := session.MegolmInboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) - assert.NoError(t, err) - newPickled, err := sess.Pickle(pickleKey) - assert.NoError(t, err) - assert.Equal(t, pickledDataFromLibOlm, newPickled) - - pickledDataFromLibOlm = append(pickledDataFromLibOlm, []byte("a")...) - _, err = session.MegolmInboundSessionFromPickled(pickledDataFromLibOlm, pickleKey) - assert.ErrorIs(t, err, base64.CorruptInputError(416)) -} diff --git a/mautrix-patched/crypto/goolm/session/olm_session.go b/mautrix-patched/crypto/goolm/session/olm_session.go deleted file mode 100644 index 12694663..00000000 --- a/mautrix-patched/crypto/goolm/session/olm_session.go +++ /dev/null @@ -1,404 +0,0 @@ -package session - -import ( - "crypto/sha256" - "encoding/base64" - "fmt" - "strings" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/goolmbase64" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/goolm/message" - "maunium.net/go/mautrix/crypto/goolm/ratchet" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -const ( - olmSessionPickleVersionLibOlm uint32 = 1 -) - -const ( - protocolVersion = 0x3 -) - -// OlmSession stores all information for an olm session -type OlmSession struct { - ReceivedMessage bool `json:"received_message"` - AliceIdentityKey crypto.Curve25519PublicKey `json:"alice_id_key"` - AliceBaseKey crypto.Curve25519PublicKey `json:"alice_base_key"` - BobOneTimeKey crypto.Curve25519PublicKey `json:"bob_one_time_key"` - Ratchet ratchet.Ratchet `json:"ratchet"` -} - -var _ olm.Session = (*OlmSession)(nil) - -// SearchOTKFunc is used to retrieve a crypto.OneTimeKey from a public key. -type SearchOTKFunc = func(crypto.Curve25519PublicKey) *crypto.OneTimeKey - -// OlmSessionFromPickled loads the OlmSession details from a pickled base64 string. The input is decrypted with the supplied key. -func OlmSessionFromPickled(pickled, key []byte) (*OlmSession, error) { - if len(pickled) == 0 { - return nil, fmt.Errorf("sessionFromPickled: %w", olm.ErrEmptyInput) - } - a := &OlmSession{} - return a, a.Unpickle(pickled, key) -} - -// NewOlmSession creates a new Session. -func NewOlmSession() *OlmSession { - s := &OlmSession{} - s.Ratchet = *ratchet.New() - return s -} - -// NewOutboundOlmSession creates a new outbound session for sending the first message to a -// given curve25519 identityKey and oneTimeKey. -func NewOutboundOlmSession(identityKeyAlice crypto.Curve25519KeyPair, identityKeyBob crypto.Curve25519PublicKey, oneTimeKeyBob crypto.Curve25519PublicKey) (*OlmSession, error) { - s := NewOlmSession() - //generate E_A - baseKey, err := crypto.Curve25519GenerateKey() - if err != nil { - return nil, err - } - //generate T_0 - ratchetKey, err := crypto.Curve25519GenerateKey() - if err != nil { - return nil, err - } - - //Calculate shared secret via Triple Diffie-Hellman - var secret []byte - //ECDH(I_A,E_B) - idSecret, err := identityKeyAlice.SharedSecret(oneTimeKeyBob) - if err != nil { - return nil, err - } - //ECDH(E_A,I_B) - baseIdSecret, err := baseKey.SharedSecret(identityKeyBob) - if err != nil { - return nil, err - } - //ECDH(E_A,E_B) - baseOneTimeSecret, err := baseKey.SharedSecret(oneTimeKeyBob) - if err != nil { - return nil, err - } - secret = append(secret, idSecret...) - secret = append(secret, baseIdSecret...) - secret = append(secret, baseOneTimeSecret...) - //Init Ratchet - err = s.Ratchet.InitializeAsAlice(secret, ratchetKey) - if err != nil { - return nil, fmt.Errorf("ratchet initialize: %w", err) - } - s.AliceIdentityKey = identityKeyAlice.PublicKey - s.AliceBaseKey = baseKey.PublicKey - s.BobOneTimeKey = oneTimeKeyBob - return s, nil -} - -// NewInboundOlmSession creates a new inbound session from receiving the first message. -func NewInboundOlmSession(identityKeyAlice *crypto.Curve25519PublicKey, receivedOTKMsg []byte, searchBobOTK SearchOTKFunc, identityKeyBob crypto.Curve25519KeyPair) (*OlmSession, error) { - decodedOTKMsg, err := goolmbase64.Decode(receivedOTKMsg) - if err != nil { - return nil, err - } - s := NewOlmSession() - - //decode OneTimeKeyMessage - oneTimeMsg := message.PreKeyMessage{} - err = oneTimeMsg.Decode(decodedOTKMsg) - if err != nil { - return nil, fmt.Errorf("OneTimeKeyMessage decode: %w", err) - } - if !oneTimeMsg.CheckFields() { - return nil, fmt.Errorf("OneTimeKeyMessage check fields: %w", olm.ErrBadMessageFormat) - } - - //Either the identityKeyAlice is set and/or the oneTimeMsg.IdentityKey is set, which is checked - // by oneTimeMsg.CheckFields - if identityKeyAlice != nil && !identityKeyAlice.Equal(oneTimeMsg.IdentityKey) { - return nil, fmt.Errorf("OneTimeKeyMessage identity keys: %w", olm.ErrBadMessageKeyID) - } - - oneTimeKeyBob := searchBobOTK(oneTimeMsg.OneTimeKey) - if oneTimeKeyBob == nil { - return nil, fmt.Errorf("ourOneTimeKey: %w", olm.ErrBadMessageKeyID) - } - - //Calculate shared secret via Triple Diffie-Hellman - var secret []byte - //ECDH(E_B,I_A) - idSecret, err := oneTimeKeyBob.Key.SharedSecret(oneTimeMsg.IdentityKey) - if err != nil { - return nil, err - } - //ECDH(I_B,E_A) - baseIdSecret, err := identityKeyBob.SharedSecret(oneTimeMsg.BaseKey) - if err != nil { - return nil, err - } - //ECDH(E_B,E_A) - baseOneTimeSecret, err := oneTimeKeyBob.Key.SharedSecret(oneTimeMsg.BaseKey) - if err != nil { - return nil, err - } - secret = append(secret, idSecret...) - secret = append(secret, baseIdSecret...) - secret = append(secret, baseOneTimeSecret...) - //decode message - msg := message.Message{} - err = msg.Decode(oneTimeMsg.Message) - if err != nil { - return nil, fmt.Errorf("message decode: %w", err) - } - - if len(msg.RatchetKey) != crypto.Curve25519PublicKeyLength { - return nil, fmt.Errorf("message missing ratchet key: %w", olm.ErrBadMessageFormat) - } - //Init Ratchet - err = s.Ratchet.InitializeAsBob(secret, msg.RatchetKey) - if err != nil { - return nil, fmt.Errorf("ratchet initialize: %w", err) - } - s.AliceBaseKey = oneTimeMsg.BaseKey - s.AliceIdentityKey = oneTimeMsg.IdentityKey - s.BobOneTimeKey = oneTimeKeyBob.Key.PublicKey - - //https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/olm.md states to remove the oneTimeKey - //this is done via the account itself - return s, nil -} - -// ID returns an identifier for this Session. Will be the same for both ends of the conversation. -// Generated by hashing the public keys used to create the session. -func (s *OlmSession) ID() id.SessionID { - message := make([]byte, 3*crypto.Curve25519PrivateKeyLength) - copy(message, s.AliceIdentityKey) - copy(message[crypto.Curve25519PrivateKeyLength:], s.AliceBaseKey) - copy(message[2*crypto.Curve25519PrivateKeyLength:], s.BobOneTimeKey) - hash := sha256.Sum256(message) - res := id.SessionID(base64.RawStdEncoding.EncodeToString(hash[:])) - return res -} - -// HasReceivedMessage returns true if this session has received any message. -func (s *OlmSession) HasReceivedMessage() bool { - return s.ReceivedMessage -} - -// MatchesInboundSession checks if the PRE_KEY message is for this in-bound -// Session. This can happen if multiple messages are sent to this Account -// before this Account sends a message in reply. Returns true if the session -// matches. Returns false if the session does not match. Returns error on -// failure. -func (s *OlmSession) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { - return s.matchesInboundSession(nil, []byte(oneTimeKeyMsg)) -} - -// MatchesInboundSessionFrom checks if the PRE_KEY message is for this in-bound -// Session. This can happen if multiple messages are sent to this Account -// before this Account sends a message in reply. Returns true if the session -// matches. Returns false if the session does not match. Returns error on -// failure. -func (s *OlmSession) MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) { - var theirKey *id.Curve25519 - if theirIdentityKey != "" { - theirs := id.Curve25519(theirIdentityKey) - theirKey = &theirs - } - - return s.matchesInboundSession(theirKey, []byte(oneTimeKeyMsg)) -} - -// matchesInboundSession checks if the oneTimeKeyMsg message is set for this -// inbound Session. This can happen if multiple messages are sent to this -// Account before this Account sends a message in reply. Returns true if the -// session matches. Returns false if the session does not match. -func (s *OlmSession) matchesInboundSession(theirIdentityKeyEncoded *id.Curve25519, receivedOTKMsg []byte) (bool, error) { - if len(receivedOTKMsg) == 0 { - return false, fmt.Errorf("inbound match: %w", olm.ErrEmptyInput) - } - decodedOTKMsg, err := goolmbase64.Decode(receivedOTKMsg) - if err != nil { - return false, err - } - - var theirIdentityKey *crypto.Curve25519PublicKey - if theirIdentityKeyEncoded != nil { - decodedKey, err := base64.RawStdEncoding.DecodeString(string(*theirIdentityKeyEncoded)) - if err != nil { - return false, err - } - theirIdentityKeyByte := crypto.Curve25519PublicKey(decodedKey) - theirIdentityKey = &theirIdentityKeyByte - } - - msg := message.PreKeyMessage{} - err = msg.Decode(decodedOTKMsg) - if err != nil { - return false, err - } - if !msg.CheckFields() { - return false, nil - } - - same := true - same = same && msg.IdentityKey.Equal(s.AliceIdentityKey) - if theirIdentityKey != nil { - same = same && theirIdentityKey.Equal(s.AliceIdentityKey) - } - same = same && msg.BaseKey.Equal(s.AliceBaseKey) - same = same && msg.OneTimeKey.Equal(s.BobOneTimeKey) - return same, nil -} - -// EncryptMsgType returns the type of the next message that Encrypt will -// return. Returns MsgTypePreKey if the message will be a oneTimeKeyMsg. -// Returns MsgTypeMsg if the message will be a normal message. -func (s *OlmSession) EncryptMsgType() id.OlmMsgType { - if s.ReceivedMessage { - return id.OlmMsgTypeMsg - } - return id.OlmMsgTypePreKey -} - -// Encrypt encrypts a message using the Session. Returns the encrypted message base64 encoded. -func (s *OlmSession) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { - if len(plaintext) == 0 { - return 0, nil, fmt.Errorf("encrypt: %w", olm.ErrEmptyInput) - } - messageType := s.EncryptMsgType() - encrypted, err := s.Ratchet.Encrypt(plaintext) - if err != nil { - return 0, nil, err - } - result := encrypted - if !s.ReceivedMessage { - msg := message.PreKeyMessage{} - msg.Version = protocolVersion - msg.OneTimeKey = s.BobOneTimeKey - msg.IdentityKey = s.AliceIdentityKey - msg.BaseKey = s.AliceBaseKey - msg.Message = encrypted - - var err error - messageBody, err := msg.Encode() - if err != nil { - return 0, nil, err - } - result = messageBody - } - - return messageType, goolmbase64.Encode(result), nil -} - -// Decrypt decrypts a base64 encoded message using the Session. -func (s *OlmSession) Decrypt(crypttext string, msgType id.OlmMsgType) ([]byte, error) { - if len(crypttext) == 0 { - return nil, fmt.Errorf("decrypt: %w", olm.ErrEmptyInput) - } - decodedCrypttext, err := base64.RawStdEncoding.DecodeString(crypttext) - if err != nil { - return nil, err - } - msgBody := decodedCrypttext - if msgType != id.OlmMsgTypeMsg { - //Pre-Key Message - msg := message.PreKeyMessage{} - err := msg.Decode(decodedCrypttext) - if err != nil { - return nil, err - } - msgBody = msg.Message - } - plaintext, err := s.Ratchet.Decrypt(msgBody) - if err != nil { - return nil, err - } - s.ReceivedMessage = true - return plaintext, nil -} - -// Unpickle decodes the base64 encoded string and decrypts the result with the key. -// The decrypted value is then passed to UnpickleLibOlm. -func (o *OlmSession) Unpickle(pickled, key []byte) error { - if len(pickled) == 0 { - return olm.ErrEmptyInput - } - decrypted, err := libolmpickle.Unpickle(key, pickled) - if err != nil { - return err - } - return o.UnpickleLibOlm(decrypted) -} - -// UnpickleLibOlm unpickles the unencryted value and populates the [OlmSession] -// accordingly. -func (o *OlmSession) UnpickleLibOlm(buf []byte) error { - decoder := libolmpickle.NewDecoder(buf) - pickledVersion, err := decoder.ReadUInt32() - if err != nil { - return fmt.Errorf("unpickle olmSession: failed to read version: %w", err) - } - - var includesChainIndex bool - switch pickledVersion { - case olmSessionPickleVersionLibOlm: - includesChainIndex = false - case uint32(0x80000001): - includesChainIndex = true - default: - return fmt.Errorf("unpickle olmSession: %w (found version %d)", olm.ErrUnknownOlmPickleVersion, pickledVersion) - } - - if o.ReceivedMessage, err = decoder.ReadBool(); err != nil { - return err - } else if err = o.AliceIdentityKey.UnpickleLibOlm(decoder); err != nil { - return err - } else if err = o.AliceBaseKey.UnpickleLibOlm(decoder); err != nil { - return err - } else if err = o.BobOneTimeKey.UnpickleLibOlm(decoder); err != nil { - return err - } - return o.Ratchet.UnpickleLibOlm(decoder, includesChainIndex) -} - -// Pickle returns a base64 encoded and with key encrypted pickled olmSession -// using PickleLibOlm(). -func (s *OlmSession) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - return libolmpickle.Pickle(key, s.PickleLibOlm()) -} - -// PickleLibOlm pickles the session and returns the raw bytes. -func (o *OlmSession) PickleLibOlm() []byte { - encoder := libolmpickle.NewEncoder() - encoder.WriteUInt32(olmSessionPickleVersionLibOlm) - encoder.WriteBool(o.ReceivedMessage) - o.AliceIdentityKey.PickleLibOlm(encoder) - o.AliceBaseKey.PickleLibOlm(encoder) - o.BobOneTimeKey.PickleLibOlm(encoder) - o.Ratchet.PickleLibOlm(encoder) - return encoder.Bytes() -} - -// Describe returns a string describing the current state of the session for debugging. -func (o *OlmSession) Describe() string { - var builder strings.Builder - builder.WriteString("sender chain index: ") - builder.WriteString(fmt.Sprint(o.Ratchet.SenderChains.CKey.Index)) - builder.WriteString(" receiver chain indices:") - for _, curChain := range o.Ratchet.ReceiverChains { - builder.WriteString(fmt.Sprintf(" %d", curChain.CKey.Index)) - } - builder.WriteString(" skipped message keys:") - for _, curSkip := range o.Ratchet.SkippedMessageKeys { - builder.WriteString(fmt.Sprintf(" %d", curSkip.MKey.Index)) - } - return builder.String() -} diff --git a/mautrix-patched/crypto/goolm/session/olm_session_test.go b/mautrix-patched/crypto/goolm/session/olm_session_test.go deleted file mode 100644 index eb79e914..00000000 --- a/mautrix-patched/crypto/goolm/session/olm_session_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package session_test - -import ( - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -func TestOlmSession(t *testing.T) { - pickleKey := []byte("secretKey") - aliceKeyPair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - bobKeyPair, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - bobOneTimeKey, err := crypto.Curve25519GenerateKey() - assert.NoError(t, err) - aliceSession, err := session.NewOutboundOlmSession(aliceKeyPair, bobKeyPair.PublicKey, bobOneTimeKey.PublicKey) - assert.NoError(t, err) - //create a message so that there are more keys to marshal - plaintext := []byte("Test message from Alice to Bob") - msgType, message, err := aliceSession.Encrypt(plaintext) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - - searchFunc := func(target crypto.Curve25519PublicKey) *crypto.OneTimeKey { - if target.Equal(bobOneTimeKey.PublicKey) { - return &crypto.OneTimeKey{ - Key: bobOneTimeKey, - Published: false, - ID: 1, - } - } - return nil - } - //bob receives message - bobSession, err := session.NewInboundOlmSession(nil, message, searchFunc, bobKeyPair) - assert.NoError(t, err) - decryptedMsg, err := bobSession.Decrypt(string(message), msgType) - assert.NoError(t, err) - assert.Equal(t, plaintext, decryptedMsg) - - // Alice pickles session - pickled, err := aliceSession.Pickle(pickleKey) - assert.NoError(t, err) - - //bob sends a message - plaintext = []byte("A message from Bob to Alice") - msgType, message, err = bobSession.Encrypt(plaintext) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - - //Alice unpickles session - newAliceSession, err := session.OlmSessionFromPickled(pickled, pickleKey) - assert.NoError(t, err) - - //Alice receives message - decryptedMsg, err = newAliceSession.Decrypt(string(message), msgType) - assert.NoError(t, err) - assert.Equal(t, plaintext, decryptedMsg) - - //Alice receives message again - _, err = newAliceSession.Decrypt(string(message), msgType) - assert.ErrorIs(t, err, olm.ErrMessageKeyNotFound) - - //Alice sends another message - plaintext = []byte("A second message to Bob") - msgType, message, err = newAliceSession.Encrypt(plaintext) - assert.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - - //bob receives message - decryptedMsg, err = bobSession.Decrypt(string(message), msgType) - assert.NoError(t, err) - assert.Equal(t, plaintext, decryptedMsg) -} - -func TestSessionPickle(t *testing.T) { - pickledDataFromLibOlm := []byte("icDKYm0b4aO23WgUuOxdpPoxC0UlEOYPVeuduNH3IkpFsmnWx5KuEOpxGiZw5IuB/sSn2RZUCTiJ90IvgC7AClkYGHep9O8lpiqQX73XVKD9okZDCAkBc83eEq0DKYC7HBkGRAU/4T6QPIBBY3UK4QZwULLE/fLsi3j4YZBehMtnlsqgHK0q1bvX4cRznZItVKR4ro0O9EAk6LLxJtSnRu5elSUk7YXT") - pickleKey := []byte("secret_key") - sess, err := session.OlmSessionFromPickled(pickledDataFromLibOlm, pickleKey) - assert.NoError(t, err) - newPickled, err := sess.Pickle(pickleKey) - assert.NoError(t, err) - assert.Equal(t, pickledDataFromLibOlm, newPickled) - - pickledDataFromLibOlm = append(pickledDataFromLibOlm, []byte("a")...) - _, err = session.OlmSessionFromPickled(pickledDataFromLibOlm, pickleKey) - assert.ErrorIs(t, err, base64.CorruptInputError(224)) -} - -func TestDecrypts(t *testing.T) { - messages := [][]byte{ - {0x41, 0x77, 0x6F}, - {0x7f, 0xff, 0x6f, 0x01, 0x01, 0x34, 0x6d, 0x67, 0x12, 0x01}, - {0xee, 0x77, 0x6f, 0x41, 0x49, 0x6f, 0x67, 0x41, 0x77, 0x80, 0x41, 0x77, 0x77, 0x80, 0x41, 0x77, 0x6f, 0x67, 0x16, 0x67, 0x0a, 0x67, 0x7d, 0x6f, 0x67, 0x0a, 0x67, 0xc2, 0x67, 0x7d}, - {0xe9, 0xe9, 0xc9, 0xc1, 0xe9, 0xe9, 0xc9, 0xe9, 0xc9, 0xc1, 0xe9, 0xe9, 0xc9, 0xc1}, - } - expectedErr := []error{ - olm.ErrInputToSmall, - // Why are these being tested 🤔 - base64.CorruptInputError(0), - base64.CorruptInputError(0), - base64.CorruptInputError(0), - } - sessionPickled := []byte("E0p44KO2y2pzp9FIjv0rud2wIvWDi2dx367kP4Fz/9JCMrH+aG369HGymkFtk0+PINTLB9lQRt" + - "ohea5d7G/UXQx3r5y4IWuyh1xaRnojEZQ9a5HRZSNtvmZ9NY1f1gutYa4UtcZcbvczN8b/5Bqg" + - "e16cPUH1v62JKLlhoAJwRkH1wU6fbyOudERg5gdXA971btR+Q2V8GKbVbO5fGKL5phmEPVXyMs" + - "rfjLdzQrgjOTxN8Pf6iuP+WFPvfnR9lDmNCFxJUVAdLIMnLuAdxf1TGcS+zzCzEE8btIZ99mHF" + - "dGvPXeH8qLeNZA") - pickleKey := []byte("") - sess, err := session.OlmSessionFromPickled(sessionPickled, pickleKey) - assert.NoError(t, err) - for curIndex, curMessage := range messages { - _, err := sess.Decrypt(string(curMessage), id.OlmMsgTypePreKey) - assert.ErrorIs(t, err, expectedErr[curIndex]) - } -} diff --git a/mautrix-patched/crypto/goolm/session/register.go b/mautrix-patched/crypto/goolm/session/register.go deleted file mode 100644 index b95a44ac..00000000 --- a/mautrix-patched/crypto/goolm/session/register.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package session - -import ( - "maunium.net/go/mautrix/crypto/olm" -) - -func Register() { - // Inbound Session - olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { - if len(pickled) == 0 { - return nil, olm.ErrEmptyInput - } - if len(key) == 0 { - key = []byte(" ") - } - return MegolmInboundSessionFromPickled(pickled, key) - } - olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { - if len(sessionKey) == 0 { - return nil, olm.ErrEmptyInput - } - return NewMegolmInboundSession(sessionKey) - } - olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { - if len(sessionKey) == 0 { - return nil, olm.ErrEmptyInput - } - return NewMegolmInboundSessionFromExport(sessionKey) - } - olm.InitBlankInboundGroupSession = func() olm.InboundGroupSession { - return &MegolmInboundSession{} - } - - // Outbound Session - olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { - if len(pickled) == 0 { - return nil, olm.ErrEmptyInput - } - lenKey := len(key) - if lenKey == 0 { - key = []byte(" ") - } - return MegolmOutboundSessionFromPickled(pickled, key) - } - olm.InitNewOutboundGroupSession = func() (olm.OutboundGroupSession, error) { return NewMegolmOutboundSession() } - olm.InitNewBlankOutboundGroupSession = func() olm.OutboundGroupSession { return &MegolmOutboundSession{} } - - // Olm Session - olm.InitSessionFromPickled = func(pickled, key []byte) (olm.Session, error) { - return OlmSessionFromPickled(pickled, key) - } - olm.InitNewBlankSession = func() olm.Session { - return NewOlmSession() - } -} diff --git a/mautrix-patched/crypto/keybackup.go b/mautrix-patched/crypto/keybackup.go deleted file mode 100644 index 6b1d97a2..00000000 --- a/mautrix-patched/crypto/keybackup.go +++ /dev/null @@ -1,242 +0,0 @@ -package crypto - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/backup" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (mach *OlmMachine) DownloadAndStoreLatestKeyBackup(ctx context.Context, megolmBackupKey *backup.MegolmBackupKey) (id.KeyBackupVersion, error) { - log := mach.machOrContextLog(ctx).With(). - Str("action", "download and store latest key backup"). - Logger() - - ctx = log.WithContext(ctx) - - versionInfo, err := mach.GetAndVerifyLatestKeyBackupVersion(ctx, megolmBackupKey) - if err != nil { - return "", err - } else if versionInfo == nil { - return "", nil - } - - err = mach.GetAndStoreKeyBackup(ctx, versionInfo.Version, megolmBackupKey) - return versionInfo.Version, err -} - -func (mach *OlmMachine) GetAndVerifyLatestKeyBackupVersion(ctx context.Context, megolmBackupKey *backup.MegolmBackupKey) (*mautrix.RespRoomKeysVersion[backup.MegolmAuthData], error) { - versionInfo, err := mach.Client.GetKeyBackupLatestVersion(ctx) - if err != nil { - return nil, err - } - - if versionInfo.Algorithm != id.KeyBackupAlgorithmMegolmBackupV1 { - return nil, fmt.Errorf("unsupported key backup algorithm: %s", versionInfo.Algorithm) - } - - log := mach.machOrContextLog(ctx).With(). - Int("count", versionInfo.Count). - Str("etag", versionInfo.ETag). - Stringer("key_backup_version", versionInfo.Version). - Logger() - - // https://spec.matrix.org/v1.10/client-server-api/#server-side-key-backups - // "Clients must only store keys in backups after they have ensured that the auth_data is trusted. This can be done either... - // ...by deriving the public key from a private key that it obtained from a trusted source. Trusted sources for the private - // key include the user entering the key, retrieving the key stored in secret storage, or obtaining the key via secret sharing - // from a verified device belonging to the same user." - if megolmBackupKey != nil { - megolmBackupDerivedPublicKey := id.Ed25519(base64.RawStdEncoding.EncodeToString(megolmBackupKey.PublicKey().Bytes())) - if versionInfo.AuthData.PublicKey == megolmBackupDerivedPublicKey { - log.Debug().Msg("Key backup is trusted based on derived public key") - return versionInfo, nil - } - log.Debug(). - Stringer("expected_key", megolmBackupDerivedPublicKey). - Stringer("actual_key", versionInfo.AuthData.PublicKey). - Msg("key backup public keys do not match, proceeding to check device signatures") - } - - // "...or checking that it is signed by the user’s master cross-signing key or by a verified device belonging to the same user" - userSignatures, ok := versionInfo.AuthData.Signatures[mach.Client.UserID] - if !ok { - return nil, fmt.Errorf("no signature from user %s found in key backup", mach.Client.UserID) - } - - crossSigningPubkeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } else if crossSigningPubkeys == nil { - return nil, ErrCrossSigningPubkeysNotCached - } - - signatureVerified := false - for keyID := range userSignatures { - keyAlg, keyName := keyID.Parse() - if keyAlg != id.KeyAlgorithmEd25519 { - continue - } - log := log.With().Str("key_name", keyName).Logger() - - var key id.Ed25519 - if keyName == crossSigningPubkeys.MasterKey.String() { - key = crossSigningPubkeys.MasterKey - } else if device, err := mach.CryptoStore.GetDevice(ctx, mach.Client.UserID, id.DeviceID(keyName)); err != nil { - return nil, fmt.Errorf("failed to get device %s/%s from store: %w", mach.Client.UserID, keyName, err) - } else if device == nil { - log.Warn().Err(err).Msg("Device does not exist, ignoring signature") - continue - } else if !mach.IsDeviceTrusted(ctx, device) { - log.Warn().Err(err).Msg("Device is not trusted") - continue - } else { - key = device.SigningKey - } - - ok, err = signatures.VerifySignatureJSON(versionInfo.AuthData, mach.Client.UserID, keyName, key) - if err != nil || !ok { - log.Warn().Err(err).Stringer("key_id", keyID).Msg("Signature verification failed") - continue - } else { - // One of the signatures is valid, break from the loop. - log.Debug().Stringer("key_id", keyID).Msg("key backup is trusted based on matching signature") - signatureVerified = true - break - } - } - if !signatureVerified { - return nil, fmt.Errorf("no valid signature from user %s found in key backup", mach.Client.UserID) - } - - return versionInfo, nil -} - -func (mach *OlmMachine) GetAndStoreKeyBackup(ctx context.Context, version id.KeyBackupVersion, megolmBackupKey *backup.MegolmBackupKey) error { - keys, err := mach.Client.GetKeyBackup(ctx, version) - if err != nil { - return err - } - - log := zerolog.Ctx(ctx) - - var count, failedCount int - - for roomID, backup := range keys.Rooms { - for sessionID, keyBackupData := range backup.Sessions { - sessionData, err := keyBackupData.SessionData.Decrypt(megolmBackupKey) - if err != nil { - log.Warn().Err(err).Msg("Failed to decrypt session data") - failedCount++ - continue - } - - _, err = mach.ImportRoomKeyFromBackup(ctx, version, roomID, sessionID, sessionData) - if err != nil { - log.Warn().Err(err).Msg("Failed to import room key from backup") - failedCount++ - continue - } - count++ - } - } - - log.Info(). - Int("count", count). - Int("failed_count", failedCount). - Msg("successfully imported sessions from backup") - - return nil -} - -var ( - ErrUnknownAlgorithmInKeyBackup = errors.New("ignoring room key in backup with weird algorithm") - ErrMismatchingSessionIDInKeyBackup = errors.New("mismatched session ID while creating inbound group session from key backup") - ErrFailedToStoreNewInboundGroupSessionFromBackup = errors.New("failed to store new inbound group session from key backup") -) - -func (mach *OlmMachine) ImportRoomKeyFromBackupWithoutSaving( - ctx context.Context, - version id.KeyBackupVersion, - roomID id.RoomID, - config *event.EncryptionEventContent, - sessionID id.SessionID, - keyBackupData *backup.MegolmSessionData, -) (*InboundGroupSession, error) { - log := zerolog.Ctx(ctx) - if keyBackupData.Algorithm != id.AlgorithmMegolmV1 { - return nil, fmt.Errorf("%w %s", ErrUnknownAlgorithmInKeyBackup, keyBackupData.Algorithm) - } - - igsInternal, err := olm.InboundGroupSessionImport([]byte(keyBackupData.SessionKey)) - if err != nil { - return nil, fmt.Errorf("failed to import inbound group session: %w", err) - } else if igsInternal.ID() != sessionID { - log.Warn(). - Stringer("room_id", roomID). - Stringer("session_id", sessionID). - Stringer("actual_session_id", igsInternal.ID()). - Msg("Mismatched session ID while creating inbound group session from key backup") - return nil, ErrMismatchingSessionIDInKeyBackup - } - - var maxAge time.Duration - var maxMessages int - if config != nil { - maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond - maxMessages = config.RotationPeriodMessages - } - - return &InboundGroupSession{ - Internal: igsInternal, - SigningKey: keyBackupData.SenderClaimedKeys.Ed25519, - SenderKey: keyBackupData.SenderKey, - RoomID: roomID, - ForwardingChains: keyBackupData.ForwardingKeyChain, - id: sessionID, - - ReceivedAt: time.Now().UTC(), - MaxAge: maxAge.Milliseconds(), - MaxMessages: maxMessages, - KeyBackupVersion: version, - KeySource: id.KeySourceBackup, - }, nil -} - -func (mach *OlmMachine) ImportRoomKeyFromBackup(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, keyBackupData *backup.MegolmSessionData) (*InboundGroupSession, error) { - config, err := mach.StateStore.GetEncryptionEvent(ctx, roomID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("room_id", roomID). - Stringer("session_id", sessionID). - Msg("Failed to get encryption event for room") - } - imported, err := mach.ImportRoomKeyFromBackupWithoutSaving(ctx, version, roomID, config, sessionID, keyBackupData) - if err != nil { - return nil, err - } - firstKnownIndex := imported.Internal.FirstKnownIndex() - if firstKnownIndex > 0 { - zerolog.Ctx(ctx).Warn(). - Stringer("room_id", roomID). - Stringer("session_id", sessionID). - Uint32("first_known_index", firstKnownIndex). - Msg("Importing partial session") - } - err = mach.CryptoStore.PutGroupSession(ctx, imported) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrFailedToStoreNewInboundGroupSessionFromBackup, err) - } - mach.MarkSessionReceived(ctx, roomID, sessionID, firstKnownIndex) - return imported, nil -} diff --git a/mautrix-patched/crypto/keyexport.go b/mautrix-patched/crypto/keyexport.go deleted file mode 100644 index 1904c8a5..00000000 --- a/mautrix-patched/crypto/keyexport.go +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha256" - "crypto/sha512" - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "math" - - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exbytes" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/random" - "golang.org/x/crypto/pbkdf2" - - "maunium.net/go/mautrix/id" -) - -var ErrNoSessionsForExport = errors.New("no sessions provided for export") - -type SenderClaimedKeys struct { - Ed25519 id.Ed25519 `json:"ed25519"` -} - -type ExportedSession struct { - Algorithm id.Algorithm `json:"algorithm"` - ForwardingChains []string `json:"forwarding_curve25519_key_chain"` - RoomID id.RoomID `json:"room_id"` - SenderKey id.SenderKey `json:"sender_key"` - SenderClaimedKeys SenderClaimedKeys `json:"sender_claimed_keys"` - SessionID id.SessionID `json:"session_id"` - SessionKey string `json:"session_key"` -} - -// The default number of pbkdf2 rounds to use when exporting keys -const defaultPassphraseRounds = 100000 - -const exportPrefix = "-----BEGIN MEGOLM SESSION DATA-----\n" -const exportSuffix = "-----END MEGOLM SESSION DATA-----\n" - -// Only version 0x01 is currently specified in the spec -const exportVersion1 = 0x01 - -// The standard for wrapping base64 is 76 bytes -const exportLineLengthLimit = 76 - -// Byte count for version + salt + iv + number of rounds -const exportHeaderLength = 1 + 16 + 16 + 4 - -// SHA-256 hash length -const exportHashLength = 32 - -func computeKey(passphrase string, salt []byte, rounds int) (encryptionKey, hashKey []byte) { - key := pbkdf2.Key([]byte(passphrase), salt, rounds, 64, sha512.New) - encryptionKey = key[:32] - hashKey = key[32:] - return -} - -func makeExportIV() []byte { - iv := random.Bytes(16) - // Set bit 63 to zero - iv[7] &= 0b11111110 - return iv -} - -func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte) { - salt = random.Bytes(16) - encryptionKey, hashKey = computeKey(passphrase, salt, defaultPassphraseRounds) - iv = makeExportIV() - return -} - -func exportSessions(sessions []*InboundGroupSession) ([]*ExportedSession, error) { - export := make([]*ExportedSession, len(sessions)) - var err error - for i, session := range sessions { - export[i], err = session.export() - if err != nil { - return nil, fmt.Errorf("failed to export session: %w", err) - } - } - return export, nil -} - -func exportSessionsJSON(sessions []*InboundGroupSession) ([]byte, error) { - exportedSessions, err := exportSessions(sessions) - if err != nil { - return nil, err - } - return json.Marshal(exportedSessions) -} - -func formatKeyExportData(data []byte) []byte { - encodedLen := base64.StdEncoding.EncodedLen(len(data)) - outputLength := len(exportPrefix) + - encodedLen + int(math.Ceil(float64(encodedLen)/exportLineLengthLimit)) + - len(exportSuffix) - output := make([]byte, 0, outputLength) - outputWriter := (*exbytes.Writer)(&output) - base64Writer := base64.NewEncoder(base64.StdEncoding, outputWriter) - lineByteCount := base64.StdEncoding.DecodedLen(exportLineLengthLimit) - exerrors.Must(outputWriter.WriteString(exportPrefix)) - for i := 0; i < len(data); i += lineByteCount { - exerrors.Must(base64Writer.Write(data[i:min(i+lineByteCount, len(data))])) - if i+lineByteCount >= len(data) { - exerrors.PanicIfNotNil(base64Writer.Close()) - } - exerrors.PanicIfNotNil(outputWriter.WriteByte('\n')) - } - exerrors.Must(outputWriter.WriteString(exportSuffix)) - if len(output) != outputLength { - panic(fmt.Errorf("unexpected length %d / %d", len(output), outputLength)) - } - return output -} - -func ExportKeysIter(passphrase string, sessions dbutil.RowIter[*InboundGroupSession]) ([]byte, error) { - buf := bytes.NewBuffer(make([]byte, 0, 50*1024)) - enc := json.NewEncoder(buf) - buf.WriteByte('[') - err := sessions.Iter(func(session *InboundGroupSession) (bool, error) { - exported, err := session.export() - if err != nil { - return false, err - } - err = enc.Encode(exported) - if err != nil { - return false, err - } - buf.WriteByte(',') - return true, nil - }) - if err != nil { - return nil, err - } - output := buf.Bytes() - if len(output) == 1 { - return nil, ErrNoSessionsForExport - } - output[len(output)-1] = ']' // Replace the last comma with a closing bracket - return EncryptKeyExport(passphrase, output) -} - -// ExportKeys exports the given Megolm sessions with the format specified in the Matrix spec. -// See https://spec.matrix.org/v1.2/client-server-api/#key-exports -func ExportKeys(passphrase string, sessions []*InboundGroupSession) ([]byte, error) { - if len(sessions) == 0 { - return nil, ErrNoSessionsForExport - } - // Export all the given sessions and put them in JSON - unencryptedData, err := exportSessionsJSON(sessions) - if err != nil { - return nil, err - } - return EncryptKeyExport(passphrase, unencryptedData) -} - -func EncryptKeyExport(passphrase string, unencryptedData json.RawMessage) ([]byte, error) { - // Make all the keys necessary for exporting - encryptionKey, hashKey, salt, iv := makeExportKeys(passphrase) - - // The export data consists of: - // 1 byte of export format version - // 16 bytes of salt - // 16 bytes of IV (initialization vector) - // 4 bytes of the number of rounds - // the encrypted export data - // 32 bytes of the hash of all the data above - - exportData := make([]byte, exportHeaderLength+len(unencryptedData)+exportHashLength) - dataWithoutHashLength := len(exportData) - exportHashLength - - // Create the header for the export data - exportData[0] = exportVersion1 - copy(exportData[1:17], salt) - copy(exportData[17:33], iv) - binary.BigEndian.PutUint32(exportData[33:37], defaultPassphraseRounds) - - // Encrypt data with AES-256-CTR - block, _ := aes.NewCipher(encryptionKey) - cipher.NewCTR(block, iv).XORKeyStream(exportData[exportHeaderLength:dataWithoutHashLength], unencryptedData) - - // Hash all the data with HMAC-SHA256 and put it at the end - mac := hmac.New(sha256.New, hashKey) - mac.Write(exportData[:dataWithoutHashLength]) - mac.Sum(exportData[:dataWithoutHashLength]) - - // Format the export (prefix, base64'd exportData, suffix) and return - return formatKeyExportData(exportData), nil -} diff --git a/mautrix-patched/crypto/keyexport_test.go b/mautrix-patched/crypto/keyexport_test.go deleted file mode 100644 index fd6f105d..00000000 --- a/mautrix-patched/crypto/keyexport_test.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exfmt" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/olm" -) - -func TestExportKeys(t *testing.T) { - acc := crypto.NewOlmAccount() - sess := exerrors.Must(crypto.NewInboundGroupSession( - acc.IdentityKey(), - acc.SigningKey(), - "!room:example.com", - exerrors.Must(olm.NewOutboundGroupSession()).Key(), - 7*exfmt.Day, - 100, - false, - )) - data, err := crypto.ExportKeys("meow", []*crypto.InboundGroupSession{sess}) - assert.NoError(t, err) - assert.Len(t, data, 893) -} diff --git a/mautrix-patched/crypto/keyimport.go b/mautrix-patched/crypto/keyimport.go deleted file mode 100644 index 3ffc74a5..00000000 --- a/mautrix-patched/crypto/keyimport.go +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "time" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -var ( - ErrMissingExportPrefix = errors.New("invalid Matrix key export: missing prefix") - ErrMissingExportSuffix = errors.New("invalid Matrix key export: missing suffix") - ErrUnsupportedExportVersion = errors.New("unsupported Matrix key export format version") - ErrMismatchingExportHash = errors.New("mismatching hash; incorrect passphrase?") - ErrInvalidExportedAlgorithm = errors.New("session has unknown algorithm") - ErrMismatchingExportedSessionID = errors.New("imported session has different ID than expected") -) - -var exportPrefixBytes, exportSuffixBytes = []byte(exportPrefix), []byte(exportSuffix) - -func decodeKeyExport(data []byte) ([]byte, error) { - // Fix some types of corruption in the key export file before checking anything - if bytes.IndexByte(data, '\r') != -1 { - data = bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'}) - } - // If the valid prefix and suffix aren't there, it's probably not a Matrix key export - if !bytes.HasPrefix(data, exportPrefixBytes) { - return nil, ErrMissingExportPrefix - } else if !bytes.HasSuffix(data, exportSuffixBytes) { - return nil, ErrMissingExportSuffix - } - // Remove the prefix and suffix, we don't care about them anymore - data = data[len(exportPrefix) : len(data)-len(exportSuffix)] - - // Allocate space for the decoded data. Ignore newlines when counting the length - exportData := make([]byte, base64.StdEncoding.DecodedLen(len(data)-bytes.Count(data, []byte{'\n'}))) - n, err := base64.StdEncoding.Decode(exportData, data) - if err != nil { - return nil, err - } - - return exportData[:n], nil -} - -func decryptKeyExport(passphrase string, exportData []byte) ([]ExportedSession, error) { - if exportData[0] != exportVersion1 { - return nil, ErrUnsupportedExportVersion - } - - // Get all the different parts of the export - salt := exportData[1:17] - iv := exportData[17:33] - passphraseRounds := binary.BigEndian.Uint32(exportData[33:37]) - dataWithoutHashLength := len(exportData) - exportHashLength - encryptedData := exportData[exportHeaderLength:dataWithoutHashLength] - hash := exportData[dataWithoutHashLength:] - - // Compute the encryption and hash keys from the passphrase and salt - encryptionKey, hashKey := computeKey(passphrase, salt, int(passphraseRounds)) - - // Compute and verify the hash. If it doesn't match, the passphrase is probably wrong - mac := hmac.New(sha256.New, hashKey) - mac.Write(exportData[:dataWithoutHashLength]) - if !bytes.Equal(hash, mac.Sum(nil)) { - return nil, ErrMismatchingExportHash - } - - // Decrypt the export - block, _ := aes.NewCipher(encryptionKey) - unencryptedData := make([]byte, len(exportData)-exportHashLength-exportHeaderLength) - cipher.NewCTR(block, iv).XORKeyStream(unencryptedData, encryptedData) - - // Parse the decrypted JSON - var sessionsJSON []ExportedSession - err := json.Unmarshal(unencryptedData, &sessionsJSON) - if err != nil { - return nil, fmt.Errorf("invalid export json: %w", err) - } - return sessionsJSON, nil -} - -func (mach *OlmMachine) importExportedRoomKey(ctx context.Context, session ExportedSession) (bool, error) { - if session.Algorithm != id.AlgorithmMegolmV1 { - return false, ErrInvalidExportedAlgorithm - } - - igsInternal, err := olm.InboundGroupSessionImport([]byte(session.SessionKey)) - if err != nil { - return false, fmt.Errorf("failed to import session: %w", err) - } else if igsInternal.ID() != session.SessionID { - return false, ErrMismatchingExportedSessionID - } - igs := &InboundGroupSession{ - Internal: igsInternal, - SigningKey: session.SenderClaimedKeys.Ed25519, - SenderKey: session.SenderKey, - RoomID: session.RoomID, - ForwardingChains: session.ForwardingChains, - KeySource: id.KeySourceImport, - ReceivedAt: time.Now().UTC(), - } - existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) - firstKnownIndex := igs.Internal.FirstKnownIndex() - if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= firstKnownIndex { - // We already have an equivalent or better session in the store, so don't override it, - // but do notify the session received callback just in case. - mach.MarkSessionReceived(ctx, session.RoomID, igs.ID(), existingIGS.Internal.FirstKnownIndex()) - return false, nil - } - err = mach.CryptoStore.PutGroupSession(ctx, igs) - if err != nil { - return false, fmt.Errorf("failed to store imported session: %w", err) - } - mach.MarkSessionReceived(ctx, session.RoomID, igs.ID(), firstKnownIndex) - return true, nil -} - -// ImportKeys imports data that was exported with the format specified in the Matrix spec. -// See https://spec.matrix.org/v1.2/client-server-api/#key-exports -func (mach *OlmMachine) ImportKeys(ctx context.Context, passphrase string, data []byte) (int, int, error) { - exportData, err := decodeKeyExport(data) - if err != nil { - return 0, 0, err - } - sessions, err := decryptKeyExport(passphrase, exportData) - if err != nil { - return 0, 0, err - } - - count := 0 - for _, session := range sessions { - log := mach.Log.With(). - Str("room_id", session.RoomID.String()). - Str("session_id", session.SessionID.String()). - Logger() - imported, err := mach.importExportedRoomKey(ctx, session) - if err != nil { - if ctx.Err() != nil { - return count, len(sessions), ctx.Err() - } - log.Error().Err(err).Msg("Failed to import Megolm session from file") - } else if imported { - log.Debug().Msg("Imported Megolm session from file") - count++ - } else { - log.Debug().Msg("Skipped Megolm session which is already in the store") - } - } - return count, len(sessions), nil -} diff --git a/mautrix-patched/crypto/keysharing.go b/mautrix-patched/crypto/keysharing.go deleted file mode 100644 index f5a8ccc5..00000000 --- a/mautrix-patched/crypto/keysharing.go +++ /dev/null @@ -1,423 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "errors" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" -) - -type KeyShareRejection struct { - Code event.RoomKeyWithheldCode - Reason string -} - -var ( - // Reject a key request without responding - KeyShareRejectNoResponse = KeyShareRejection{} - - KeyShareRejectBlacklisted = KeyShareRejection{event.RoomKeyWithheldBlacklisted, "You have been blacklisted by this device"} - KeyShareRejectUnverified = KeyShareRejection{event.RoomKeyWithheldUnverified, "This device does not share keys to unverified devices"} - KeyShareRejectOtherUser = KeyShareRejection{event.RoomKeyWithheldUnauthorized, "This device does not share keys to other users"} - KeyShareRejectNotRecipient = KeyShareRejection{event.RoomKeyWithheldUnauthorized, "You were not in the original recipient list for that session, or that session didn't originate from this device"} - KeyShareRejectUnavailable = KeyShareRejection{event.RoomKeyWithheldUnavailable, "Requested session ID not found on this device"} - KeyShareRejectInternalError = KeyShareRejection{event.RoomKeyWithheldUnavailable, "An internal error occurred while trying to share the requested session"} -) - -// RequestRoomKey sends a key request for a room to the current user's devices. If the context is cancelled, then so is the key request. -// Returns a bool channel that will get notified either when the key is received or the request is cancelled. -// -// Deprecated: this only supports a single key request target, so the whole automatic cancelling feature isn't very useful. -func (mach *OlmMachine) RequestRoomKey(ctx context.Context, toUser id.UserID, toDevice id.DeviceID, - roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID) (chan bool, error) { - - requestID := mach.Client.TxnID() - keyResponseReceived := make(chan struct{}) - mach.roomKeyRequestFilled.Store(sessionID, keyResponseReceived) - - err := mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, requestID, map[id.UserID][]id.DeviceID{toUser: {toDevice}}) - if err != nil { - return nil, err - } - - resChan := make(chan bool, 1) - go func() { - select { - case <-keyResponseReceived: - // key request successful - mach.Log.Debug(). - Stringer("session_id", sessionID). - Msg("Key for session was received, cancelling other key requests") - resChan <- true - case <-ctx.Done(): - // if the context is done, key request was unsuccessful - mach.Log.Debug().Err(err). - Stringer("session_id", sessionID). - Msg("Context closed before forwarded key for session received, sending key request cancellation") - resChan <- false - } - - // send a message to all devices cancelling this key request - mach.roomKeyRequestFilled.Delete(sessionID) - - cancelEvtContent := &event.Content{ - Parsed: event.RoomKeyRequestEventContent{ - Action: event.KeyRequestActionCancel, - RequestID: requestID, - RequestingDeviceID: mach.Client.DeviceID, - }, - } - - toDeviceCancel := &mautrix.ReqSendToDevice{ - Messages: map[id.UserID]map[id.DeviceID]*event.Content{ - toUser: { - toDevice: cancelEvtContent, - }, - }, - } - - mach.Client.SendToDevice(ctx, event.ToDeviceRoomKeyRequest, toDeviceCancel) - }() - return resChan, nil -} - -// SendRoomKeyRequest sends a key request for the given key (identified by the room ID, sender key and session ID) to the given users. -// -// The request ID parameter is optional. If it's empty, a random ID will be generated. -// -// This function does not wait for the keys to arrive. You can use WaitForSession to wait for the session to -// arrive (in any way, not just as a reply to this request). There's also RequestRoomKey which waits for a response -// to the specific key request, but currently it only supports a single target device and is therefore deprecated. -// A future function may properly support multiple targets and automatically canceling the other requests when receiving -// the first response. -func (mach *OlmMachine) SendRoomKeyRequest(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, requestID string, users map[id.UserID][]id.DeviceID) error { - if len(requestID) == 0 { - requestID = mach.Client.TxnID() - } - requestEvent := &event.Content{ - Parsed: &event.RoomKeyRequestEventContent{ - Action: event.KeyRequestActionRequest, - Body: event.RequestedKeyInfo{ - Algorithm: id.AlgorithmMegolmV1, - RoomID: roomID, - SenderKey: senderKey, - SessionID: sessionID, - }, - RequestID: requestID, - RequestingDeviceID: mach.Client.DeviceID, - }, - } - - toDeviceReq := &mautrix.ReqSendToDevice{ - Messages: make(map[id.UserID]map[id.DeviceID]*event.Content, len(users)), - } - for user, devices := range users { - toDeviceReq.Messages[user] = make(map[id.DeviceID]*event.Content, len(devices)) - for _, device := range devices { - toDeviceReq.Messages[user][device] = requestEvent - } - } - _, err := mach.Client.SendToDevice(ctx, event.ToDeviceRoomKeyRequest, toDeviceReq) - return err -} - -func (mach *OlmMachine) importForwardedRoomKey(ctx context.Context, evt *DecryptedOlmEvent, content *event.ForwardedRoomKeyEventContent) bool { - log := zerolog.Ctx(ctx).With(). - Str("session_id", content.SessionID.String()). - Str("room_id", content.RoomID.String()). - Logger() - if content.Algorithm != id.AlgorithmMegolmV1 || evt.Keys.Ed25519 == "" { - log.Debug(). - Str("algorithm", string(content.Algorithm)). - Msg("Ignoring weird forwarded room key") - return false - } - - igsInternal, err := olm.InboundGroupSessionImport([]byte(content.SessionKey)) - if err != nil { - log.Error().Err(err).Msg("Failed to import inbound group session") - return false - } else if igsInternal.ID() != content.SessionID { - log.Warn(). - Str("actual_session_id", igsInternal.ID().String()). - Msg("Mismatched session ID while creating inbound group session from forward") - return false - } - config, err := mach.StateStore.GetEncryptionEvent(ctx, content.RoomID) - if err != nil { - log.Error().Err(err).Msg("Failed to get encryption event for room") - } - var maxAge time.Duration - var maxMessages int - if config != nil { - maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond - maxMessages = config.RotationPeriodMessages - } - if content.MaxAge != 0 { - maxAge = time.Duration(content.MaxAge) * time.Millisecond - } - if content.MaxMessages != 0 { - maxMessages = content.MaxMessages - } - firstKnownIndex := igsInternal.FirstKnownIndex() - if firstKnownIndex > 0 { - log.Warn().Uint32("first_known_index", firstKnownIndex).Msg("Importing partial session") - } - igs := &InboundGroupSession{ - Internal: igsInternal, - SigningKey: content.SenderClaimedKey, - SenderKey: content.SenderKey, - RoomID: content.RoomID, - ForwardingChains: append(content.ForwardingKeyChain, evt.SenderKey.String()), - id: content.SessionID, - - ReceivedAt: time.Now().UTC(), - MaxAge: maxAge.Milliseconds(), - MaxMessages: maxMessages, - IsScheduled: content.IsScheduled, - KeySource: id.KeySourceForward, - } - existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID()) - if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= igs.Internal.FirstKnownIndex() { - // We already have an equivalent or better session in the store, so don't override it. - return false - } - err = mach.CryptoStore.PutGroupSession(ctx, igs) - if err != nil { - log.Error().Err(err).Msg("Failed to store new inbound group session") - return false - } - mach.MarkSessionReceived(ctx, content.RoomID, content.SessionID, firstKnownIndex) - log.Debug().Msg("Received forwarded inbound group session") - return true -} - -func (mach *OlmMachine) rejectKeyRequest(ctx context.Context, rejection KeyShareRejection, device *id.Device, request event.RequestedKeyInfo) { - if rejection.Code == "" { - // If the rejection code is empty, it means don't share keys, but also don't tell the requester. - return - } - content := event.RoomKeyWithheldEventContent{ - RoomID: request.RoomID, - Algorithm: request.Algorithm, - SessionID: request.SessionID, - //lint:ignore SA1019 This is just echoing back the deprecated field - SenderKey: request.SenderKey, - Code: rejection.Code, - Reason: rejection.Reason, - } - err := mach.sendToOneDevice(ctx, device.UserID, device.DeviceID, event.ToDeviceRoomKeyWithheld, &content) - if err != nil { - mach.Log.Warn().Err(err). - Str("code", string(rejection.Code)). - Str("user_id", device.UserID.String()). - Str("device_id", device.DeviceID.String()). - Msg("Failed to send key share rejection") - } - err = mach.sendToOneDevice(ctx, device.UserID, device.DeviceID, event.ToDeviceOrgMatrixRoomKeyWithheld, &content) - if err != nil { - mach.Log.Warn().Err(err). - Str("code", string(rejection.Code)). - Str("user_id", device.UserID.String()). - Str("device_id", device.DeviceID.String()). - Msg("Failed to send key share rejection (legacy event type)") - } -} - -// sendToOneDevice sends a to-device event to a single device. -func (mach *OlmMachine) sendToOneDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID, eventType event.Type, content interface{}) error { - _, err := mach.Client.SendToDevice(ctx, eventType, &mautrix.ReqSendToDevice{ - Messages: map[id.UserID]map[id.DeviceID]*event.Content{ - userID: { - deviceID: { - Parsed: content, - }, - }, - }, - }) - - return err -} - -func (mach *OlmMachine) defaultAllowKeyShare(ctx context.Context, device *id.Device, evt event.RequestedKeyInfo) *KeyShareRejection { - log := mach.machOrContextLog(ctx) - if mach.Client.UserID != device.UserID { - if mach.DisableSharedGroupSessionTracking { - log.Debug().Msg("Rejecting key request from another user as recipient list tracking is disabled") - return &KeyShareRejectOtherUser - } - isShared, err := mach.CryptoStore.IsOutboundGroupSessionShared(ctx, device.UserID, device.IdentityKey, evt.SessionID) - if err != nil { - log.Err(err).Msg("Rejecting key request due to internal error when checking session sharing") - return &KeyShareRejectNoResponse - } else if !isShared { - igs, _ := mach.CryptoStore.GetGroupSession(ctx, evt.RoomID, evt.SessionID) - if igs != nil && igs.SenderKey == mach.OwnIdentity().IdentityKey { - log.Debug().Msg("Rejecting key request for unshared session") - return &KeyShareRejectNotRecipient - } - // Note: this case will also happen for redacted sessions and database errors - log.Debug().Msg("Rejecting key request for session created by another device") - return &KeyShareRejectNoResponse - } - log.Debug().Msg("Accepting key request for shared session") - return nil - } else if mach.Client.DeviceID == device.DeviceID { - log.Debug().Msg("Ignoring key request from ourselves") - return &KeyShareRejectNoResponse - } else if device.Trust == id.TrustStateBlacklisted { - log.Debug().Msg("Rejecting key request from blacklisted device") - return &KeyShareRejectBlacklisted - } else if trustState, _ := mach.ResolveTrustContext(ctx, device); trustState >= mach.ShareKeysMinTrust { - log.Debug(). - Str("min_trust", mach.SendKeysMinTrust.String()). - Str("device_trust", trustState.String()). - Msg("Accepting key request from trusted device") - return nil - } else { - log.Debug(). - Str("min_trust", mach.SendKeysMinTrust.String()). - Str("device_trust", trustState.String()). - Msg("Rejecting key request from untrusted device") - return &KeyShareRejectUnverified - } -} - -func (mach *OlmMachine) HandleRoomKeyRequest(ctx context.Context, sender id.UserID, content *event.RoomKeyRequestEventContent) { - log := zerolog.Ctx(ctx).With(). - Str("request_id", content.RequestID). - Str("device_id", content.RequestingDeviceID.String()). - Str("room_id", content.Body.RoomID.String()). - Str("session_id", content.Body.SessionID.String()). - Logger() - ctx = log.WithContext(ctx) - if content.Action != event.KeyRequestActionRequest { - return - } else if content.RequestingDeviceID == mach.Client.DeviceID && sender == mach.Client.UserID { - log.Debug().Msg("Ignoring key request from ourselves") - return - } - - log.Debug().Msg("Received key request") - - device, err := mach.GetOrFetchDevice(ctx, sender, content.RequestingDeviceID) - if err != nil { - log.Error().Err(err).Msg("Failed to fetch device that requested keys") - return - } - - rejection := mach.AllowKeyShare(ctx, device, content.Body) - if rejection != nil { - mach.rejectKeyRequest(ctx, *rejection, device, content.Body) - return - } - - igs, err := mach.CryptoStore.GetGroupSession(ctx, content.Body.RoomID, content.Body.SessionID) - if err != nil { - if errors.Is(err, ErrGroupSessionWithheld) { - log.Debug().Err(err).Msg("Requested group session not available") - if sender != mach.Client.UserID { - mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) - } - } else { - log.Error().Err(err).Msg("Failed to get group session to forward") - mach.rejectKeyRequest(ctx, KeyShareRejectInternalError, device, content.Body) - } - return - } else if igs == nil { - log.Error().Msg("Didn't find group session to forward") - if sender != mach.Client.UserID { - mach.rejectKeyRequest(ctx, KeyShareRejectUnavailable, device, content.Body) - } - return - } - if internalID := igs.ID(); internalID != content.Body.SessionID { - // Should this be an error? - log = log.With().Stringer("unexpected_session_id", internalID).Logger() - } - - firstKnownIndex := igs.Internal.FirstKnownIndex() - log = log.With().Uint32("first_known_index", firstKnownIndex).Logger() - exportedKey, err := igs.Internal.Export(firstKnownIndex) - if err != nil { - log.Error().Err(err).Msg("Failed to export group session to forward") - mach.rejectKeyRequest(ctx, KeyShareRejectInternalError, device, content.Body) - return - } - - forwardedRoomKey := event.Content{ - Parsed: &event.ForwardedRoomKeyEventContent{ - RoomKeyEventContent: event.RoomKeyEventContent{ - Algorithm: id.AlgorithmMegolmV1, - RoomID: igs.RoomID, - SessionID: igs.ID(), - SessionKey: string(exportedKey), - }, - SenderKey: igs.SenderKey, - ForwardingKeyChain: igs.ForwardingChains, - SenderClaimedKey: igs.SigningKey, - }, - } - - if err = mach.SendEncryptedToDevice(ctx, device, event.ToDeviceForwardedRoomKey, forwardedRoomKey); err != nil { - log.Error().Err(err).Msg("Failed to encrypt and send group session") - } else { - log.Debug().Msg("Successfully sent forwarded group session") - } -} - -func (mach *OlmMachine) HandleBeeperRoomKeyAck(ctx context.Context, sender id.UserID, content *event.BeeperRoomKeyAckEventContent) { - // Room key acks are only used on Beeper. The server will send an ack when the user uploads a key to key backup. - // No special authentication is needed. The server is single-tenant, so DoS isn't a concern. - // On any other servers, don't do anything. - if !mach.DeleteOutboundKeysOnAck { - return - } - log := mach.machOrContextLog(ctx).With(). - Str("room_id", content.RoomID.String()). - Str("session_id", content.SessionID.String()). - Int("first_message_index", content.FirstMessageIndex). - Logger() - - sess, err := mach.CryptoStore.GetGroupSession(ctx, content.RoomID, content.SessionID) - if err != nil { - if errors.Is(err, ErrGroupSessionWithheld) { - log.Debug().Err(err).Msg("Acked group session was already redacted") - } else { - log.Err(err).Msg("Failed to get group session to check if it should be redacted") - } - return - } else if sess == nil { - log.Warn().Msg("Got key backup ack for unknown session") - return - } - log = log.With(). - Str("sender_key", sess.SenderKey.String()). - Str("own_identity", mach.OwnIdentity().IdentityKey.String()). - Logger() - - isInbound := sess.SenderKey == mach.OwnIdentity().IdentityKey - if isInbound && content.FirstMessageIndex == 0 { - log.Debug().Msg("Redacting inbound copy of outbound group session after ack") - err = mach.CryptoStore.RedactGroupSession(ctx, content.RoomID, content.SessionID, "outbound session acked") - if err != nil { - log.Err(err).Msg("Failed to redact group session") - } - } else { - log.Debug().Bool("inbound", isInbound).Msg("Received room key ack") - } -} diff --git a/mautrix-patched/crypto/libolm/account.go b/mautrix-patched/crypto/libolm/account.go deleted file mode 100644 index 0350f083..00000000 --- a/mautrix-patched/crypto/libolm/account.go +++ /dev/null @@ -1,419 +0,0 @@ -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -import "C" - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "runtime" - "unsafe" - - "github.com/tidwall/gjson" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -// Account stores a device account for end to end encrypted messaging. -type Account struct { - int *C.OlmAccount - mem []byte -} - -// Ensure that [Account] implements [olm.Account]. -var _ olm.Account = (*Account)(nil) - -// AccountFromPickled loads an Account from a pickled base64 string. Decrypts -// the Account using the supplied key. Returns error on failure. If the key -// doesn't match the one used to encrypt the Account then the error will be -// "BAD_ACCOUNT_KEY". If the base64 couldn't be decoded then the error will be -// "INVALID_BASE64". -func AccountFromPickled(pickled, key []byte) (*Account, error) { - if len(pickled) == 0 { - return nil, olm.ErrEmptyInput - } - a := NewBlankAccount() - return a, a.Unpickle(pickled, key) -} - -func NewBlankAccount() *Account { - memory := make([]byte, accountSize()) - return &Account{ - int: C.olm_account(unsafe.Pointer(unsafe.SliceData(memory))), - mem: memory, - } -} - -// NewAccount creates a new [Account]. -func NewAccount() (*Account, error) { - a := NewBlankAccount() - random := make([]byte, a.createRandomLen()+1) - _, err := rand.Read(random) - if err != nil { - panic(olm.ErrNotEnoughGoRandom) - } - ret := C.olm_create_account( - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(random)), - C.size_t(len(random))) - runtime.KeepAlive(random) - if ret == errorVal() { - return nil, a.lastError() - } else { - return a, nil - } -} - -// accountSize returns the size of an account object in bytes. -func accountSize() uint { - return uint(C.olm_account_size()) -} - -// lastError returns an error describing the most recent error to happen to an -// account. -func (a *Account) lastError() error { - return convertError(C.GoString(C.olm_account_last_error((*C.OlmAccount)(a.int)))) -} - -// Clear clears the memory used to back this Account. -func (a *Account) Clear() error { - r := C.olm_clear_account((*C.OlmAccount)(a.int)) - if r == errorVal() { - return a.lastError() - } else { - return nil - } -} - -// pickleLen returns the number of bytes needed to store an Account. -func (a *Account) pickleLen() uint { - return uint(C.olm_pickle_account_length((*C.OlmAccount)(a.int))) -} - -// createRandomLen returns the number of random bytes needed to create an -// Account. -func (a *Account) createRandomLen() uint { - return uint(C.olm_create_account_random_length((*C.OlmAccount)(a.int))) -} - -// identityKeysLen returns the size of the output buffer needed to hold the -// identity keys. -func (a *Account) identityKeysLen() uint { - return uint(C.olm_account_identity_keys_length((*C.OlmAccount)(a.int))) -} - -// signatureLen returns the length of an ed25519 signature encoded as base64. -func (a *Account) signatureLen() uint { - return uint(C.olm_account_signature_length((*C.OlmAccount)(a.int))) -} - -// oneTimeKeysLen returns the size of the output buffer needed to hold the one -// time keys. -func (a *Account) oneTimeKeysLen() uint { - return uint(C.olm_account_one_time_keys_length((*C.OlmAccount)(a.int))) -} - -// genOneTimeKeysRandomLen returns the number of random bytes needed to -// generate a given number of new one time keys. -func (a *Account) genOneTimeKeysRandomLen(num uint) uint { - return uint(C.olm_account_generate_one_time_keys_random_length( - (*C.OlmAccount)(a.int), - C.size_t(num))) -} - -// Pickle returns an Account as a base64 string. Encrypts the Account using the -// supplied key. -func (a *Account) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - pickled := make([]byte, a.pickleLen()) - r := C.olm_pickle_account( - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled))) - if r == errorVal() { - return nil, a.lastError() - } - return pickled[:r], nil -} - -func (a *Account) Unpickle(pickled, key []byte) error { - if len(key) == 0 { - return olm.ErrNoKeyProvided - } - r := C.olm_unpickle_account( - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled))) - if r == errorVal() { - return a.lastError() - } - return nil -} - -// Deprecated -func (a *Account) GobEncode() ([]byte, error) { - pickled, err := a.Pickle(pickleKey) - if err != nil { - return nil, err - } - length := base64.RawStdEncoding.DecodedLen(len(pickled)) - rawPickled := make([]byte, length) - _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) - return rawPickled, err -} - -// Deprecated -func (a *Account) GobDecode(rawPickled []byte) error { - if a.int == nil { - *a = *NewBlankAccount() - } - length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) - pickled := make([]byte, length) - base64.RawStdEncoding.Encode(pickled, rawPickled) - return a.Unpickle(pickled, pickleKey) -} - -// Deprecated -func (a *Account) MarshalJSON() ([]byte, error) { - pickled, err := a.Pickle(pickleKey) - if err != nil { - return nil, err - } - quotes := make([]byte, len(pickled)+2) - quotes[0] = '"' - quotes[len(quotes)-1] = '"' - copy(quotes[1:len(quotes)-1], pickled) - return quotes, nil -} - -// Deprecated -func (a *Account) UnmarshalJSON(data []byte) error { - if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.ErrInputNotJSONString - } - if a.int == nil { - *a = *NewBlankAccount() - } - return a.Unpickle(data[1:len(data)-1], pickleKey) -} - -// IdentityKeysJSON returns the public parts of the identity keys for the Account. -func (a *Account) IdentityKeysJSON() ([]byte, error) { - identityKeys := make([]byte, a.identityKeysLen()) - r := C.olm_account_identity_keys( - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(identityKeys)), - C.size_t(len(identityKeys))) - if r == errorVal() { - return nil, a.lastError() - } else { - return identityKeys, nil - } -} - -// IdentityKeys returns the public parts of the Ed25519 and Curve25519 identity -// keys for the Account. -func (a *Account) IdentityKeys() (id.Ed25519, id.Curve25519, error) { - identityKeysJSON, err := a.IdentityKeysJSON() - if err != nil { - return "", "", err - } - results := gjson.GetManyBytes(identityKeysJSON, "ed25519", "curve25519") - return id.Ed25519(results[0].Str), id.Curve25519(results[1].Str), nil -} - -// Sign returns the signature of a message using the ed25519 key for this -// Account. -func (a *Account) Sign(message []byte) ([]byte, error) { - if len(message) == 0 { - panic(olm.ErrEmptyInput) - } - signature := make([]byte, a.signatureLen()) - r := C.olm_account_sign( - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(message)), - C.size_t(len(message)), - unsafe.Pointer(unsafe.SliceData(signature)), - C.size_t(len(signature))) - runtime.KeepAlive(message) - if r == errorVal() { - panic(a.lastError()) - } - return signature, nil -} - -// OneTimeKeys returns the public parts of the unpublished one time keys for -// the Account. -// -// The returned data is a struct with the single value "Curve25519", which is -// itself an object mapping key id to base64-encoded Curve25519 key. For -// example: -// -// { -// Curve25519: { -// "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo", -// "AAAAAB": "LRvjo46L1X2vx69sS9QNFD29HWulxrmW11Up5AfAjgU" -// } -// } -func (a *Account) OneTimeKeys() (map[string]id.Curve25519, error) { - oneTimeKeysJSON := make([]byte, a.oneTimeKeysLen()) - r := C.olm_account_one_time_keys( - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(oneTimeKeysJSON)), - C.size_t(len(oneTimeKeysJSON)), - ) - if r == errorVal() { - return nil, a.lastError() - } - var oneTimeKeys struct { - Curve25519 map[string]id.Curve25519 `json:"curve25519"` - } - return oneTimeKeys.Curve25519, json.Unmarshal(oneTimeKeysJSON, &oneTimeKeys) -} - -// MarkKeysAsPublished marks the current set of one time keys as being -// published. -func (a *Account) MarkKeysAsPublished() { - C.olm_account_mark_keys_as_published((*C.OlmAccount)(a.int)) -} - -// MaxNumberOfOneTimeKeys returns the largest number of one time keys this -// Account can store. -func (a *Account) MaxNumberOfOneTimeKeys() uint { - return uint(C.olm_account_max_number_of_one_time_keys((*C.OlmAccount)(a.int))) -} - -// GenOneTimeKeys generates a number of new one time keys. If the total number -// of keys stored by this Account exceeds MaxNumberOfOneTimeKeys then the old -// keys are discarded. -func (a *Account) GenOneTimeKeys(num uint) error { - random := make([]byte, a.genOneTimeKeysRandomLen(num)+1) - _, err := rand.Read(random) - if err != nil { - return olm.ErrNotEnoughGoRandom - } - r := C.olm_account_generate_one_time_keys( - (*C.OlmAccount)(a.int), - C.size_t(num), - unsafe.Pointer(unsafe.SliceData(random)), - C.size_t(len(random)), - ) - runtime.KeepAlive(random) - if r == errorVal() { - return a.lastError() - } - return nil -} - -// NewOutboundSession creates a new out-bound session for sending messages to a -// given curve25519 identityKey and oneTimeKey. Returns error on failure. If the -// keys couldn't be decoded as base64 then the error will be "INVALID_BASE64" -func (a *Account) NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (olm.Session, error) { - if len(theirIdentityKey) == 0 || len(theirOneTimeKey) == 0 { - return nil, olm.ErrEmptyInput - } - s := NewBlankSession() - random := make([]byte, s.createOutboundRandomLen()+1) - _, err := rand.Read(random) - if err != nil { - panic(olm.ErrNotEnoughGoRandom) - } - theirIdentityKeyCopy := []byte(theirIdentityKey) - theirOneTimeKeyCopy := []byte(theirOneTimeKey) - r := C.olm_create_outbound_session( - (*C.OlmSession)(s.int), - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), - C.size_t(len(theirIdentityKeyCopy)), - unsafe.Pointer(unsafe.SliceData(theirOneTimeKeyCopy)), - C.size_t(len(theirOneTimeKeyCopy)), - unsafe.Pointer(unsafe.SliceData(random)), - C.size_t(len(random)), - ) - runtime.KeepAlive(random) - runtime.KeepAlive(theirIdentityKeyCopy) - runtime.KeepAlive(theirOneTimeKeyCopy) - if r == errorVal() { - return nil, s.lastError() - } - return s, nil -} - -// NewInboundSession creates a new in-bound session for sending/receiving -// messages from an incoming PRE_KEY message. Returns error on failure. If -// the base64 couldn't be decoded then the error will be "INVALID_BASE64". If -// the message was for an unsupported protocol version then the error will be -// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the -// error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one -// time key then the error will be "BAD_MESSAGE_KEY_ID". -func (a *Account) NewInboundSession(oneTimeKeyMsg string) (olm.Session, error) { - if len(oneTimeKeyMsg) == 0 { - return nil, olm.ErrEmptyInput - } - s := NewBlankSession() - oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) - r := C.olm_create_inbound_session( - (*C.OlmSession)(s.int), - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), - C.size_t(len(oneTimeKeyMsgCopy)), - ) - runtime.KeepAlive(oneTimeKeyMsgCopy) - if r == errorVal() { - return nil, s.lastError() - } - return s, nil -} - -// NewInboundSessionFrom creates a new in-bound session for sending/receiving -// messages from an incoming PRE_KEY message. Returns error on failure. If -// the base64 couldn't be decoded then the error will be "INVALID_BASE64". If -// the message was for an unsupported protocol version then the error will be -// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the -// error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one -// time key then the error will be "BAD_MESSAGE_KEY_ID". -func (a *Account) NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (olm.Session, error) { - if theirIdentityKey == nil || len(oneTimeKeyMsg) == 0 { - return nil, olm.ErrEmptyInput - } - theirIdentityKeyCopy := []byte(*theirIdentityKey) - oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) - s := NewBlankSession() - r := C.olm_create_inbound_session_from( - (*C.OlmSession)(s.int), - (*C.OlmAccount)(a.int), - unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), - C.size_t(len(theirIdentityKeyCopy)), - unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), - C.size_t(len(oneTimeKeyMsgCopy)), - ) - runtime.KeepAlive(theirIdentityKeyCopy) - runtime.KeepAlive(oneTimeKeyMsgCopy) - if r == errorVal() { - return nil, s.lastError() - } - return s, nil -} - -// RemoveOneTimeKeys removes the one time keys that the session used from the -// Account. Returns error on failure. If the Account doesn't have any -// matching one time keys then the error will be "BAD_MESSAGE_KEY_ID". -func (a *Account) RemoveOneTimeKeys(s olm.Session) error { - r := C.olm_remove_one_time_keys( - (*C.OlmAccount)(a.int), - (*C.OlmSession)(s.(*Session).int), - ) - if r == errorVal() { - return a.lastError() - } - return nil -} diff --git a/mautrix-patched/crypto/libolm/error.go b/mautrix-patched/crypto/libolm/error.go deleted file mode 100644 index 6fb5512b..00000000 --- a/mautrix-patched/crypto/libolm/error.go +++ /dev/null @@ -1,37 +0,0 @@ -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -import "C" - -import ( - "fmt" - - "maunium.net/go/mautrix/crypto/olm" -) - -var errorMap = map[string]error{ - "NOT_ENOUGH_RANDOM": olm.ErrLibolmNotEnoughRandom, - "OUTPUT_BUFFER_TOO_SMALL": olm.ErrLibolmOutputBufferTooSmall, - "BAD_MESSAGE_VERSION": olm.ErrWrongProtocolVersion, - "BAD_MESSAGE_FORMAT": olm.ErrBadMessageFormat, - "BAD_MESSAGE_MAC": olm.ErrBadMAC, - "BAD_MESSAGE_KEY_ID": olm.ErrBadMessageKeyID, - "INVALID_BASE64": olm.ErrLibolmInvalidBase64, - "BAD_ACCOUNT_KEY": olm.ErrLibolmBadAccountKey, - "UNKNOWN_PICKLE_VERSION": olm.ErrUnknownOlmPickleVersion, - "CORRUPTED_PICKLE": olm.ErrLibolmCorruptedPickle, - "BAD_SESSION_KEY": olm.ErrLibolmBadSessionKey, - "UNKNOWN_MESSAGE_INDEX": olm.ErrUnknownMessageIndex, - "BAD_LEGACY_ACCOUNT_PICKLE": olm.ErrLibolmBadLegacyAccountPickle, - "BAD_SIGNATURE": olm.ErrBadSignature, - "INPUT_BUFFER_TOO_SMALL": olm.ErrInputToSmall, -} - -func convertError(errCode string) error { - err, ok := errorMap[errCode] - if ok { - return err - } - return fmt.Errorf("unknown error: %s", errCode) -} diff --git a/mautrix-patched/crypto/libolm/inboundgroupsession.go b/mautrix-patched/crypto/libolm/inboundgroupsession.go deleted file mode 100644 index 8815ac32..00000000 --- a/mautrix-patched/crypto/libolm/inboundgroupsession.go +++ /dev/null @@ -1,327 +0,0 @@ -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -import "C" - -import ( - "bytes" - "encoding/base64" - "runtime" - "unsafe" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -// InboundGroupSession stores an inbound encrypted messaging session for a -// group. -type InboundGroupSession struct { - int *C.OlmInboundGroupSession - mem []byte -} - -// Ensure that [InboundGroupSession] implements [olm.InboundGroupSession]. -var _ olm.InboundGroupSession = (*InboundGroupSession)(nil) - -// InboundGroupSessionFromPickled loads an InboundGroupSession from a pickled -// base64 string. Decrypts the InboundGroupSession using the supplied key. -// Returns error on failure. If the key doesn't match the one used to encrypt -// the InboundGroupSession then the error will be "BAD_SESSION_KEY". If the -// base64 couldn't be decoded then the error will be "INVALID_BASE64". -func InboundGroupSessionFromPickled(pickled, key []byte) (*InboundGroupSession, error) { - if len(pickled) == 0 { - return nil, olm.ErrEmptyInput - } - lenKey := len(key) - if lenKey == 0 { - key = []byte(" ") - } - s := NewBlankInboundGroupSession() - return s, s.Unpickle(pickled, key) -} - -// NewInboundGroupSession creates a new inbound group session from a key -// exported from OutboundGroupSession.Key(). Returns error on failure. -// If the sessionKey is not valid base64 the error will be -// "OLM_INVALID_BASE64". If the session_key is invalid the error will be -// "OLM_BAD_SESSION_KEY". -func NewInboundGroupSession(sessionKey []byte) (*InboundGroupSession, error) { - if len(sessionKey) == 0 { - return nil, olm.ErrEmptyInput - } - s := NewBlankInboundGroupSession() - r := C.olm_init_inbound_group_session( - (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), - C.size_t(len(sessionKey)), - ) - runtime.KeepAlive(sessionKey) - if r == errorVal() { - return nil, s.lastError() - } - return s, nil -} - -// InboundGroupSessionImport imports an inbound group session from a previous -// export. Returns error on failure. If the sessionKey is not valid base64 -// the error will be "OLM_INVALID_BASE64". If the session_key is invalid the -// error will be "OLM_BAD_SESSION_KEY". -func InboundGroupSessionImport(sessionKey []byte) (*InboundGroupSession, error) { - if len(sessionKey) == 0 { - return nil, olm.ErrEmptyInput - } - s := NewBlankInboundGroupSession() - r := C.olm_import_inbound_group_session( - (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), - C.size_t(len(sessionKey)), - ) - runtime.KeepAlive(sessionKey) - if r == errorVal() { - return nil, s.lastError() - } - return s, nil -} - -// inboundGroupSessionSize is the size of an inbound group session object in -// bytes. -func inboundGroupSessionSize() uint { - return uint(C.olm_inbound_group_session_size()) -} - -// newInboundGroupSession initialises an empty InboundGroupSession. -func NewBlankInboundGroupSession() *InboundGroupSession { - memory := make([]byte, inboundGroupSessionSize()) - return &InboundGroupSession{ - int: C.olm_inbound_group_session(unsafe.Pointer(unsafe.SliceData(memory))), - mem: memory, - } -} - -// lastError returns an error describing the most recent error to happen to an -// inbound group session. -func (s *InboundGroupSession) lastError() error { - return convertError(C.GoString(C.olm_inbound_group_session_last_error((*C.OlmInboundGroupSession)(s.int)))) -} - -// Clear clears the memory used to back this InboundGroupSession. -func (s *InboundGroupSession) Clear() error { - r := C.olm_clear_inbound_group_session((*C.OlmInboundGroupSession)(s.int)) - if r == errorVal() { - return s.lastError() - } - return nil -} - -// pickleLen returns the number of bytes needed to store an inbound group -// session. -func (s *InboundGroupSession) pickleLen() uint { - return uint(C.olm_pickle_inbound_group_session_length((*C.OlmInboundGroupSession)(s.int))) -} - -// Pickle returns an InboundGroupSession as a base64 string. Encrypts the -// InboundGroupSession using the supplied key. -func (s *InboundGroupSession) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - pickled := make([]byte, s.pickleLen()) - r := C.olm_pickle_inbound_group_session( - (*C.OlmInboundGroupSession)(s.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled)), - ) - runtime.KeepAlive(key) - if r == errorVal() { - return nil, s.lastError() - } - return pickled[:r], nil -} - -func (s *InboundGroupSession) Unpickle(pickled, key []byte) error { - if len(key) == 0 { - return olm.ErrNoKeyProvided - } else if len(pickled) == 0 { - return olm.ErrEmptyInput - } - r := C.olm_unpickle_inbound_group_session( - (*C.OlmInboundGroupSession)(s.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled)), - ) - runtime.KeepAlive(key) - if r == errorVal() { - return s.lastError() - } - return nil -} - -// Deprecated -func (s *InboundGroupSession) GobEncode() ([]byte, error) { - pickled, err := s.Pickle(pickleKey) - if err != nil { - return nil, err - } - length := base64.RawStdEncoding.DecodedLen(len(pickled)) - rawPickled := make([]byte, length) - _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) - return rawPickled, err -} - -// Deprecated -func (s *InboundGroupSession) GobDecode(rawPickled []byte) error { - if s == nil || s.int == nil { - *s = *NewBlankInboundGroupSession() - } - length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) - pickled := make([]byte, length) - base64.RawStdEncoding.Encode(pickled, rawPickled) - return s.Unpickle(pickled, pickleKey) -} - -// Deprecated -func (s *InboundGroupSession) MarshalJSON() ([]byte, error) { - pickled, err := s.Pickle(pickleKey) - if err != nil { - return nil, err - } - quotes := make([]byte, len(pickled)+2) - quotes[0] = '"' - quotes[len(quotes)-1] = '"' - copy(quotes[1:len(quotes)-1], pickled) - return quotes, nil -} - -// Deprecated -func (s *InboundGroupSession) UnmarshalJSON(data []byte) error { - if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.ErrInputNotJSONString - } - if s == nil || s.int == nil { - *s = *NewBlankInboundGroupSession() - } - return s.Unpickle(data[1:len(data)-1], pickleKey) -} - -// decryptMaxPlaintextLen returns the maximum number of bytes of plain-text a -// given message could decode to. The actual size could be different due to -// padding. Returns error on failure. If the message base64 couldn't be -// decoded then the error will be "INVALID_BASE64". If the message is for an -// unsupported version of the protocol then the error will be -// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error -// will be "BAD_MESSAGE_FORMAT". -func (s *InboundGroupSession) decryptMaxPlaintextLen(message []byte) (uint, error) { - if len(message) == 0 { - return 0, olm.ErrEmptyInput - } - // olm_group_decrypt_max_plaintext_length destroys the input, so we have to clone it - messageCopy := bytes.Clone(message) - r := C.olm_group_decrypt_max_plaintext_length( - (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(messageCopy))), - C.size_t(len(messageCopy)), - ) - runtime.KeepAlive(messageCopy) - if r == errorVal() { - return 0, s.lastError() - } - return uint(r), nil -} - -// Decrypt decrypts a message using the InboundGroupSession. Returns the the -// plain-text and message index on success. Returns error on failure. If the -// base64 couldn't be decoded then the error will be "INVALID_BASE64". If the -// message is for an unsupported version of the protocol then the error will be -// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error -// will be BAD_MESSAGE_FORMAT". If the MAC on the message was invalid then the -// error will be "BAD_MESSAGE_MAC". If we do not have a session key -// corresponding to the message's index (ie, it was sent before the session key -// was shared with us) the error will be "OLM_UNKNOWN_MESSAGE_INDEX". -func (s *InboundGroupSession) Decrypt(message []byte) ([]byte, uint, error) { - if len(message) == 0 { - return nil, 0, olm.ErrEmptyInput - } - decryptMaxPlaintextLen, err := s.decryptMaxPlaintextLen(message) - if err != nil { - return nil, 0, err - } - messageCopy := bytes.Clone(message) - plaintext := make([]byte, decryptMaxPlaintextLen) - var messageIndex uint32 - r := C.olm_group_decrypt( - (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(messageCopy))), - C.size_t(len(messageCopy)), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(plaintext))), - C.size_t(len(plaintext)), - (*C.uint32_t)(unsafe.Pointer(&messageIndex)), - ) - runtime.KeepAlive(messageCopy) - if r == errorVal() { - return nil, 0, s.lastError() - } - return plaintext[:r], uint(messageIndex), nil -} - -// sessionIdLen returns the number of bytes needed to store a session ID. -func (s *InboundGroupSession) sessionIdLen() uint { - return uint(C.olm_inbound_group_session_id_length((*C.OlmInboundGroupSession)(s.int))) -} - -// ID returns a base64-encoded identifier for this session. -func (s *InboundGroupSession) ID() id.SessionID { - sessionID := make([]byte, s.sessionIdLen()) - r := C.olm_inbound_group_session_id( - (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionID))), - C.size_t(len(sessionID)), - ) - if r == errorVal() { - panic(s.lastError()) - } - return id.SessionID(sessionID[:r]) -} - -// FirstKnownIndex returns the first message index we know how to decrypt. -func (s *InboundGroupSession) FirstKnownIndex() uint32 { - return uint32(C.olm_inbound_group_session_first_known_index((*C.OlmInboundGroupSession)(s.int))) -} - -// IsVerified check if the session has been verified as a valid session. (A -// session is verified either because the original session share was signed, or -// because we have subsequently successfully decrypted a message.) -func (s *InboundGroupSession) IsVerified() bool { - return uint(C.olm_inbound_group_session_is_verified((*C.OlmInboundGroupSession)(s.int))) == 1 -} - -// exportLen returns the number of bytes needed to export an inbound group -// session. -func (s *InboundGroupSession) exportLen() uint { - return uint(C.olm_export_inbound_group_session_length((*C.OlmInboundGroupSession)(s.int))) -} - -// Export returns the base64-encoded ratchet key for this session, at the given -// index, in a format which can be used by -// InboundGroupSession.InboundGroupSessionImport(). Encrypts the -// InboundGroupSession using the supplied key. Returns error on failure. -// if we do not have a session key corresponding to the given index (ie, it was -// sent before the session key was shared with us) the error will be -// "OLM_UNKNOWN_MESSAGE_INDEX". -func (s *InboundGroupSession) Export(messageIndex uint32) ([]byte, error) { - key := make([]byte, s.exportLen()) - r := C.olm_export_inbound_group_session( - (*C.OlmInboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(key))), - C.size_t(len(key)), - C.uint32_t(messageIndex), - ) - if r == errorVal() { - return nil, s.lastError() - } - return key[:r], nil -} diff --git a/mautrix-patched/crypto/libolm/libolm.go b/mautrix-patched/crypto/libolm/libolm.go deleted file mode 100644 index 18815767..00000000 --- a/mautrix-patched/crypto/libolm/libolm.go +++ /dev/null @@ -1,10 +0,0 @@ -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -import "C" - -// errorVal returns the value that olm functions return if there was an error. -func errorVal() C.size_t { - return C.olm_error() -} diff --git a/mautrix-patched/crypto/libolm/outboundgroupsession.go b/mautrix-patched/crypto/libolm/outboundgroupsession.go deleted file mode 100644 index ca5b68f7..00000000 --- a/mautrix-patched/crypto/libolm/outboundgroupsession.go +++ /dev/null @@ -1,245 +0,0 @@ -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -import "C" - -import ( - "crypto/rand" - "encoding/base64" - "runtime" - "unsafe" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -// OutboundGroupSession stores an outbound encrypted messaging session -// for a group. -type OutboundGroupSession struct { - int *C.OlmOutboundGroupSession - mem []byte -} - -// Ensure that [OutboundGroupSession] implements [olm.OutboundGroupSession]. -var _ olm.OutboundGroupSession = (*OutboundGroupSession)(nil) - -func NewOutboundGroupSession() (*OutboundGroupSession, error) { - s := NewBlankOutboundGroupSession() - random := make([]byte, s.createRandomLen()+1) - _, err := rand.Read(random) - if err != nil { - return nil, err - } - r := C.olm_init_outbound_group_session( - (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(random))), - C.size_t(len(random)), - ) - runtime.KeepAlive(random) - if r == errorVal() { - return nil, s.lastError() - } - return s, nil -} - -// outboundGroupSessionSize is the size of an outbound group session object in -// bytes. -func outboundGroupSessionSize() uint { - return uint(C.olm_outbound_group_session_size()) -} - -// NewBlankOutboundGroupSession initialises an empty [OutboundGroupSession]. -func NewBlankOutboundGroupSession() *OutboundGroupSession { - memory := make([]byte, outboundGroupSessionSize()) - return &OutboundGroupSession{ - int: C.olm_outbound_group_session(unsafe.Pointer(unsafe.SliceData(memory))), - mem: memory, - } -} - -// lastError returns an error describing the most recent error to happen to an -// outbound group session. -func (s *OutboundGroupSession) lastError() error { - return convertError(C.GoString(C.olm_outbound_group_session_last_error((*C.OlmOutboundGroupSession)(s.int)))) -} - -// Clear clears the memory used to back this OutboundGroupSession. -func (s *OutboundGroupSession) Clear() error { - r := C.olm_clear_outbound_group_session((*C.OlmOutboundGroupSession)(s.int)) - if r == errorVal() { - return s.lastError() - } else { - return nil - } -} - -// pickleLen returns the number of bytes needed to store an outbound group -// session. -func (s *OutboundGroupSession) pickleLen() uint { - return uint(C.olm_pickle_outbound_group_session_length((*C.OlmOutboundGroupSession)(s.int))) -} - -// Pickle returns an OutboundGroupSession as a base64 string. Encrypts the -// OutboundGroupSession using the supplied key. -func (s *OutboundGroupSession) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - pickled := make([]byte, s.pickleLen()) - r := C.olm_pickle_outbound_group_session( - (*C.OlmOutboundGroupSession)(s.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled)), - ) - runtime.KeepAlive(key) - if r == errorVal() { - return nil, s.lastError() - } - return pickled[:r], nil -} - -func (s *OutboundGroupSession) Unpickle(pickled, key []byte) error { - if len(key) == 0 { - return olm.ErrNoKeyProvided - } - r := C.olm_unpickle_outbound_group_session( - (*C.OlmOutboundGroupSession)(s.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled)), - ) - runtime.KeepAlive(pickled) - runtime.KeepAlive(key) - if r == errorVal() { - return s.lastError() - } - return nil -} - -// Deprecated -func (s *OutboundGroupSession) GobEncode() ([]byte, error) { - pickled, err := s.Pickle(pickleKey) - if err != nil { - return nil, err - } - length := base64.RawStdEncoding.DecodedLen(len(pickled)) - rawPickled := make([]byte, length) - _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) - return rawPickled, err -} - -// Deprecated -func (s *OutboundGroupSession) GobDecode(rawPickled []byte) error { - if s == nil || s.int == nil { - *s = *NewBlankOutboundGroupSession() - } - length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) - pickled := make([]byte, length) - base64.RawStdEncoding.Encode(pickled, rawPickled) - return s.Unpickle(pickled, pickleKey) -} - -// Deprecated -func (s *OutboundGroupSession) MarshalJSON() ([]byte, error) { - pickled, err := s.Pickle(pickleKey) - if err != nil { - return nil, err - } - quotes := make([]byte, len(pickled)+2) - quotes[0] = '"' - quotes[len(quotes)-1] = '"' - copy(quotes[1:len(quotes)-1], pickled) - return quotes, nil -} - -// Deprecated -func (s *OutboundGroupSession) UnmarshalJSON(data []byte) error { - if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.ErrInputNotJSONString - } - if s == nil || s.int == nil { - *s = *NewBlankOutboundGroupSession() - } - return s.Unpickle(data[1:len(data)-1], pickleKey) -} - -// createRandomLen returns the number of random bytes needed to create an -// Account. -func (s *OutboundGroupSession) createRandomLen() uint { - return uint(C.olm_init_outbound_group_session_random_length((*C.OlmOutboundGroupSession)(s.int))) -} - -// encryptMsgLen returns the size of the next message in bytes for the given -// number of plain-text bytes. -func (s *OutboundGroupSession) encryptMsgLen(plainTextLen int) uint { - return uint(C.olm_group_encrypt_message_length((*C.OlmOutboundGroupSession)(s.int), C.size_t(plainTextLen))) -} - -// Encrypt encrypts a message using the Session. Returns the encrypted message -// as base64. -func (s *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { - if len(plaintext) == 0 { - return nil, olm.ErrEmptyInput - } - message := make([]byte, s.encryptMsgLen(len(plaintext))) - r := C.olm_group_encrypt( - (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(plaintext))), - C.size_t(len(plaintext)), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(message))), - C.size_t(len(message)), - ) - runtime.KeepAlive(plaintext) - if r == errorVal() { - return nil, s.lastError() - } - return message[:r], nil -} - -// sessionIdLen returns the number of bytes needed to store a session ID. -func (s *OutboundGroupSession) sessionIdLen() uint { - return uint(C.olm_outbound_group_session_id_length((*C.OlmOutboundGroupSession)(s.int))) -} - -// ID returns a base64-encoded identifier for this session. -func (s *OutboundGroupSession) ID() id.SessionID { - sessionID := make([]byte, s.sessionIdLen()) - r := C.olm_outbound_group_session_id( - (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionID))), - C.size_t(len(sessionID)), - ) - if r == errorVal() { - panic(s.lastError()) - } - return id.SessionID(sessionID[:r]) -} - -// MessageIndex returns the message index for this session. Each message is -// sent with an increasing index; this returns the index for the next message. -func (s *OutboundGroupSession) MessageIndex() uint { - return uint(C.olm_outbound_group_session_message_index((*C.OlmOutboundGroupSession)(s.int))) -} - -// sessionKeyLen returns the number of bytes needed to store a session key. -func (s *OutboundGroupSession) sessionKeyLen() uint { - return uint(C.olm_outbound_group_session_key_length((*C.OlmOutboundGroupSession)(s.int))) -} - -// Key returns the base64-encoded current ratchet key for this session. -func (s *OutboundGroupSession) Key() string { - sessionKey := make([]byte, s.sessionKeyLen()) - r := C.olm_outbound_group_session_key( - (*C.OlmOutboundGroupSession)(s.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(sessionKey))), - C.size_t(len(sessionKey)), - ) - if r == errorVal() { - panic(s.lastError()) - } - return string(sessionKey[:r]) -} diff --git a/mautrix-patched/crypto/libolm/pk.go b/mautrix-patched/crypto/libolm/pk.go deleted file mode 100644 index 7ef9d468..00000000 --- a/mautrix-patched/crypto/libolm/pk.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -// #include -import "C" - -import ( - "crypto/rand" - "fmt" - "runtime" - "unsafe" - - "github.com/tidwall/sjson" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -// PKSigning stores a key pair for signing messages. -type PKSigning struct { - int *C.OlmPkSigning - mem []byte - publicKey id.Ed25519 - seed []byte -} - -// Ensure that [PKSigning] implements [olm.PKSigning]. -var _ olm.PKSigning = (*PKSigning)(nil) - -func pkSigningSize() uint { - return uint(C.olm_pk_signing_size()) -} - -func pkSigningSeedLength() uint { - return uint(C.olm_pk_signing_seed_length()) -} - -func pkSigningPublicKeyLength() uint { - return uint(C.olm_pk_signing_public_key_length()) -} - -func pkSigningSignatureLength() uint { - return uint(C.olm_pk_signature_length()) -} - -func newBlankPKSigning() *PKSigning { - memory := make([]byte, pkSigningSize()) - return &PKSigning{ - int: C.olm_pk_signing(unsafe.Pointer(unsafe.SliceData(memory))), - mem: memory, - } -} - -// NewPKSigningFromSeed creates a new [PKSigning] object using the given seed. -func NewPKSigningFromSeed(seed []byte) (*PKSigning, error) { - p := newBlankPKSigning() - p.clear() - pubKey := make([]byte, pkSigningPublicKeyLength()) - r := C.olm_pk_signing_key_from_seed( - (*C.OlmPkSigning)(p.int), - unsafe.Pointer(unsafe.SliceData(pubKey)), - C.size_t(len(pubKey)), - unsafe.Pointer(unsafe.SliceData(seed)), - C.size_t(len(seed)), - ) - if r == errorVal() { - return nil, p.lastError() - } - p.publicKey = id.Ed25519(pubKey) - p.seed = seed - return p, nil -} - -// NewPKSigning creates a new [PKSigning] object, containing a key pair for -// signing messages. -func NewPKSigning() (*PKSigning, error) { - // Generate the seed - seed := make([]byte, pkSigningSeedLength()) - _, err := rand.Read(seed) - if err != nil { - panic(olm.ErrNotEnoughGoRandom) - } - pk, err := NewPKSigningFromSeed(seed) - return pk, err -} - -func (p *PKSigning) PublicKey() id.Ed25519 { - return p.publicKey -} - -func (p *PKSigning) Seed() []byte { - return p.seed -} - -// clear clears the underlying memory of a [PKSigning] object. -func (p *PKSigning) clear() { - C.olm_clear_pk_signing((*C.OlmPkSigning)(p.int)) -} - -// Sign creates a signature for the given message using this key. -func (p *PKSigning) Sign(message []byte) ([]byte, error) { - signature := make([]byte, pkSigningSignatureLength()) - r := C.olm_pk_sign( - (*C.OlmPkSigning)(p.int), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(message))), - C.size_t(len(message)), - (*C.uint8_t)(unsafe.Pointer(unsafe.SliceData(signature))), - C.size_t(len(signature)), - ) - runtime.KeepAlive(message) - if r == errorVal() { - return nil, p.lastError() - } - return signature, nil -} - -// SignJSON creates a signature for the given object after encoding it to canonical JSON. -func (p *PKSigning) SignJSON(obj interface{}) (string, error) { - objJSON, err := canonicaljson.Marshal(obj) - if err != nil { - return "", err - } - objJSON, _ = sjson.DeleteBytes(objJSON, "unsigned") - objJSON, _ = sjson.DeleteBytes(objJSON, "signatures") - // This is probably not necessary - err = canonicaljson.Canonicalize(&objJSON) - if err != nil { - return "", fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) - } - signature, err := p.Sign(objJSON) - if err != nil { - return "", err - } - return string(signature), nil -} - -// lastError returns the last error that happened in relation to this -// [PKSigning] object. -func (p *PKSigning) lastError() error { - return convertError(C.GoString(C.olm_pk_signing_last_error((*C.OlmPkSigning)(p.int)))) -} diff --git a/mautrix-patched/crypto/libolm/register.go b/mautrix-patched/crypto/libolm/register.go deleted file mode 100644 index 6f4086f3..00000000 --- a/mautrix-patched/crypto/libolm/register.go +++ /dev/null @@ -1,72 +0,0 @@ -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -import "C" -import ( - "unsafe" - - "maunium.net/go/mautrix/crypto/olm" -) - -var pickleKey = []byte("maunium.net/go/mautrix/crypto/olm") - -func Register() { - olm.Driver = "libolm" - - olm.GetVersion = func() (major, minor, patch uint8) { - C.olm_get_library_version( - (*C.uint8_t)(unsafe.Pointer(&major)), - (*C.uint8_t)(unsafe.Pointer(&minor)), - (*C.uint8_t)(unsafe.Pointer(&patch))) - return 3, 2, 15 - } - olm.SetPickleKeyImpl = func(key []byte) { - pickleKey = key - } - - olm.InitNewAccount = func() (olm.Account, error) { - return NewAccount() - } - olm.InitBlankAccount = func() olm.Account { - return NewBlankAccount() - } - olm.InitNewAccountFromPickled = func(pickled, key []byte) (olm.Account, error) { - return AccountFromPickled(pickled, key) - } - - olm.InitSessionFromPickled = func(pickled, key []byte) (olm.Session, error) { - return SessionFromPickled(pickled, key) - } - olm.InitNewBlankSession = func() olm.Session { - return NewBlankSession() - } - - olm.InitNewPKSigning = func() (olm.PKSigning, error) { return NewPKSigning() } - olm.InitNewPKSigningFromSeed = func(seed []byte) (olm.PKSigning, error) { - return NewPKSigningFromSeed(seed) - } - - olm.InitInboundGroupSessionFromPickled = func(pickled, key []byte) (olm.InboundGroupSession, error) { - return InboundGroupSessionFromPickled(pickled, key) - } - olm.InitNewInboundGroupSession = func(sessionKey []byte) (olm.InboundGroupSession, error) { - return NewInboundGroupSession(sessionKey) - } - olm.InitInboundGroupSessionImport = func(sessionKey []byte) (olm.InboundGroupSession, error) { - return InboundGroupSessionImport(sessionKey) - } - olm.InitBlankInboundGroupSession = func() olm.InboundGroupSession { - return NewBlankInboundGroupSession() - } - - olm.InitNewOutboundGroupSessionFromPickled = func(pickled, key []byte) (olm.OutboundGroupSession, error) { - if len(pickled) == 0 { - return nil, olm.ErrEmptyInput - } - s := NewBlankOutboundGroupSession() - return s, s.Unpickle(pickled, key) - } - olm.InitNewOutboundGroupSession = func() (olm.OutboundGroupSession, error) { return NewOutboundGroupSession() } - olm.InitNewBlankOutboundGroupSession = func() olm.OutboundGroupSession { return NewBlankOutboundGroupSession() } -} diff --git a/mautrix-patched/crypto/libolm/session.go b/mautrix-patched/crypto/libolm/session.go deleted file mode 100644 index 1441df26..00000000 --- a/mautrix-patched/crypto/libolm/session.go +++ /dev/null @@ -1,401 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package libolm - -// #cgo LDFLAGS: -lolm -lstdc++ -// #include -// #include -// #include -// void olm_session_describe(OlmSession * session, char *buf, size_t buflen) __attribute__((weak)); -// void meowlm_session_describe(OlmSession * session, char *buf, size_t buflen) { -// if (olm_session_describe) { -// olm_session_describe(session, buf, buflen); -// } else { -// sprintf(buf, "olm_session_describe not supported"); -// } -// } -import "C" - -import ( - "crypto/rand" - "encoding/base64" - "runtime" - "unsafe" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -// Session stores an end to end encrypted messaging session. -type Session struct { - int *C.OlmSession - mem []byte -} - -// Ensure that [Session] implements [olm.Session]. -var _ olm.Session = (*Session)(nil) - -// sessionSize is the size of a session object in bytes. -func sessionSize() uint { - return uint(C.olm_session_size()) -} - -// SessionFromPickled loads a Session from a pickled base64 string. Decrypts -// the Session using the supplied key. Returns error on failure. If the key -// doesn't match the one used to encrypt the Session then the error will be -// "BAD_SESSION_KEY". If the base64 couldn't be decoded then the error will be -// "INVALID_BASE64". -func SessionFromPickled(pickled, key []byte) (*Session, error) { - if len(pickled) == 0 { - return nil, olm.ErrEmptyInput - } - s := NewBlankSession() - return s, s.Unpickle(pickled, key) -} - -func NewBlankSession() *Session { - memory := make([]byte, sessionSize()) - return &Session{ - int: C.olm_session(unsafe.Pointer(unsafe.SliceData(memory))), - mem: memory, - } -} - -// lastError returns an error describing the most recent error to happen to a -// session. -func (s *Session) lastError() error { - return convertError(C.GoString(C.olm_session_last_error((*C.OlmSession)(s.int)))) -} - -// Clear clears the memory used to back this Session. -func (s *Session) Clear() error { - r := C.olm_clear_session((*C.OlmSession)(s.int)) - if r == errorVal() { - return s.lastError() - } - return nil -} - -// pickleLen returns the number of bytes needed to store a session. -func (s *Session) pickleLen() uint { - return uint(C.olm_pickle_session_length((*C.OlmSession)(s.int))) -} - -// createOutboundRandomLen returns the number of random bytes needed to create -// an outbound session. -func (s *Session) createOutboundRandomLen() uint { - return uint(C.olm_create_outbound_session_random_length((*C.OlmSession)(s.int))) -} - -// idLen returns the length of the buffer needed to return the id for this -// session. -func (s *Session) idLen() uint { - return uint(C.olm_session_id_length((*C.OlmSession)(s.int))) -} - -// encryptRandomLen returns the number of random bytes needed to encrypt the -// next message. -func (s *Session) encryptRandomLen() uint { - return uint(C.olm_encrypt_random_length((*C.OlmSession)(s.int))) -} - -// encryptMsgLen returns the size of the next message in bytes for the given -// number of plain-text bytes. -func (s *Session) encryptMsgLen(plainTextLen int) uint { - return uint(C.olm_encrypt_message_length((*C.OlmSession)(s.int), C.size_t(plainTextLen))) -} - -// decryptMaxPlaintextLen returns the maximum number of bytes of plain-text a -// given message could decode to. The actual size could be different due to -// padding. Returns error on failure. If the message base64 couldn't be -// decoded then the error will be "INVALID_BASE64". If the message is for an -// unsupported version of the protocol then the error will be -// "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error -// will be "BAD_MESSAGE_FORMAT". -func (s *Session) decryptMaxPlaintextLen(message string, msgType id.OlmMsgType) (uint, error) { - if len(message) == 0 { - return 0, olm.ErrEmptyInput - } - messageCopy := []byte(message) - r := C.olm_decrypt_max_plaintext_length( - (*C.OlmSession)(s.int), - C.size_t(msgType), - unsafe.Pointer(unsafe.SliceData((messageCopy))), - C.size_t(len(messageCopy)), - ) - runtime.KeepAlive(messageCopy) - if r == errorVal() { - return 0, s.lastError() - } - return uint(r), nil -} - -// Pickle returns a Session as a base64 string. Encrypts the Session using the -// supplied key. -func (s *Session) Pickle(key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, olm.ErrNoKeyProvided - } - pickled := make([]byte, s.pickleLen()) - r := C.olm_pickle_session( - (*C.OlmSession)(s.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled))) - runtime.KeepAlive(key) - if r == errorVal() { - panic(s.lastError()) - } - return pickled[:r], nil -} - -// Unpickle unpickles the base64-encoded Olm session decrypting it with the -// provided key. This function mutates the input pickled data slice. -func (s *Session) Unpickle(pickled, key []byte) error { - if len(key) == 0 { - return olm.ErrNoKeyProvided - } - r := C.olm_unpickle_session( - (*C.OlmSession)(s.int), - unsafe.Pointer(unsafe.SliceData(key)), - C.size_t(len(key)), - unsafe.Pointer(unsafe.SliceData(pickled)), - C.size_t(len(pickled))) - runtime.KeepAlive(pickled) - runtime.KeepAlive(key) - if r == errorVal() { - return s.lastError() - } - return nil -} - -// Deprecated -func (s *Session) GobEncode() ([]byte, error) { - pickled, err := s.Pickle(pickleKey) - if err != nil { - return nil, err - } - length := base64.RawStdEncoding.DecodedLen(len(pickled)) - rawPickled := make([]byte, length) - _, err = base64.RawStdEncoding.Decode(rawPickled, pickled) - return rawPickled, err -} - -// Deprecated -func (s *Session) GobDecode(rawPickled []byte) error { - if s == nil || s.int == nil { - *s = *NewBlankSession() - } - length := base64.RawStdEncoding.EncodedLen(len(rawPickled)) - pickled := make([]byte, length) - base64.RawStdEncoding.Encode(pickled, rawPickled) - return s.Unpickle(pickled, pickleKey) -} - -// Deprecated -func (s *Session) MarshalJSON() ([]byte, error) { - pickled, err := s.Pickle(pickleKey) - if err != nil { - return nil, err - } - quotes := make([]byte, len(pickled)+2) - quotes[0] = '"' - quotes[len(quotes)-1] = '"' - copy(quotes[1:len(quotes)-1], pickled) - return quotes, nil -} - -// Deprecated -func (s *Session) UnmarshalJSON(data []byte) error { - if len(data) == 0 || data[0] != '"' || data[len(data)-1] != '"' { - return olm.ErrInputNotJSONString - } - if s == nil || s.int == nil { - *s = *NewBlankSession() - } - return s.Unpickle(data[1:len(data)-1], pickleKey) -} - -// Id returns an identifier for this Session. Will be the same for both ends -// of the conversation. -func (s *Session) ID() id.SessionID { - sessionID := make([]byte, s.idLen()) - r := C.olm_session_id( - (*C.OlmSession)(s.int), - unsafe.Pointer(unsafe.SliceData(sessionID)), - C.size_t(len(sessionID)), - ) - if r == errorVal() { - panic(s.lastError()) - } - return id.SessionID(sessionID) -} - -// HasReceivedMessage returns true if this session has received any message. -func (s *Session) HasReceivedMessage() bool { - switch C.olm_session_has_received_message((*C.OlmSession)(s.int)) { - case 0: - return false - default: - return true - } -} - -// MatchesInboundSession checks if the PRE_KEY message is for this in-bound -// Session. This can happen if multiple messages are sent to this Account -// before this Account sends a message in reply. Returns true if the session -// matches. Returns false if the session does not match. Returns error on -// failure. If the base64 couldn't be decoded then the error will be -// "INVALID_BASE64". If the message was for an unsupported protocol version -// then the error will be "BAD_MESSAGE_VERSION". If the message couldn't be -// decoded then then the error will be "BAD_MESSAGE_FORMAT". -func (s *Session) MatchesInboundSession(oneTimeKeyMsg string) (bool, error) { - if len(oneTimeKeyMsg) == 0 { - return false, olm.ErrEmptyInput - } - oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) - r := C.olm_matches_inbound_session( - (*C.OlmSession)(s.int), - unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), - C.size_t(len(oneTimeKeyMsgCopy)), - ) - runtime.KeepAlive(oneTimeKeyMsgCopy) - if r == 1 { - return true, nil - } else if r == 0 { - return false, nil - } else { // if r == errorVal() - return false, s.lastError() - } -} - -// MatchesInboundSessionFrom checks if the PRE_KEY message is for this in-bound -// Session. This can happen if multiple messages are sent to this Account -// before this Account sends a message in reply. Returns true if the session -// matches. Returns false if the session does not match. Returns error on -// failure. If the base64 couldn't be decoded then the error will be -// "INVALID_BASE64". If the message was for an unsupported protocol version -// then the error will be "BAD_MESSAGE_VERSION". If the message couldn't be -// decoded then then the error will be "BAD_MESSAGE_FORMAT". -func (s *Session) MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) { - if len(theirIdentityKey) == 0 || len(oneTimeKeyMsg) == 0 { - return false, olm.ErrEmptyInput - } - theirIdentityKeyCopy := []byte(theirIdentityKey) - oneTimeKeyMsgCopy := []byte(oneTimeKeyMsg) - r := C.olm_matches_inbound_session_from( - (*C.OlmSession)(s.int), - unsafe.Pointer(unsafe.SliceData(theirIdentityKeyCopy)), - C.size_t(len(theirIdentityKeyCopy)), - unsafe.Pointer(unsafe.SliceData(oneTimeKeyMsgCopy)), - C.size_t(len(oneTimeKeyMsgCopy)), - ) - runtime.KeepAlive(theirIdentityKeyCopy) - runtime.KeepAlive(oneTimeKeyMsgCopy) - if r == 1 { - return true, nil - } else if r == 0 { - return false, nil - } else { // if r == errorVal() - return false, s.lastError() - } -} - -// EncryptMsgType returns the type of the next message that Encrypt will -// return. Returns MsgTypePreKey if the message will be a PRE_KEY message. -// Returns MsgTypeMsg if the message will be a normal message. Returns error -// on failure. -func (s *Session) EncryptMsgType() id.OlmMsgType { - switch C.olm_encrypt_message_type((*C.OlmSession)(s.int)) { - case C.size_t(id.OlmMsgTypePreKey): - return id.OlmMsgTypePreKey - case C.size_t(id.OlmMsgTypeMsg): - return id.OlmMsgTypeMsg - default: - panic("olm_encrypt_message_type returned invalid result") - } -} - -// Encrypt encrypts a message using the Session. Returns the encrypted message -// as base64. -func (s *Session) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { - if len(plaintext) == 0 { - return 0, nil, olm.ErrEmptyInput - } - // Make the slice be at least length 1 - random := make([]byte, s.encryptRandomLen()+1) - _, err := rand.Read(random) - if err != nil { - // TODO can we just return err here? - return 0, nil, olm.ErrNotEnoughGoRandom - } - messageType := s.EncryptMsgType() - message := make([]byte, s.encryptMsgLen(len(plaintext))) - r := C.olm_encrypt( - (*C.OlmSession)(s.int), - unsafe.Pointer(unsafe.SliceData(plaintext)), - C.size_t(len(plaintext)), - unsafe.Pointer(unsafe.SliceData(random)), - C.size_t(len(random)), - unsafe.Pointer(unsafe.SliceData(message)), - C.size_t(len(message)), - ) - runtime.KeepAlive(plaintext) - runtime.KeepAlive(random) - if r == errorVal() { - return 0, nil, s.lastError() - } - return messageType, message[:r], nil -} - -// Decrypt decrypts a message using the Session. Returns the the plain-text on -// success. Returns error on failure. If the base64 couldn't be decoded then -// the error will be "INVALID_BASE64". If the message is for an unsupported -// version of the protocol then the error will be "BAD_MESSAGE_VERSION". If -// the message couldn't be decoded then the error will be BAD_MESSAGE_FORMAT". -// If the MAC on the message was invalid then the error will be -// "BAD_MESSAGE_MAC". -func (s *Session) Decrypt(message string, msgType id.OlmMsgType) ([]byte, error) { - if len(message) == 0 { - return nil, olm.ErrEmptyInput - } - decryptMaxPlaintextLen, err := s.decryptMaxPlaintextLen(message, msgType) - if err != nil { - return nil, err - } - messageCopy := []byte(message) - plaintext := make([]byte, decryptMaxPlaintextLen) - r := C.olm_decrypt( - (*C.OlmSession)(s.int), - C.size_t(msgType), - unsafe.Pointer(unsafe.SliceData(messageCopy)), - C.size_t(len(messageCopy)), - unsafe.Pointer(unsafe.SliceData(plaintext)), - C.size_t(len(plaintext)), - ) - runtime.KeepAlive(messageCopy) - if r == errorVal() { - return nil, s.lastError() - } - return plaintext[:r], nil -} - -// https://gitlab.matrix.org/matrix-org/olm/-/blob/3.2.8/include/olm/olm.h#L392-393 -const maxDescribeSize = 600 - -// Describe generates a string describing the internal state of an olm session for debugging and logging purposes. -func (s *Session) Describe() string { - desc := (*C.char)(C.malloc(C.size_t(maxDescribeSize))) - defer C.free(unsafe.Pointer(desc)) - C.meowlm_session_describe( - (*C.OlmSession)(s.int), - desc, - C.size_t(maxDescribeSize), - ) - return C.GoString(desc) -} diff --git a/mautrix-patched/crypto/machine.go b/mautrix-patched/crypto/machine.go deleted file mode 100644 index 619ac6b7..00000000 --- a/mautrix-patched/crypto/machine.go +++ /dev/null @@ -1,839 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exsync" - "go.mau.fi/util/ptr" - - "go.mau.fi/util/exzerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/ssss" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// OlmMachine is the main struct for handling Matrix end-to-end encryption. -type OlmMachine struct { - Client *mautrix.Client - SSSS *ssss.Machine - Log *zerolog.Logger - - CryptoStore Store - StateStore StateStore - - backgroundCtx context.Context - cancelBackgroundCtx context.CancelFunc - - PlaintextMentions bool - MSC4392Relations bool - AllowEncryptedState bool - AllowBeeperRoomReroute bool - - // Never ask the server for keys automatically as a side effect during Megolm decryption. - DisableDecryptKeyFetching bool - keyFetchAttempted *exsync.Set[userSenderKeyTuple] - - // Don't mark outbound Olm sessions as shared for devices they were initially sent to. - DisableSharedGroupSessionTracking bool - - IgnorePostDecryptionParseErrors bool - - SendKeysMinTrust id.TrustState - ShareKeysMinTrust id.TrustState - - AllowKeyShare func(context.Context, *id.Device, event.RequestedKeyInfo) *KeyShareRejection - - account *OlmAccount - - roomKeyRequestFilled *sync.Map - keyVerificationTransactionState *sync.Map - - keyWaiters map[id.SessionID]chan struct{} - keyWaitersLock sync.Mutex - - // Optional callback which is called when we save a session to store - SessionReceived func(context.Context, id.RoomID, id.SessionID, uint32) - - devicesToUnwedge map[id.IdentityKey]bool - devicesToUnwedgeLock sync.Mutex - recentlyUnwedged map[id.IdentityKey]time.Time - recentlyUnwedgedLock sync.Mutex - olmHashSavePoints []time.Time - lastHashDelete time.Time - olmHashSavePointLock sync.Mutex - - olmLock sync.Mutex - megolmEncryptLock sync.Mutex - megolmDecryptLock sync.Mutex - - otkUploadLock sync.Mutex - lastOTKUpload time.Time - receivedOTKsForSelf atomic.Bool - - CrossSigningKeys *CrossSigningKeysCache - crossSigningPubkeys *CrossSigningPublicKeysCache - - crossSigningPubkeysFetched bool - ownDeviceKeysCache atomic.Pointer[mautrix.DeviceKeys] - - DeleteOutboundKeysOnAck bool - DontStoreOutboundKeys bool - DeletePreviousKeysOnReceive bool - RatchetKeysOnDecrypt bool - DeleteFullyUsedKeysOnDecrypt bool - DeleteKeysOnDeviceDelete bool - DisableRatchetTracking bool - - DisableDeviceChangeKeyRotation bool - - secretLock sync.Mutex - secretListeners map[string]chan<- string -} - -// StateStore is used by OlmMachine to get room state information that's needed for encryption. -type StateStore interface { - // IsEncrypted returns whether a room is encrypted. - IsEncrypted(context.Context, id.RoomID) (bool, error) - // GetEncryptionEvent returns the encryption event's content for an encrypted room. - GetEncryptionEvent(context.Context, id.RoomID) (*event.EncryptionEventContent, error) - // FindSharedRooms returns the encrypted rooms that another user is also in for a user ID. - FindSharedRooms(context.Context, id.UserID) ([]id.RoomID, error) -} - -// NewOlmMachine creates an OlmMachine with the given client, logger and stores. -func NewOlmMachine(client *mautrix.Client, log *zerolog.Logger, cryptoStore Store, stateStore StateStore) *OlmMachine { - if log == nil { - logPtr := zerolog.Nop() - log = &logPtr - } - mach := &OlmMachine{ - Client: client, - SSSS: ssss.NewSSSSMachine(client), - Log: log, - CryptoStore: cryptoStore, - StateStore: stateStore, - - SendKeysMinTrust: id.TrustStateUnset, - ShareKeysMinTrust: id.TrustStateCrossSignedTOFU, - - roomKeyRequestFilled: &sync.Map{}, - keyVerificationTransactionState: &sync.Map{}, - - keyWaiters: make(map[id.SessionID]chan struct{}), - - devicesToUnwedge: make(map[id.IdentityKey]bool), - recentlyUnwedged: make(map[id.IdentityKey]time.Time), - secretListeners: make(map[string]chan<- string), - - keyFetchAttempted: exsync.NewSet[userSenderKeyTuple](), - } - mach.backgroundCtx, mach.cancelBackgroundCtx = context.WithCancel(context.Background()) - mach.AllowKeyShare = mach.defaultAllowKeyShare - return mach -} - -func (mach *OlmMachine) machOrContextLog(ctx context.Context) *zerolog.Logger { - log := zerolog.Ctx(ctx) - if log.GetLevel() == zerolog.Disabled || log == zerolog.DefaultContextLogger { - return mach.Log - } - return log -} - -func (mach *OlmMachine) SetBackgroundCtx(ctx context.Context) { - mach.cancelBackgroundCtx() - mach.backgroundCtx, mach.cancelBackgroundCtx = context.WithCancel(ctx) -} - -// Load loads the Olm account information from the crypto store. If there's no olm account, a new one is created. -// This must be called before using the machine. -func (mach *OlmMachine) Load(ctx context.Context) (err error) { - mach.account, err = mach.CryptoStore.GetAccount(ctx) - if err != nil { - return - } - if mach.account == nil { - mach.account = NewOlmAccount() - } - zerolog.Ctx(ctx).Debug(). - Str("machine_ptr", fmt.Sprintf("%p", mach)). - Str("account_ptr", fmt.Sprintf("%p", mach.account.Internal)). - Str("olm_driver", olm.Driver). - Msg("Loaded olm account") - return nil -} - -func (mach *OlmMachine) Destroy() { - mach.Log.Debug(). - Str("machine_ptr", fmt.Sprintf("%p", mach)). - Str("account_ptr", fmt.Sprintf("%p", ptr.Val(mach.account).Internal)). - Msg("Destroying olm machine") - mach.cancelBackgroundCtx() - // TODO actually destroy something? -} - -func (mach *OlmMachine) saveAccount(ctx context.Context) error { - err := mach.CryptoStore.PutAccount(ctx, mach.account) - if err != nil { - mach.Log.Error().Err(err).Msg("Failed to save account") - } - return err -} - -func (mach *OlmMachine) KeyBackupVersion() id.KeyBackupVersion { - return mach.account.KeyBackupVersion -} - -func (mach *OlmMachine) SetKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) error { - mach.account.KeyBackupVersion = version - return mach.saveAccount(ctx) -} - -// FlushStore calls the Flush method of the CryptoStore. -func (mach *OlmMachine) FlushStore(ctx context.Context) error { - return mach.CryptoStore.Flush(ctx) -} - -func (mach *OlmMachine) timeTrace(ctx context.Context, thing string, expectedDuration time.Duration) func() { - start := time.Now() - return func() { - duration := time.Since(start) - if duration > expectedDuration { - zerolog.Ctx(ctx).Warn(). - Str("action", thing). - Dur("duration", duration). - Msg("Executing encryption function took longer than expected") - } - } -} - -// Deprecated: moved to SigningKey.Fingerprint -func Fingerprint(key id.SigningKey) string { - return key.Fingerprint() -} - -// Fingerprint returns the fingerprint of the Olm account that can be used for non-interactive verification. -func (mach *OlmMachine) Fingerprint() string { - return mach.account.SigningKey().Fingerprint() -} - -func (mach *OlmMachine) GetAccount() *OlmAccount { - return mach.account -} - -// OwnIdentity returns this device's id.Device struct -func (mach *OlmMachine) OwnIdentity() *id.Device { - return &id.Device{ - UserID: mach.Client.UserID, - DeviceID: mach.Client.DeviceID, - IdentityKey: mach.account.IdentityKey(), - SigningKey: mach.account.SigningKey(), - Trust: id.TrustStateVerified, - Deleted: false, - } -} - -type ASEventProcessor interface { - On(evtType event.Type, handler func(ctx context.Context, evt *event.Event)) - OnOTK(func(ctx context.Context, otk *mautrix.OTKCount)) - OnDeviceList(func(ctx context.Context, lists *mautrix.DeviceLists, since string)) - Dispatch(ctx context.Context, evt *event.Event) -} - -func (mach *OlmMachine) AddAppserviceListener(ep ASEventProcessor) { - // ToDeviceForwardedRoomKey and ToDeviceRoomKey should only be present inside encrypted to-device events - ep.On(event.ToDeviceEncrypted, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceRoomKeyRequest, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceRoomKeyWithheld, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceBeeperRoomKeyAck, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceOrgMatrixRoomKeyWithheld, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceVerificationRequest, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceVerificationStart, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceVerificationAccept, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceVerificationKey, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceVerificationMAC, mach.HandleToDeviceEvent) - ep.On(event.ToDeviceVerificationCancel, mach.HandleToDeviceEvent) - ep.OnOTK(mach.HandleOTKCounts) - ep.OnDeviceList(mach.HandleDeviceLists) - mach.Log.Debug().Msg("Added listeners for encryption data coming from appservice transactions") -} - -func (mach *OlmMachine) HandleDeviceLists(ctx context.Context, dl *mautrix.DeviceLists, since string) { - if len(dl.Changed) > 0 { - traceID := time.Now().Format("15:04:05.000000") - mach.Log.Debug(). - Str("trace_id", traceID). - Interface("changes", dl.Changed). - Msg("Device list changes in /sync") - mach.FetchKeys(ctx, dl.Changed, false) - mach.Log.Debug().Str("trace_id", traceID).Msg("Finished handling device list changes") - } -} - -func (mach *OlmMachine) otkCountIsForCrossSigningKey(otkCount *mautrix.OTKCount) bool { - if mach.crossSigningPubkeys == nil || otkCount.UserID != mach.Client.UserID { - return false - } - switch id.Ed25519(otkCount.DeviceID) { - case mach.crossSigningPubkeys.MasterKey, mach.crossSigningPubkeys.UserSigningKey, mach.crossSigningPubkeys.SelfSigningKey: - return true - } - return false -} - -func (mach *OlmMachine) HandleOTKCounts(ctx context.Context, otkCount *mautrix.OTKCount) { - receivedOTKsForSelf := mach.receivedOTKsForSelf.Load() - if (len(otkCount.UserID) > 0 && otkCount.UserID != mach.Client.UserID) || (len(otkCount.DeviceID) > 0 && otkCount.DeviceID != mach.Client.DeviceID) { - if otkCount.UserID != mach.Client.UserID || (!receivedOTKsForSelf && !mach.otkCountIsForCrossSigningKey(otkCount)) { - mach.Log.Warn(). - Str("target_user_id", otkCount.UserID.String()). - Str("target_device_id", otkCount.DeviceID.String()). - Msg("Dropping OTK counts targeted to someone else") - } - return - } else if !receivedOTKsForSelf { - mach.receivedOTKsForSelf.Store(true) - } - - minCount := mach.account.Internal.MaxNumberOfOneTimeKeys() / 2 - if otkCount.SignedCurve25519 < int(minCount) { - traceID := time.Now().Format("15:04:05.000000") - log := mach.Log.With().Str("trace_id", traceID).Logger() - ctx = log.WithContext(ctx) - log.Debug(). - Int("keys_left", otkCount.SignedCurve25519). - Msg("Sync response said we have less than 50 signed curve25519 keys left, sharing new ones...") - err := mach.ShareKeys(ctx, otkCount.SignedCurve25519) - if err != nil { - log.Error().Err(err).Msg("Failed to share keys") - } else { - log.Debug().Msg("Successfully shared keys") - } - } -} - -// ProcessSyncResponse processes a single /sync response. -// -// This can be easily registered into a mautrix client using .OnSync(): -// -// client.Syncer.(mautrix.ExtensibleSyncer).OnSync(c.crypto.ProcessSyncResponse) -func (mach *OlmMachine) ProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) bool { - mach.HandleDeviceLists(ctx, &resp.DeviceLists, since) - - for _, evt := range resp.ToDevice.Events { - evt.Type.Class = event.ToDeviceEventType - err := evt.Content.ParseRaw(evt.Type) - if err != nil { - mach.Log.Warn().Str("event_type", evt.Type.Type).Err(err).Msg("Failed to parse to-device event") - continue - } - mach.HandleToDeviceEvent(ctx, evt) - } - - mach.HandleOTKCounts(ctx, &resp.DeviceOTKCount) - mach.MarkOlmHashSavePoint(ctx) - return true -} - -// HandleMemberEvent handles a single membership event. -// -// Currently, this is not automatically called, so you must add a listener yourself: -// -// client.Syncer.(mautrix.ExtensibleSyncer).OnEventType(event.StateMember, c.crypto.HandleMemberEvent) -func (mach *OlmMachine) HandleMemberEvent(ctx context.Context, evt *event.Event) { - if isEncrypted, err := mach.StateStore.IsEncrypted(ctx, evt.RoomID); err != nil { - mach.machOrContextLog(ctx).Err(err).Stringer("room_id", evt.RoomID). - Msg("Failed to check if room is encrypted to handle member event") - return - } else if !isEncrypted { - return - } - content := evt.Content.AsMember() - if content == nil { - return - } - var prevContent *event.MemberEventContent - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent = evt.Unsigned.PrevContent.AsMember() - } - if prevContent == nil { - prevContent = &event.MemberEventContent{Membership: "unknown"} - } - if prevContent.Membership == content.Membership || - (prevContent.Membership == event.MembershipInvite && content.Membership == event.MembershipJoin) || - (prevContent.Membership == event.MembershipBan && content.Membership == event.MembershipLeave) || - (prevContent.Membership == event.MembershipLeave && content.Membership == event.MembershipBan) { - return - } - // TODO on joins and invites, it would be enough to mark the session as needing re-sharing to that user instead of deleting it - mach.Log.Trace(). - Str("room_id", evt.RoomID.String()). - Str("user_id", evt.GetStateKey()). - Str("prev_membership", string(prevContent.Membership)). - Str("new_membership", string(content.Membership)). - Msg("Got membership state change, invalidating group session in room") - err := mach.CryptoStore.RemoveOutboundGroupSession(ctx, evt.RoomID) - if err != nil { - mach.Log.Warn().Stringer("room_id", evt.RoomID).Msg("Failed to invalidate outbound group session") - } -} - -func (mach *OlmMachine) HandleEncryptedEvent(ctx context.Context, evt *event.Event) *DecryptedOlmEvent { - content, ok := evt.Content.Parsed.(*event.EncryptedEventContent) - if !ok { - mach.machOrContextLog(ctx).Warn().Msg("Passed invalid event to encrypted handler") - return nil - } else if content.Algorithm == id.AlgorithmBeeperStreamV1 { - mach.machOrContextLog(ctx).Debug().Msg("Skipping beeper stream encrypted to-device event in Olm machine") - return nil - } - - decryptedEvt, err := mach.decryptOlmEvent(ctx, evt) - if err != nil { - mach.machOrContextLog(ctx).Error().Err(err).Msg("Failed to decrypt to-device event") - return nil - } - - log := mach.machOrContextLog(ctx).With(). - Str("decrypted_type", decryptedEvt.Type.Type). - Stringer("sender_device", ptr.Val(decryptedEvt.SenderDevice).DeviceID). - Stringer("sender_signing_key", decryptedEvt.Keys.Ed25519). - Logger() - log.Trace().Msg("Successfully decrypted to-device event") - - switch decryptedContent := decryptedEvt.Content.Parsed.(type) { - case *event.RoomKeyEventContent: - mach.receiveRoomKey(ctx, decryptedEvt, decryptedContent) - log.Trace().Msg("Handled room key event") - case *event.ForwardedRoomKeyEventContent: - if mach.importForwardedRoomKey(ctx, decryptedEvt, decryptedContent) { - if ch, ok := mach.roomKeyRequestFilled.Load(decryptedContent.SessionID); ok { - // close channel to notify listener that the key was received - close(ch.(chan struct{})) - } - } - log.Trace().Msg("Handled forwarded room key event") - case *event.DummyEventContent: - log.Debug().Msg("Received encrypted dummy event") - case *event.SecretSendEventContent: - mach.receiveSecret(ctx, decryptedEvt, decryptedContent) - log.Trace().Msg("Handled secret send event") - default: - log.Debug().Msg("Unhandled encrypted to-device event") - return decryptedEvt - } - return nil -} - -const olmHashSavePointCount = 5 -const olmHashDeleteMinInterval = 10 * time.Minute -const minSavePointInterval = 1 * time.Minute - -// MarkOlmHashSavePoint marks the current time as a save point for olm hashes and deletes old hashes if needed. -// -// This should be called after all to-device events in a sync have been processed. -// The function will then delete old olm hashes after enough syncs have happened -// (such that it's unlikely for the olm messages to repeat). -func (mach *OlmMachine) MarkOlmHashSavePoint(ctx context.Context) { - mach.olmHashSavePointLock.Lock() - defer mach.olmHashSavePointLock.Unlock() - if len(mach.olmHashSavePoints) > 0 && time.Since(mach.olmHashSavePoints[len(mach.olmHashSavePoints)-1]) < minSavePointInterval { - return - } - mach.olmHashSavePoints = append(mach.olmHashSavePoints, time.Now()) - if len(mach.olmHashSavePoints) > olmHashSavePointCount { - sp := mach.olmHashSavePoints[0] - mach.olmHashSavePoints = mach.olmHashSavePoints[1:] - if time.Since(mach.lastHashDelete) > olmHashDeleteMinInterval { - err := mach.CryptoStore.DeleteOldOlmHashes(ctx, sp) - mach.lastHashDelete = time.Now() - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to delete old olm hashes") - } - } - } -} - -// HandleToDeviceEvent handles a single to-device event. This is automatically called by ProcessSyncResponse, so you -// don't need to add any custom handlers if you use that method. -func (mach *OlmMachine) HandleToDeviceEvent(ctx context.Context, evt *event.Event) { - if len(evt.ToUserID) > 0 && (evt.ToUserID != mach.Client.UserID || evt.ToDeviceID != mach.Client.DeviceID) { - // TODO This log probably needs to be silence-able if someone wants to use encrypted appservices with multiple e2ee sessions - mach.Log.Debug(). - Str("target_user_id", evt.ToUserID.String()). - Str("target_device_id", evt.ToDeviceID.String()). - Msg("Dropping to-device event targeted to someone else") - return - } - traceID := time.Now().Format("15:04:05.000000") - // TODO use context log? - log := mach.Log.With(). - Str("trace_id", traceID). - Str("sender", evt.Sender.String()). - Str("type", evt.Type.Type). - Logger() - ctx = log.WithContext(ctx) - if evt.Type != event.ToDeviceEncrypted { - log.Debug().Msg("Starting handling to-device event") - } - switch content := evt.Content.Parsed.(type) { - case *event.EncryptedEventContent: - mach.HandleEncryptedEvent(ctx, evt) - return - case *event.RoomKeyRequestEventContent: - go mach.HandleRoomKeyRequest(ctx, evt.Sender, content) - case *event.BeeperRoomKeyAckEventContent: - mach.HandleBeeperRoomKeyAck(ctx, evt.Sender, content) - case *event.RoomKeyWithheldEventContent: - mach.HandleRoomKeyWithheld(ctx, content) - case *event.SecretRequestEventContent: - if content.Action == event.SecretRequestRequest { - mach.HandleSecretRequest(ctx, evt.Sender, content) - log.Trace().Msg("Handled secret request event") - } - default: - deviceID, _ := evt.Content.Raw["device_id"].(string) - log.Debug().Str("maybe_device_id", deviceID).Msg("Unhandled to-device event") - return - } - log.Debug().Msg("Finished handling to-device event") -} - -// GetOrFetchDevice attempts to retrieve the device identity for the given device from the store -// and if it's not found it asks the server for it. -func (mach *OlmMachine) GetOrFetchDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*id.Device, error) { - device, err := mach.CryptoStore.GetDevice(ctx, userID, deviceID) - if err != nil { - return nil, fmt.Errorf("failed to get sender device from store: %w", err) - } else if device != nil { - return device, nil - } - if usersToDevices, err := mach.FetchKeys(ctx, []id.UserID{userID}, true); err != nil { - return nil, fmt.Errorf("failed to fetch keys: %w", err) - } else if devices, ok := usersToDevices[userID]; ok { - if device, ok = devices[deviceID]; ok { - return device, nil - } - return nil, fmt.Errorf("didn't get identity for device %s of %s", deviceID, userID) - } - return nil, fmt.Errorf("didn't get any devices for %s", userID) -} - -// GetOrFetchDeviceByKey attempts to retrieve the device identity for the device with the given identity key from the -// store and if it's not found it asks the server for it. This returns nil if the server doesn't return a device with -// the given identity key. -func (mach *OlmMachine) GetOrFetchDeviceByKey(ctx context.Context, userID id.UserID, identityKey id.IdentityKey) (*id.Device, error) { - deviceIdentity, err := mach.CryptoStore.FindDeviceByKey(ctx, userID, identityKey) - if err != nil || deviceIdentity != nil { - return deviceIdentity, err - } - mach.machOrContextLog(ctx).Debug(). - Str("user_id", userID.String()). - Str("identity_key", identityKey.String()). - Msg("Didn't find identity in crypto store, fetching from server") - devices := mach.LoadDevices(ctx, userID) - for _, device := range devices { - if device.IdentityKey == identityKey { - return device, nil - } - } - return nil, nil -} - -// SendEncryptedToDevice sends an Olm-encrypted event to the given user device. -func (mach *OlmMachine) SendEncryptedToDevice(ctx context.Context, device *id.Device, evtType event.Type, content event.Content) error { - if err := mach.createOutboundSessions(ctx, map[id.UserID]map[id.DeviceID]*id.Device{ - device.UserID: { - device.DeviceID: device, - }, - }); err != nil { - return err - } - - mach.olmLock.Lock() - defer mach.olmLock.Unlock() - - olmSess, err := mach.CryptoStore.GetLatestSession(ctx, device.IdentityKey) - if err != nil { - return err - } - if olmSess == nil { - return fmt.Errorf("didn't find created outbound session for device %s of %s", device.DeviceID, device.UserID) - } - - encrypted := mach.encryptOlmEvent(ctx, olmSess, device, evtType, content) - encryptedContent := &event.Content{Parsed: &encrypted} - - mach.machOrContextLog(ctx).Debug(). - Str("decrypted_type", evtType.Type). - Str("to_user_id", device.UserID.String()). - Str("to_device_id", device.DeviceID.String()). - Str("to_identity_key", device.IdentityKey.String()). - Str("olm_session_id", olmSess.ID().String()). - Msg("Sending encrypted to-device event") - _, err = mach.Client.SendToDevice(ctx, event.ToDeviceEncrypted, - &mautrix.ReqSendToDevice{ - Messages: map[id.UserID]map[id.DeviceID]*event.Content{ - device.UserID: { - device.DeviceID: encryptedContent, - }, - }, - }, - ) - - return err -} - -func (mach *OlmMachine) createGroupSession(ctx context.Context, senderKey id.SenderKey, signingKey id.Ed25519, roomID id.RoomID, sessionID id.SessionID, sessionKey string, maxAge time.Duration, maxMessages int, isScheduled bool) error { - log := zerolog.Ctx(ctx) - igs, err := NewInboundGroupSession(senderKey, signingKey, roomID, sessionKey, maxAge, maxMessages, isScheduled) - if err != nil { - return fmt.Errorf("failed to create inbound group session: %w", err) - } else if igs.ID() != sessionID { - log.Warn(). - Str("expected_session_id", sessionID.String()). - Str("actual_session_id", igs.ID().String()). - Msg("Mismatched session ID while creating inbound group session") - return fmt.Errorf("mismatched session ID while creating inbound group session") - } - err = mach.CryptoStore.PutGroupSession(ctx, igs) - if err != nil { - log.Err(err).Stringer("session_id", sessionID).Msg("Failed to store new inbound group session") - return fmt.Errorf("failed to store new inbound group session: %w", err) - } - mach.MarkSessionReceived(ctx, roomID, sessionID, igs.Internal.FirstKnownIndex()) - log.Debug(). - Str("session_id", sessionID.String()). - Str("sender_key", senderKey.String()). - Str("max_age", maxAge.String()). - Int("max_messages", maxMessages). - Bool("is_scheduled", isScheduled). - Msg("Received inbound group session") - return nil -} - -func (mach *OlmMachine) MarkSessionReceived(ctx context.Context, roomID id.RoomID, id id.SessionID, firstKnownIndex uint32) { - if mach.SessionReceived != nil { - mach.SessionReceived(ctx, roomID, id, firstKnownIndex) - } - - mach.keyWaitersLock.Lock() - ch, ok := mach.keyWaiters[id] - if ok { - close(ch) - delete(mach.keyWaiters, id) - } - mach.keyWaitersLock.Unlock() -} - -// WaitForSession waits for the given Megolm session to arrive. -func (mach *OlmMachine) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool { - mach.keyWaitersLock.Lock() - ch, ok := mach.keyWaiters[sessionID] - if !ok { - ch = make(chan struct{}) - mach.keyWaiters[sessionID] = ch - } - mach.keyWaitersLock.Unlock() - // Handle race conditions where a session appears between the failed decryption and WaitForSession call. - sess, err := mach.CryptoStore.GetGroupSession(ctx, roomID, sessionID) - if sess != nil || errors.Is(err, ErrGroupSessionWithheld) { - return true - } - select { - case <-ch: - return true - case <-time.After(timeout): - sess, err = mach.CryptoStore.GetGroupSession(ctx, roomID, sessionID) - // Check if the session somehow appeared in the store without telling us - // We accept withheld sessions as received, as then the decryption attempt will show the error. - return sess != nil || errors.Is(err, ErrGroupSessionWithheld) - case <-ctx.Done(): - return false - } -} - -func (mach *OlmMachine) receiveRoomKey(ctx context.Context, evt *DecryptedOlmEvent, content *event.RoomKeyEventContent) { - log := zerolog.Ctx(ctx).With(). - Str("algorithm", string(content.Algorithm)). - Str("session_id", content.SessionID.String()). - Str("room_id", content.RoomID.String()). - Logger() - if content.Algorithm != id.AlgorithmMegolmV1 || evt.Keys.Ed25519 == "" { - log.Debug().Msg("Ignoring weird room key") - return - } - - config, err := mach.StateStore.GetEncryptionEvent(ctx, content.RoomID) - if err != nil { - log.Error().Err(err).Msg("Failed to get encryption event for room") - } - var maxAge time.Duration - var maxMessages int - if config != nil { - maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond - if maxAge == 0 { - maxAge = 7 * 24 * time.Hour - } - maxMessages = config.RotationPeriodMessages - if maxMessages == 0 { - maxMessages = 100 - } - } - if content.MaxAge != 0 { - maxAge = time.Duration(content.MaxAge) * time.Millisecond - } - if content.MaxMessages != 0 { - maxMessages = content.MaxMessages - } - if mach.DeletePreviousKeysOnReceive && !content.IsScheduled { - log.Debug().Msg("Redacting previous megolm sessions from sender in room") - sessionIDs, err := mach.CryptoStore.RedactGroupSessions(ctx, content.RoomID, evt.SenderKey, "received new key from device") - if err != nil { - log.Err(err).Msg("Failed to redact previous megolm sessions") - } else { - log.Info(). - Array("session_ids", exzerolog.ArrayOfStrs(sessionIDs)). - Msg("Redacted previous megolm sessions") - } - } - err = mach.createGroupSession(ctx, evt.SenderKey, evt.Keys.Ed25519, content.RoomID, content.SessionID, content.SessionKey, maxAge, maxMessages, content.IsScheduled) - if err != nil { - log.Err(err).Msg("Failed to create inbound group session") - } -} - -func (mach *OlmMachine) HandleRoomKeyWithheld(ctx context.Context, content *event.RoomKeyWithheldEventContent) { - if content.Algorithm != id.AlgorithmMegolmV1 { - zerolog.Ctx(ctx).Debug().Interface("content", content).Msg("Non-megolm room key withheld event") - return - } - // TODO log if there's a conflict? (currently ignored) - err := mach.CryptoStore.PutWithheldGroupSession(ctx, *content) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to save room key withheld event") - } -} - -func (mach *OlmMachine) getKeysForOlmMessage(ctx context.Context) *mautrix.DeviceKeys { - if keys := mach.ownDeviceKeysCache.Load(); keys != nil { - return keys - } - keys := mach.account.getInitialKeys(mach.Client.UserID, mach.Client.DeviceID) - csKeys, err := mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to get own cross signing keys") - return keys - } else if csKeys == nil { - mach.ownDeviceKeysCache.Store(keys) - return keys - } - sigs, err := mach.CryptoStore.GetSignaturesForKeyBy(ctx, mach.Client.UserID, keys.Keys.GetEd25519(mach.Client.DeviceID), mach.Client.UserID) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to get signatures for own device keys") - return keys - } - selfSig, ok := sigs[csKeys.SelfSigningKey] - if ok { - keys.Signatures[mach.Client.UserID][id.NewKeyID(id.KeyAlgorithmEd25519, csKeys.SelfSigningKey.String())] = selfSig - } - mach.ownDeviceKeysCache.Store(keys) - return keys -} - -// ShareKeys uploads necessary keys to the server. -// -// If the Olm account hasn't been shared, the account keys will be uploaded. -// If currentOTKCount is less than half of the limit (100 / 2 = 50), enough one-time keys will be uploaded so exactly -// half of the limit is filled. -func (mach *OlmMachine) ShareKeys(ctx context.Context, currentOTKCount int) error { - log := mach.machOrContextLog(ctx) - start := time.Now() - mach.otkUploadLock.Lock() - defer mach.otkUploadLock.Unlock() - if mach.lastOTKUpload.Add(1*time.Minute).After(start) || (currentOTKCount < 0 && mach.account.Shared) { - log.Debug().Msg("Checking OTK count from server due to suspiciously close share keys requests or negative OTK count") - resp, err := mach.Client.UploadKeys(ctx, &mautrix.ReqUploadKeys{}) - if err != nil { - return fmt.Errorf("failed to check current OTK counts: %w", err) - } - log.Debug(). - Int("input_count", currentOTKCount). - Int("server_count", resp.OneTimeKeyCounts.SignedCurve25519). - Msg("Fetched current OTK count from server") - currentOTKCount = resp.OneTimeKeyCounts.SignedCurve25519 - } - var deviceKeys *mautrix.DeviceKeys - if !mach.account.Shared { - deviceKeys = mach.account.getInitialKeys(mach.Client.UserID, mach.Client.DeviceID) - err := mach.CryptoStore.PutDevice(ctx, mach.Client.UserID, &id.Device{ - UserID: mach.Client.UserID, - DeviceID: mach.Client.DeviceID, - IdentityKey: deviceKeys.Keys.GetCurve25519(mach.Client.DeviceID), - SigningKey: deviceKeys.Keys.GetEd25519(mach.Client.DeviceID), - }) - if err != nil { - return fmt.Errorf("failed to save initial keys: %w", err) - } - log.Debug().Msg("Going to upload initial account keys") - } - oneTimeKeys := mach.account.getOneTimeKeys(mach.Client.UserID, mach.Client.DeviceID, currentOTKCount) - if len(oneTimeKeys) == 0 && deviceKeys == nil { - log.Debug().Msg("No one-time keys nor device keys got when trying to share keys") - return nil - } - // Save the keys before sending the upload request in case there is a - // network failure. - if err := mach.saveAccount(ctx); err != nil { - return err - } - req := &mautrix.ReqUploadKeys{ - DeviceKeys: deviceKeys, - OneTimeKeys: oneTimeKeys, - } - log.Debug().Int("count", len(oneTimeKeys)).Msg("Uploading one-time keys") - _, err := mach.Client.UploadKeys(ctx, req) - if err != nil { - return err - } - mach.lastOTKUpload = time.Now() - mach.account.Internal.MarkKeysAsPublished() - mach.account.Shared = true - return mach.saveAccount(ctx) -} - -func (mach *OlmMachine) ExpiredKeyDeleteLoop(ctx context.Context) { - log := mach.Log.With().Str("action", "redact expired sessions").Logger() - for { - sessionIDs, err := mach.CryptoStore.RedactExpiredGroupSessions(ctx) - if err != nil { - log.Err(err).Msg("Failed to redact expired megolm sessions") - } else if len(sessionIDs) > 0 { - log.Info().Array("session_ids", exzerolog.ArrayOfStrs(sessionIDs)).Msg("Redacted expired megolm sessions") - } else { - log.Debug().Msg("Didn't find any expired megolm sessions") - } - select { - case <-ctx.Done(): - log.Debug().Msg("Loop stopped") - return - case <-time.After(24 * time.Hour): - } - } -} diff --git a/mautrix-patched/crypto/machine_bench_test.go b/mautrix-patched/crypto/machine_bench_test.go deleted file mode 100644 index fd40d795..00000000 --- a/mautrix-patched/crypto/machine_bench_test.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto_test - -import ( - "context" - "fmt" - "math/rand/v2" - "testing" - - "github.com/rs/zerolog" - globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/cryptohelper" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/mockserver" -) - -func randomDeviceCount(r *rand.Rand) int { - k := 1 - for k < 10 && r.IntN(3) > 0 { - k++ - } - return k -} - -func BenchmarkOlmMachine_ShareGroupSession(b *testing.B) { - globallog.Logger = zerolog.Nop() - server := mockserver.Create(b) - server.PopOTKs = false - server.MemoryStore = false - var i int - var shareTargets []id.UserID - r := rand.New(rand.NewPCG(293, 0)) - var totalDeviceCount int - for i = 1; i < 1000; i++ { - userID := id.UserID(fmt.Sprintf("@user%d:localhost", i)) - deviceCount := randomDeviceCount(r) - for j := 0; j < deviceCount; j++ { - client, _ := server.Login(b, nil, userID, id.DeviceID(fmt.Sprintf("u%d_d%d", i, j))) - mach := client.Crypto.(*cryptohelper.CryptoHelper).Machine() - keysCache, err := mach.GenerateCrossSigningKeys() - require.NoError(b, err) - err = mach.PublishCrossSigningKeys(context.TODO(), keysCache, nil) - require.NoError(b, err) - } - totalDeviceCount += deviceCount - shareTargets = append(shareTargets, userID) - } - for b.Loop() { - client, _ := server.Login(b, nil, id.UserID(fmt.Sprintf("@benchuser%d:localhost", i)), id.DeviceID(fmt.Sprintf("u%d_d1", i))) - mach := client.Crypto.(*cryptohelper.CryptoHelper).Machine() - keysCache, err := mach.GenerateCrossSigningKeys() - require.NoError(b, err) - err = mach.PublishCrossSigningKeys(context.TODO(), keysCache, nil) - require.NoError(b, err) - err = mach.ShareGroupSession(context.TODO(), "!room:localhost", shareTargets) - require.NoError(b, err) - i++ - } - fmt.Println(totalDeviceCount, "devices total") -} diff --git a/mautrix-patched/crypto/machine_test.go b/mautrix-patched/crypto/machine_test.go deleted file mode 100644 index 872c3ac4..00000000 --- a/mautrix-patched/crypto/machine_test.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type mockStateStore struct{} - -func (mockStateStore) IsEncrypted(context.Context, id.RoomID) (bool, error) { - return true, nil -} - -func (mockStateStore) GetEncryptionEvent(context.Context, id.RoomID) (*event.EncryptionEventContent, error) { - return &event.EncryptionEventContent{ - RotationPeriodMessages: 3, - }, nil -} - -func (mockStateStore) FindSharedRooms(context.Context, id.UserID) ([]id.RoomID, error) { - return []id.RoomID{"room1"}, nil -} - -func newMachine(t *testing.T, userID id.UserID) *OlmMachine { - client, err := mautrix.NewClient("http://localhost", userID, "token") - require.NoError(t, err, "Error creating client") - client.DeviceID = "device1" - - gobStore := NewMemoryStore(nil) - require.NoError(t, err, "Error creating Gob store") - - machine := NewOlmMachine(client, nil, gobStore, mockStateStore{}) - err = machine.Load(context.TODO()) - require.NoError(t, err, "Error creating account") - - return machine -} - -func TestRatchetMegolmSession(t *testing.T) { - mach := newMachine(t, "user1") - outSess, err := mach.newOutboundGroupSession(context.TODO(), "meow") - assert.NoError(t, err) - inSess, err := mach.CryptoStore.GetGroupSession(context.TODO(), "meow", outSess.ID()) - require.NoError(t, err) - assert.Equal(t, uint32(0), inSess.Internal.FirstKnownIndex()) - err = inSess.RatchetTo(10) - assert.NoError(t, err) - assert.Equal(t, uint32(10), inSess.Internal.FirstKnownIndex()) -} - -func TestOlmMachineOlmMegolmSessions(t *testing.T) { - machineOut := newMachine(t, "user1") - machineIn := newMachine(t, "user2") - - // generate OTKs for receiving machine - otks := machineIn.account.getOneTimeKeys("user2", "device2", 0) - var otk mautrix.OneTimeKey - for _, otkTmp := range otks { - // take first OTK - otk = otkTmp - break - } - machineIn.account.Internal.MarkKeysAsPublished() - - // create outbound olm session for sending machine using OTK - olmSession, err := machineOut.account.Internal.NewOutboundSession(machineIn.account.IdentityKey(), otk.Key) - require.NoError(t, err, "Error creating outbound olm session") - - // store sender device identity in receiving machine store - machineIn.CryptoStore.PutDevices(context.TODO(), "user1", map[id.DeviceID]*id.Device{ - "device1": { - UserID: "user1", - DeviceID: "device1", - IdentityKey: machineOut.account.IdentityKey(), - SigningKey: machineOut.account.SigningKey(), - }, - }) - - // create & store outbound megolm session for sending the event later - megolmOutSession, err := machineOut.newOutboundGroupSession(context.TODO(), "room1") - assert.NoError(t, err) - megolmOutSession.Shared = true - machineOut.CryptoStore.AddOutboundGroupSession(context.TODO(), megolmOutSession) - - // encrypt m.room_key event with olm session - deviceIdentity := &id.Device{ - UserID: "user2", - DeviceID: "device2", - IdentityKey: machineIn.account.IdentityKey(), - SigningKey: machineIn.account.SigningKey(), - } - wrapped := wrapSession(olmSession) - content := machineOut.encryptOlmEvent(context.TODO(), wrapped, deviceIdentity, event.ToDeviceRoomKey, megolmOutSession.ShareContent()) - - senderKey := machineOut.account.IdentityKey() - signingKey := machineOut.account.SigningKey() - - for _, content := range content.OlmCiphertext { - // decrypt olm ciphertext - decrypted, err := machineIn.decryptAndParseOlmCiphertext(context.TODO(), &event.Event{ - Type: event.ToDeviceEncrypted, - Sender: "user1", - }, senderKey, content.Type, content.Body) - require.NoError(t, err, "Error decrypting olm ciphertext") - - // store room key in new inbound group session - roomKeyEvt := decrypted.Content.AsRoomKey() - igs, err := NewInboundGroupSession(senderKey, signingKey, "room1", roomKeyEvt.SessionKey, 0, 0, false) - require.NoError(t, err, "Error creating inbound group session") - err = machineIn.CryptoStore.PutGroupSession(context.TODO(), igs) - require.NoError(t, err, "Error storing inbound group session") - } - - // encrypt event with megolm session in sending machine - eventContent := map[string]string{"hello": "world"} - encryptedEvtContent, err := machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) - require.NoError(t, err, "Error encrypting megolm event") - assert.Equal(t, 1, megolmOutSession.MessageCount) - - encryptedEvt := &event.Event{ - Content: event.Content{Parsed: encryptedEvtContent}, - Type: event.EventEncrypted, - ID: "event1", - RoomID: "room1", - Sender: "user1", - } - - // decrypt event on receiving machine and confirm - decryptedEvt, err := machineIn.DecryptMegolmEvent(context.TODO(), encryptedEvt) - require.NoError(t, err, "Error decrypting megolm event") - assert.Equal(t, event.EventMessage, decryptedEvt.Type) - assert.Equal(t, "world", decryptedEvt.Content.Raw["hello"]) - - machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) - assert.False(t, megolmOutSession.Expired(), "Megolm outbound session expired before 3rd message") - machineOut.EncryptMegolmEvent(context.TODO(), "room1", event.EventMessage, eventContent) - assert.True(t, megolmOutSession.Expired(), "Megolm outbound session not expired after 3rd message") -} diff --git a/mautrix-patched/crypto/olm/README.md b/mautrix-patched/crypto/olm/README.md deleted file mode 100644 index 7d8086c0..00000000 --- a/mautrix-patched/crypto/olm/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Go olm bindings -Based on [Dhole/go-olm](https://github.com/Dhole/go-olm) - -The original project is licensed under the Apache 2.0 license. diff --git a/mautrix-patched/crypto/olm/account.go b/mautrix-patched/crypto/olm/account.go deleted file mode 100644 index 863abfb7..00000000 --- a/mautrix-patched/crypto/olm/account.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -import ( - "maunium.net/go/mautrix/id" -) - -type Account interface { - // Pickle returns an Account as a base64 string. Encrypts the Account using the - // supplied key. - Pickle(key []byte) ([]byte, error) - - // Unpickle loads an Account from a pickled base64 string. Decrypts the - // Account using the supplied key. Returns error on failure. - Unpickle(pickled, key []byte) error - - // IdentityKeysJSON returns the public parts of the identity keys for the Account. - IdentityKeysJSON() ([]byte, error) - - // IdentityKeys returns the public parts of the Ed25519 and Curve25519 identity - // keys for the Account. - IdentityKeys() (id.Ed25519, id.Curve25519, error) - - // Sign returns the signature of a message using the ed25519 key for this - // Account. - Sign(message []byte) ([]byte, error) - - // OneTimeKeys returns the public parts of the unpublished one time keys for - // the Account. - // - // The returned data is a struct with the single value "Curve25519", which is - // itself an object mapping key id to base64-encoded Curve25519 key. For - // example: - // - // { - // Curve25519: { - // "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo", - // "AAAAAB": "LRvjo46L1X2vx69sS9QNFD29HWulxrmW11Up5AfAjgU" - // } - // } - OneTimeKeys() (map[string]id.Curve25519, error) - - // MarkKeysAsPublished marks the current set of one time keys as being - // published. - MarkKeysAsPublished() - - // MaxNumberOfOneTimeKeys returns the largest number of one time keys this - // Account can store. - MaxNumberOfOneTimeKeys() uint - - // GenOneTimeKeys generates a number of new one time keys. If the total - // number of keys stored by this Account exceeds MaxNumberOfOneTimeKeys - // then the old keys are discarded. - GenOneTimeKeys(num uint) error - - // NewOutboundSession creates a new out-bound session for sending messages to a - // given curve25519 identityKey and oneTimeKey. Returns error on failure. If the - // keys couldn't be decoded as base64 then the error will be "INVALID_BASE64" - NewOutboundSession(theirIdentityKey, theirOneTimeKey id.Curve25519) (Session, error) - - // NewInboundSession creates a new in-bound session for sending/receiving - // messages from an incoming PRE_KEY message. Returns error on failure. If - // the base64 couldn't be decoded then the error will be "INVALID_BASE64". If - // the message was for an unsupported protocol version then the error will be - // "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the - // error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one - // time key then the error will be "BAD_MESSAGE_KEY_ID". - NewInboundSession(oneTimeKeyMsg string) (Session, error) - - // NewInboundSessionFrom creates a new in-bound session for sending/receiving - // messages from an incoming PRE_KEY message. Returns error on failure. If - // the base64 couldn't be decoded then the error will be "INVALID_BASE64". If - // the message was for an unsupported protocol version then the error will be - // "BAD_MESSAGE_VERSION". If the message couldn't be decoded then then the - // error will be "BAD_MESSAGE_FORMAT". If the message refers to an unknown one - // time key then the error will be "BAD_MESSAGE_KEY_ID". - NewInboundSessionFrom(theirIdentityKey *id.Curve25519, oneTimeKeyMsg string) (Session, error) - - // RemoveOneTimeKeys removes the one time keys that the session used from the - // Account. Returns error on failure. If the Account doesn't have any - // matching one time keys then the error will be "BAD_MESSAGE_KEY_ID". - // - // Note: this must only be called after successfully decrypting a message with the session, - // as otherwise an attacker can cause the client to drop any one-time key by sending an - // invalid message with a valid one-time key id. - RemoveOneTimeKeys(s Session) error -} - -var Driver = "none" - -var InitBlankAccount func() Account -var InitNewAccount func() (Account, error) -var InitNewAccountFromPickled func(pickled, key []byte) (Account, error) - -// NewAccount creates a new Account. -func NewAccount() (Account, error) { - return InitNewAccount() -} - -func NewBlankAccount() Account { - return InitBlankAccount() -} - -// AccountFromPickled loads an Account from a pickled base64 string. Decrypts -// the Account using the supplied key. Returns error on failure. If the key -// doesn't match the one used to encrypt the Account then the error will be -// "BAD_ACCOUNT_KEY". If the base64 couldn't be decoded then the error will be -// "INVALID_BASE64". -func AccountFromPickled(pickled, key []byte) (Account, error) { - return InitNewAccountFromPickled(pickled, key) -} diff --git a/mautrix-patched/crypto/olm/account_test.go b/mautrix-patched/crypto/olm/account_test.go deleted file mode 100644 index 736dc900..00000000 --- a/mautrix-patched/crypto/olm/account_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build !goolm - -package olm_test - -import ( - "bytes" - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/crypto/ed25519" - "maunium.net/go/mautrix/crypto/goolm/account" - "maunium.net/go/mautrix/crypto/libolm" - "maunium.net/go/mautrix/crypto/olm" -) - -func ensureAccountsEqual(t *testing.T, a, b olm.Account) { - t.Helper() - - assert.Equal(t, a.MaxNumberOfOneTimeKeys(), b.MaxNumberOfOneTimeKeys()) - - aEd25519, aCurve25519, err := a.IdentityKeys() - require.NoError(t, err) - bEd25519, bCurve25519, err := b.IdentityKeys() - require.NoError(t, err) - assert.Equal(t, aEd25519, bEd25519) - assert.Equal(t, aCurve25519, bCurve25519) - - aIdentityKeysJSON, err := a.IdentityKeysJSON() - require.NoError(t, err) - bIdentityKeysJSON, err := b.IdentityKeysJSON() - require.NoError(t, err) - assert.JSONEq(t, string(aIdentityKeysJSON), string(bIdentityKeysJSON)) - - aOTKs, err := a.OneTimeKeys() - require.NoError(t, err) - bOTKs, err := b.OneTimeKeys() - require.NoError(t, err) - assert.Equal(t, aOTKs, bOTKs) -} - -// TestAccount_UnpickleLibolmToGoolm tests creating an account from libolm, -// pickling it, and importing it into goolm. -func TestAccount_UnpickleLibolmToGoolm(t *testing.T) { - libolmAccount, err := libolm.NewAccount() - require.NoError(t, err) - - require.NoError(t, libolmAccount.GenOneTimeKeys(50)) - - libolmPickled, err := libolmAccount.Pickle([]byte("test")) - require.NoError(t, err) - - goolmAccount, err := account.AccountFromPickled(libolmPickled, []byte("test")) - require.NoError(t, err) - - ensureAccountsEqual(t, libolmAccount, goolmAccount) - - goolmPickled, err := goolmAccount.Pickle([]byte("test")) - require.NoError(t, err) - assert.Equal(t, libolmPickled, goolmPickled) -} - -// TestAccount_UnpickleGoolmToLibolm tests creating an account from goolm, -// pickling it, and importing it into libolm. -func TestAccount_UnpickleGoolmToLibolm(t *testing.T) { - goolmAccount, err := account.NewAccount() - require.NoError(t, err) - - require.NoError(t, goolmAccount.GenOneTimeKeys(50)) - - goolmPickled, err := goolmAccount.Pickle([]byte("test")) - require.NoError(t, err) - - libolmAccount, err := libolm.AccountFromPickled(bytes.Clone(goolmPickled), []byte("test")) - require.NoError(t, err) - - ensureAccountsEqual(t, libolmAccount, goolmAccount) - - libolmPickled, err := libolmAccount.Pickle([]byte("test")) - require.NoError(t, err) - assert.Equal(t, goolmPickled, libolmPickled) -} - -func FuzzAccount_Sign(f *testing.F) { - f.Add([]byte("anything")) - - libolmAccount := exerrors.Must(libolm.NewAccount()) - goolmAccount := exerrors.Must(account.AccountFromPickled(exerrors.Must(libolmAccount.Pickle([]byte("test"))), []byte("test"))) - - f.Fuzz(func(t *testing.T, message []byte) { - if len(message) == 0 { - t.Skip("empty message is not supported") - } - - libolmSignature, err := libolmAccount.Sign(bytes.Clone(message)) - require.NoError(t, err) - goolmSignature, err := goolmAccount.Sign(bytes.Clone(message)) - require.NoError(t, err) - assert.Equal(t, goolmSignature, libolmSignature) - - goolmSignatureBytes, err := base64.RawStdEncoding.DecodeString(string(goolmSignature)) - require.NoError(t, err) - libolmSignatureBytes, err := base64.RawStdEncoding.DecodeString(string(libolmSignature)) - require.NoError(t, err) - - libolmEd25519, _, err := libolmAccount.IdentityKeys() - require.NoError(t, err) - - assert.True(t, ed25519.Verify(ed25519.PublicKey(libolmEd25519.Bytes()), message, libolmSignatureBytes)) - assert.True(t, ed25519.Verify(ed25519.PublicKey(libolmEd25519.Bytes()), message, goolmSignatureBytes)) - - assert.True(t, goolmAccount.IdKeys.Ed25519.Verify(bytes.Clone(message), libolmSignatureBytes)) - assert.True(t, goolmAccount.IdKeys.Ed25519.Verify(bytes.Clone(message), goolmSignatureBytes)) - }) -} diff --git a/mautrix-patched/crypto/olm/errors.go b/mautrix-patched/crypto/olm/errors.go deleted file mode 100644 index 9e522b2a..00000000 --- a/mautrix-patched/crypto/olm/errors.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -import "errors" - -// Those are the most common used errors -var ( - ErrBadSignature = errors.New("bad signature") - ErrBadMAC = errors.New("the message couldn't be decrypted (bad mac)") - ErrBadMessageFormat = errors.New("the message couldn't be decoded") - ErrBadVerification = errors.New("bad verification") - ErrWrongProtocolVersion = errors.New("wrong protocol version") - ErrEmptyInput = errors.New("empty input") - ErrNoKeyProvided = errors.New("no key provided") - ErrBadMessageKeyID = errors.New("the message references an unknown key ID") - ErrUnknownMessageIndex = errors.New("attempt to decode a message whose index is earlier than our earliest known session key") - ErrMsgIndexTooHigh = errors.New("message index too high") - ErrProtocolViolation = errors.New("not protocol message order") - ErrMessageKeyNotFound = errors.New("message key not found") - ErrChainTooHigh = errors.New("chain index too high") - ErrBadInput = errors.New("bad input") - ErrUnknownOlmPickleVersion = errors.New("unknown olm pickle version") - ErrUnknownJSONPickleVersion = errors.New("unknown JSON pickle version") - ErrInputToSmall = errors.New("input too small (truncated?)") -) - -// Error codes from go-olm -var ( - ErrNotEnoughGoRandom = errors.New("couldn't get enough randomness from crypto/rand") - ErrInputNotJSONString = errors.New("input doesn't look like a JSON string") -) - -// Error codes from olm code -var ( - ErrLibolmInvalidBase64 = errors.New("the input base64 was invalid") - - ErrLibolmNotEnoughRandom = errors.New("not enough entropy was supplied") - ErrLibolmOutputBufferTooSmall = errors.New("supplied output buffer is too small") - ErrLibolmBadAccountKey = errors.New("the supplied account key is invalid") - ErrLibolmCorruptedPickle = errors.New("the pickled object couldn't be decoded") - ErrLibolmBadSessionKey = errors.New("attempt to initialise an inbound group session from an invalid session key") - ErrLibolmBadLegacyAccountPickle = errors.New("attempt to unpickle an account which uses pickle version 1") -) - -// Deprecated: use variables prefixed with Err -var ( - EmptyInput = ErrEmptyInput - BadSignature = ErrBadSignature - InvalidBase64 = ErrLibolmInvalidBase64 - BadMessageKeyID = ErrBadMessageKeyID - BadMessageFormat = ErrBadMessageFormat - BadMessageVersion = ErrWrongProtocolVersion - BadMessageMAC = ErrBadMAC - UnknownPickleVersion = ErrUnknownOlmPickleVersion - NotEnoughRandom = ErrLibolmNotEnoughRandom - OutputBufferTooSmall = ErrLibolmOutputBufferTooSmall - BadAccountKey = ErrLibolmBadAccountKey - CorruptedPickle = ErrLibolmCorruptedPickle - BadSessionKey = ErrLibolmBadSessionKey - UnknownMessageIndex = ErrUnknownMessageIndex - BadLegacyAccountPickle = ErrLibolmBadLegacyAccountPickle - InputBufferTooSmall = ErrInputToSmall - NoKeyProvided = ErrNoKeyProvided - - NotEnoughGoRandom = ErrNotEnoughGoRandom - InputNotJSONString = ErrInputNotJSONString - - ErrBadVersion = ErrUnknownJSONPickleVersion - ErrWrongPickleVersion = ErrUnknownJSONPickleVersion - ErrRatchetNotAvailable = ErrUnknownMessageIndex -) diff --git a/mautrix-patched/crypto/olm/groupsession_test.go b/mautrix-patched/crypto/olm/groupsession_test.go deleted file mode 100644 index 9dbfb9e5..00000000 --- a/mautrix-patched/crypto/olm/groupsession_test.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build !goolm - -package olm_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/libolm" -) - -// TestEncryptDecrypt_GoolmToLibolm tests encryption where goolm encrypts and libolm decrypts -func TestEncryptDecrypt_GoolmToLibolm(t *testing.T) { - goolmOutbound, err := session.NewMegolmOutboundSession() - require.NoError(t, err) - - libolmInbound, err := libolm.NewInboundGroupSession([]byte(goolmOutbound.Key())) - require.NoError(t, err) - - for i := 0; i < 10; i++ { - ciphertext, err := goolmOutbound.Encrypt([]byte(fmt.Sprintf("message %d", i))) - require.NoError(t, err) - - plaintext, msgIdx, err := libolmInbound.Decrypt(ciphertext) - assert.NoError(t, err) - assert.Equal(t, []byte(fmt.Sprintf("message %d", i)), plaintext) - assert.Equal(t, goolmOutbound.MessageIndex()-1, msgIdx) - } -} - -func TestEncryptDecrypt_LibolmToGoolm(t *testing.T) { - libolmOutbound, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - goolmInbound, err := session.NewMegolmInboundSession([]byte(libolmOutbound.Key())) - require.NoError(t, err) - - for i := 0; i < 10; i++ { - ciphertext, err := libolmOutbound.Encrypt([]byte(fmt.Sprintf("message %d", i))) - require.NoError(t, err) - - plaintext, msgIdx, err := goolmInbound.Decrypt(ciphertext) - assert.NoError(t, err) - assert.Equal(t, []byte(fmt.Sprintf("message %d", i)), plaintext) - assert.Equal(t, libolmOutbound.MessageIndex()-1, msgIdx) - } -} diff --git a/mautrix-patched/crypto/olm/inboundgroupsession.go b/mautrix-patched/crypto/olm/inboundgroupsession.go deleted file mode 100644 index 8839b48c..00000000 --- a/mautrix-patched/crypto/olm/inboundgroupsession.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -import "maunium.net/go/mautrix/id" - -type InboundGroupSession interface { - // Pickle returns an InboundGroupSession as a base64 string. Encrypts the - // InboundGroupSession using the supplied key. - Pickle(key []byte) ([]byte, error) - - // Unpickle loads an [InboundGroupSession] from a pickled base64 string. - // Decrypts the [InboundGroupSession] using the supplied key. - Unpickle(pickled, key []byte) error - - // Decrypt decrypts a message using the [InboundGroupSession]. Returns the - // plain-text and message index on success. Returns error on failure. If - // the base64 couldn't be decoded then the error will be "INVALID_BASE64". - // If the message is for an unsupported version of the protocol then the - // error will be "BAD_MESSAGE_VERSION". If the message couldn't be decoded - // then the error will be BAD_MESSAGE_FORMAT". If the MAC on the message - // was invalid then the error will be "BAD_MESSAGE_MAC". If we do not have - // a session key corresponding to the message's index (ie, it was sent - // before the session key was shared with us) the error will be - // "OLM_UNKNOWN_MESSAGE_INDEX". - Decrypt(message []byte) ([]byte, uint, error) - - // ID returns a base64-encoded identifier for this session. - ID() id.SessionID - - // FirstKnownIndex returns the first message index we know how to decrypt. - FirstKnownIndex() uint32 - - // IsVerified check if the session has been verified as a valid session. - // (A session is verified either because the original session share was - // signed, or because we have subsequently successfully decrypted a - // message.) - IsVerified() bool - - // Export returns the base64-encoded ratchet key for this session, at the - // given index, in a format which can be used by - // InboundGroupSession.InboundGroupSessionImport(). Encrypts the - // InboundGroupSession using the supplied key. Returns error on failure. - // if we do not have a session key corresponding to the given index (ie, it - // was sent before the session key was shared with us) the error will be - // "OLM_UNKNOWN_MESSAGE_INDEX". - Export(messageIndex uint32) ([]byte, error) -} - -var InitInboundGroupSessionFromPickled func(pickled, key []byte) (InboundGroupSession, error) -var InitNewInboundGroupSession func(sessionKey []byte) (InboundGroupSession, error) -var InitInboundGroupSessionImport func(sessionKey []byte) (InboundGroupSession, error) -var InitBlankInboundGroupSession func() InboundGroupSession - -// InboundGroupSessionFromPickled loads an InboundGroupSession from a pickled -// base64 string. Decrypts the InboundGroupSession using the supplied key. -// Returns error on failure. -func InboundGroupSessionFromPickled(pickled, key []byte) (InboundGroupSession, error) { - return InitInboundGroupSessionFromPickled(pickled, key) -} - -// NewInboundGroupSession creates a new inbound group session from a key -// exported from OutboundGroupSession.Key(). Returns error on failure. -func NewInboundGroupSession(sessionKey []byte) (InboundGroupSession, error) { - return InitNewInboundGroupSession(sessionKey) -} - -// InboundGroupSessionImport imports an inbound group session from a previous -// export. Returns error on failure. -func InboundGroupSessionImport(sessionKey []byte) (InboundGroupSession, error) { - return InitInboundGroupSessionImport(sessionKey) -} - -func NewBlankInboundGroupSession() InboundGroupSession { - return InitBlankInboundGroupSession() -} diff --git a/mautrix-patched/crypto/olm/olm.go b/mautrix-patched/crypto/olm/olm.go deleted file mode 100644 index fa2345e1..00000000 --- a/mautrix-patched/crypto/olm/olm.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -var GetVersion func() (major, minor, patch uint8) -var SetPickleKeyImpl func(key []byte) - -// Version returns the version number of the olm library. -func Version() (major, minor, patch uint8) { - return GetVersion() -} - -// SetPickleKey sets the global pickle key used when encoding structs with Gob or JSON. -func SetPickleKey(key []byte) { - SetPickleKeyImpl(key) -} diff --git a/mautrix-patched/crypto/olm/outboundgroupsession.go b/mautrix-patched/crypto/olm/outboundgroupsession.go deleted file mode 100644 index 7e582b7e..00000000 --- a/mautrix-patched/crypto/olm/outboundgroupsession.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -import "maunium.net/go/mautrix/id" - -type OutboundGroupSession interface { - // Pickle returns a Session as a base64 string. Encrypts the Session using - // the supplied key. - Pickle(key []byte) ([]byte, error) - - // Unpickle loads an [OutboundGroupSession] from a pickled base64 string. - // Decrypts the [OutboundGroupSession] using the supplied key. - Unpickle(pickled, key []byte) error - - // Encrypt encrypts a message using the [OutboundGroupSession]. Returns the - // encrypted message as base64. - Encrypt(plaintext []byte) ([]byte, error) - - // ID returns a base64-encoded identifier for this session. - ID() id.SessionID - - // MessageIndex returns the message index for this session. Each message - // is sent with an increasing index; this returns the index for the next - // message. - MessageIndex() uint - - // Key returns the base64-encoded current ratchet key for this session. - Key() string -} - -var InitNewOutboundGroupSessionFromPickled func(pickled, key []byte) (OutboundGroupSession, error) -var InitNewOutboundGroupSession func() (OutboundGroupSession, error) -var InitNewBlankOutboundGroupSession func() OutboundGroupSession - -// OutboundGroupSessionFromPickled loads an OutboundGroupSession from a pickled -// base64 string. Decrypts the OutboundGroupSession using the supplied key. -// Returns error on failure. If the key doesn't match the one used to encrypt -// the OutboundGroupSession then the error will be "BAD_SESSION_KEY". If the -// base64 couldn't be decoded then the error will be "INVALID_BASE64". -func OutboundGroupSessionFromPickled(pickled, key []byte) (OutboundGroupSession, error) { - return InitNewOutboundGroupSessionFromPickled(pickled, key) -} - -// NewOutboundGroupSession creates a new outbound group session. -func NewOutboundGroupSession() (OutboundGroupSession, error) { - return InitNewOutboundGroupSession() -} - -// NewBlankOutboundGroupSession initialises an empty [OutboundGroupSession]. -func NewBlankOutboundGroupSession() OutboundGroupSession { - return InitNewBlankOutboundGroupSession() -} diff --git a/mautrix-patched/crypto/olm/outboundgroupsession_test.go b/mautrix-patched/crypto/olm/outboundgroupsession_test.go deleted file mode 100644 index afd0383f..00000000 --- a/mautrix-patched/crypto/olm/outboundgroupsession_test.go +++ /dev/null @@ -1,135 +0,0 @@ -//go:build !goolm - -package olm_test - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/libolm" -) - -func TestMegolmOutboundSessionPickle_RoundtripThroughGoolm(t *testing.T) { - libolmSession, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - libolmPickled, err := libolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - goolmSession, err := session.MegolmOutboundSessionFromPickled(libolmPickled, []byte("test")) - require.NoError(t, err) - - goolmPickled, err := goolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - assert.Equal(t, libolmPickled, goolmPickled, "pickled versions are not the same") - - libolmSession2, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - err = libolmSession2.Unpickle(bytes.Clone(goolmPickled), []byte("test")) - require.NoError(t, err) - - assert.Equal(t, libolmSession.Key(), libolmSession2.Key()) -} - -func TestMegolmOutboundSessionPickle_RoundtripThroughLibolm(t *testing.T) { - goolmSession, err := session.NewMegolmOutboundSession() - require.NoError(t, err) - - goolmPickled, err := goolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - libolmSession, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - err = libolmSession.Unpickle(bytes.Clone(goolmPickled), []byte("test")) - require.NoError(t, err) - - libolmPickled, err := libolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - assert.Equal(t, goolmPickled, libolmPickled, "pickled versions are not the same") - - goolmSession2, err := session.MegolmOutboundSessionFromPickled(libolmPickled, []byte("test")) - require.NoError(t, err) - - assert.Equal(t, goolmSession.Key(), goolmSession2.Key()) - assert.Equal(t, goolmSession.SigningKey.PrivateKey, goolmSession2.SigningKey.PrivateKey) -} - -func TestMegolmOutboundSessionPickleLibolm(t *testing.T) { - libolmSession, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - libolmPickled, err := libolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - goolmSession, err := session.MegolmOutboundSessionFromPickled(bytes.Clone(libolmPickled), []byte("test")) - require.NoError(t, err) - goolmPickled, err := goolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - assert.Equal(t, libolmPickled, goolmPickled, "pickled versions are not the same") - assert.Equal(t, goolmSession.SigningKey.PrivateKey.PubKey(), goolmSession.SigningKey.PublicKey) - - // Ensure that the key export is the same and that the pickle is the same - assert.Equal(t, libolmSession.Key(), goolmSession.Key(), "keys are not the same") -} - -func TestMegolmOutboundSessionPickleGoolm(t *testing.T) { - goolmSession, err := session.NewMegolmOutboundSession() - require.NoError(t, err) - goolmPickled, err := goolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - libolmSession, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - err = libolmSession.Unpickle(bytes.Clone(goolmPickled), []byte("test")) - require.NoError(t, err) - libolmPickled, err := libolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - assert.Equal(t, libolmPickled, goolmPickled, "pickled versions are not the same") - assert.Equal(t, goolmSession.SigningKey.PrivateKey.PubKey(), goolmSession.SigningKey.PublicKey) - - // Ensure that the key export is the same and that the pickle is the same - assert.Equal(t, libolmSession.Key(), goolmSession.Key(), "keys are not the same") -} - -func FuzzMegolmOutboundSession_Encrypt(f *testing.F) { - f.Add([]byte("anything")) - - f.Fuzz(func(t *testing.T, plaintext []byte) { - if len(plaintext) == 0 { - t.Skip("empty plaintext is not supported") - } - - libolmSession, err := libolm.NewOutboundGroupSession() - require.NoError(t, err) - libolmPickled, err := libolmSession.Pickle([]byte("test")) - require.NoError(t, err) - - goolmSession, err := session.MegolmOutboundSessionFromPickled(bytes.Clone(libolmPickled), []byte("test")) - require.NoError(t, err) - - assert.Equal(t, libolmSession.Key(), goolmSession.Key()) - - // Encrypt the plaintext ten times because the ratchet increments. - for i := 0; i < 10; i++ { - assert.EqualValues(t, i, libolmSession.MessageIndex()) - assert.EqualValues(t, i, goolmSession.MessageIndex()) - - libolmEncrypted, err := libolmSession.Encrypt(plaintext) - require.NoError(t, err) - - goolmEncrypted, err := goolmSession.Encrypt(plaintext) - require.NoError(t, err) - - assert.Equal(t, libolmEncrypted, goolmEncrypted) - - assert.EqualValues(t, i+1, libolmSession.MessageIndex()) - assert.EqualValues(t, i+1, goolmSession.MessageIndex()) - } - }) -} diff --git a/mautrix-patched/crypto/olm/pk.go b/mautrix-patched/crypto/olm/pk.go deleted file mode 100644 index b671bc33..00000000 --- a/mautrix-patched/crypto/olm/pk.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -import ( - "maunium.net/go/mautrix/id" -) - -// PKSigning is an interface for signing messages. -type PKSigning interface { - // Seed returns the seed of the key. - Seed() []byte - - // PublicKey returns the public key. - PublicKey() id.Ed25519 - - // Sign creates a signature for the given message using this key. - Sign(message []byte) ([]byte, error) - - // SignJSON creates a signature for the given object after encoding it to - // canonical JSON. - SignJSON(obj any) (string, error) -} - -var InitNewPKSigning func() (PKSigning, error) -var InitNewPKSigningFromSeed func(seed []byte) (PKSigning, error) - -// NewPKSigning creates a new [PKSigning] object, containing a key pair for -// signing messages. -func NewPKSigning() (PKSigning, error) { - return InitNewPKSigning() -} - -// NewPKSigningFromSeed creates a new PKSigning object using the given seed. -func NewPKSigningFromSeed(seed []byte) (PKSigning, error) { - return InitNewPKSigningFromSeed(seed) -} diff --git a/mautrix-patched/crypto/olm/pk_test.go b/mautrix-patched/crypto/olm/pk_test.go deleted file mode 100644 index 19dab226..00000000 --- a/mautrix-patched/crypto/olm/pk_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -// Only run this test if goolm is disabled (that is, libolm is used). - -//go:build !goolm - -package olm_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/goolm/pk" - "maunium.net/go/mautrix/crypto/libolm" -) - -func FuzzSign(f *testing.F) { - seed := []byte("Quohboh3ka3ooghequier9lee8Bahwoh") - goolmPkSigning, err := pk.NewSigningFromSeed(seed) - require.NoError(f, err) - - libolmPkSigning, err := libolm.NewPKSigningFromSeed(seed) - require.NoError(f, err) - - f.Add([]byte("message")) - - f.Fuzz(func(t *testing.T, message []byte) { - // libolm breaks with empty messages, so don't perform differential - // fuzzing on that. - if len(message) == 0 { - return - } - - libolmResult, libolmErr := libolmPkSigning.Sign(message) - goolmResult, goolmErr := goolmPkSigning.Sign(message) - - assert.Equal(t, goolmErr, libolmErr) - assert.Equal(t, goolmResult, libolmResult) - }) -} diff --git a/mautrix-patched/crypto/olm/session.go b/mautrix-patched/crypto/olm/session.go deleted file mode 100644 index c4b91ffc..00000000 --- a/mautrix-patched/crypto/olm/session.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package olm - -import "maunium.net/go/mautrix/id" - -type Session interface { - // Pickle returns a Session as a base64 string. Encrypts the Session using - // the supplied key. - Pickle(key []byte) ([]byte, error) - - // Unpickle loads a Session from a pickled base64 string. Decrypts the - // Session using the supplied key. - Unpickle(pickled, key []byte) error - - // ID returns an identifier for this Session. Will be the same for both - // ends of the conversation. - ID() id.SessionID - - // HasReceivedMessage returns true if this session has received any - // message. - HasReceivedMessage() bool - - // MatchesInboundSession checks if the PRE_KEY message is for this in-bound - // Session. This can happen if multiple messages are sent to this Account - // before this Account sends a message in reply. Returns true if the - // session matches. Returns false if the session does not match. Returns - // error on failure. If the base64 couldn't be decoded then the error will - // be "INVALID_BASE64". If the message was for an unsupported protocol - // version then the error will be "BAD_MESSAGE_VERSION". If the message - // couldn't be decoded then then the error will be "BAD_MESSAGE_FORMAT". - MatchesInboundSession(oneTimeKeyMsg string) (bool, error) - - // MatchesInboundSessionFrom checks if the PRE_KEY message is for this - // in-bound Session. This can happen if multiple messages are sent to this - // Account before this Account sends a message in reply. Returns true if - // the session matches. Returns false if the session does not match. - // Returns error on failure. If the base64 couldn't be decoded then the - // error will be "INVALID_BASE64". If the message was for an unsupported - // protocol version then the error will be "BAD_MESSAGE_VERSION". If the - // message couldn't be decoded then then the error will be - // "BAD_MESSAGE_FORMAT". - MatchesInboundSessionFrom(theirIdentityKey, oneTimeKeyMsg string) (bool, error) - - // EncryptMsgType returns the type of the next message that Encrypt will - // return. Returns MsgTypePreKey if the message will be a PRE_KEY message. - // Returns MsgTypeMsg if the message will be a normal message. - EncryptMsgType() id.OlmMsgType - - // Encrypt encrypts a message using the Session. Returns the encrypted - // message as base64. - Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) - - // Decrypt decrypts a message using the Session. Returns the plain-text on - // success. Returns error on failure. If the base64 couldn't be decoded - // then the error will be "INVALID_BASE64". If the message is for an - // unsupported version of the protocol then the error will be - // "BAD_MESSAGE_VERSION". If the message couldn't be decoded then the error - // will be BAD_MESSAGE_FORMAT". If the MAC on the message was invalid then - // the error will be "BAD_MESSAGE_MAC". - Decrypt(message string, msgType id.OlmMsgType) ([]byte, error) - - // Describe generates a string describing the internal state of an olm - // session for debugging and logging purposes. - Describe() string -} - -var InitSessionFromPickled func(pickled, key []byte) (Session, error) -var InitNewBlankSession func() Session - -// SessionFromPickled loads a Session from a pickled base64 string. Decrypts -// the Session using the supplied key. Returns error on failure. -func SessionFromPickled(pickled, key []byte) (Session, error) { - return InitSessionFromPickled(pickled, key) -} - -func NewBlankSession() Session { - return InitNewBlankSession() -} diff --git a/mautrix-patched/crypto/olm/session_test.go b/mautrix-patched/crypto/olm/session_test.go deleted file mode 100644 index 9cbac675..00000000 --- a/mautrix-patched/crypto/olm/session_test.go +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build !goolm - -package olm_test - -import ( - "bytes" - "fmt" - "math/rand/v2" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/exerrors" - "golang.org/x/exp/maps" - - "maunium.net/go/mautrix/crypto/goolm/account" - "maunium.net/go/mautrix/crypto/goolm/session" - "maunium.net/go/mautrix/crypto/libolm" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -func TestBlankSession(t *testing.T) { - libolmSession := libolm.NewBlankSession() - session := session.NewOlmSession() - - assert.Equal(t, libolmSession.ID(), session.ID()) - assert.Equal(t, libolmSession.HasReceivedMessage(), session.HasReceivedMessage()) - assert.Equal(t, libolmSession.EncryptMsgType(), session.EncryptMsgType()) - assert.Equal(t, libolmSession.Describe(), session.Describe()) - - libolmPickled, err := libolmSession.Pickle([]byte("test")) - assert.NoError(t, err) - goolmPickled, err := session.Pickle([]byte("test")) - assert.NoError(t, err) - assert.Equal(t, goolmPickled, libolmPickled) -} - -func TestSessionPickle(t *testing.T) { - pickledDataFromLibOlm := []byte("icDKYm0b4aO23WgUuOxdpPoxC0UlEOYPVeuduNH3IkpFsmnWx5KuEOpxGiZw5IuB/sSn2RZUCTiJ90IvgC7AClkYGHep9O8lpiqQX73XVKD9okZDCAkBc83eEq0DKYC7HBkGRAU/4T6QPIBBY3UK4QZwULLE/fLsi3j4YZBehMtnlsqgHK0q1bvX4cRznZItVKR4ro0O9EAk6LLxJtSnRu5elSUk7YXT") - pickleKey := []byte("secret_key") - - goolmSession, err := session.OlmSessionFromPickled(bytes.Clone(pickledDataFromLibOlm), pickleKey) - assert.NoError(t, err) - - libolmSession, err := libolm.SessionFromPickled(bytes.Clone(pickledDataFromLibOlm), pickleKey) - assert.NoError(t, err) - - goolmPickled, err := goolmSession.Pickle(pickleKey) - require.NoError(t, err) - assert.Equal(t, pickledDataFromLibOlm, goolmPickled) - - libolmPickled, err := libolmSession.Pickle(pickleKey) - require.NoError(t, err) - assert.Equal(t, pickledDataFromLibOlm, libolmPickled) -} - -func TestSession_EncryptDecrypt(t *testing.T) { - combos := [][2]olm.Account{ - {exerrors.Must(libolm.NewAccount()), exerrors.Must(libolm.NewAccount())}, - {exerrors.Must(account.NewAccount()), exerrors.Must(account.NewAccount())}, - {exerrors.Must(libolm.NewAccount()), exerrors.Must(account.NewAccount())}, - {exerrors.Must(account.NewAccount()), exerrors.Must(libolm.NewAccount())}, - } - - for _, combo := range combos { - receiver, sender := combo[0], combo[1] - require.NoError(t, receiver.GenOneTimeKeys(50)) - require.NoError(t, sender.GenOneTimeKeys(50)) - - _, receiverCurve25519, err := receiver.IdentityKeys() - require.NoError(t, err) - accountAOTKs, err := receiver.OneTimeKeys() - require.NoError(t, err) - - senderSession, err := sender.NewOutboundSession(receiverCurve25519, accountAOTKs[maps.Keys(accountAOTKs)[0]]) - require.NoError(t, err) - - // Send a couple pre-key messages from sender -> receiver. - var receiverSession olm.Session - for i := 0; i < 10; i++ { - msgType, ciphertext, err := senderSession.Encrypt([]byte(fmt.Sprintf("prekey %d", i))) - require.NoError(t, err) - assert.Equal(t, id.OlmMsgTypePreKey, msgType) - - receiverSession, err = receiver.NewInboundSession(string(ciphertext)) - require.NoError(t, err) - - decrypted, err := receiverSession.Decrypt(string(ciphertext), msgType) - require.NoError(t, err) - assert.Equal(t, []byte(fmt.Sprintf("prekey %d", i)), decrypted) - } - - // Send some messages from receiver -> sender. - for i := 0; i < 10; i++ { - msgType, ciphertext, err := receiverSession.Encrypt([]byte(fmt.Sprintf("response %d", i))) - require.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - - decrypted, err := senderSession.Decrypt(string(ciphertext), msgType) - require.NoError(t, err) - assert.Equal(t, []byte(fmt.Sprintf("response %d", i)), decrypted) - } - - // Send some more messages from sender -> receiver - for i := 0; i < 10; i++ { - msgType, ciphertext, err := senderSession.Encrypt([]byte(fmt.Sprintf("%d", i))) - require.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - - decrypted, err := receiverSession.Decrypt(string(ciphertext), msgType) - require.NoError(t, err) - assert.Equal(t, []byte(fmt.Sprintf("%d", i)), decrypted) - } - - // Misordered messages - messages := make([][]byte, 10) - plainMessages := make([]string, 10) - for i := 0; i < 10; i++ { - plainMessages[i] = fmt.Sprintf("meow%d", i) - msgType, ciphertext, err := senderSession.Encrypt([]byte(plainMessages[i])) - require.NoError(t, err) - assert.Equal(t, id.OlmMsgTypeMsg, msgType) - messages[i] = ciphertext - - } - rand.Shuffle(len(messages), func(i, j int) { - messages[i], messages[j] = messages[j], messages[i] - plainMessages[i], plainMessages[j] = plainMessages[j], plainMessages[i] - }) - for i, ciphertext := range messages { - decrypted, err := receiverSession.Decrypt(string(ciphertext), id.OlmMsgTypeMsg) - require.NoError(t, err) - assert.Equal(t, []byte(plainMessages[i]), decrypted) - } - } -} diff --git a/mautrix-patched/crypto/registergoolm.go b/mautrix-patched/crypto/registergoolm.go deleted file mode 100644 index 6b5b65fd..00000000 --- a/mautrix-patched/crypto/registergoolm.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build goolm - -package crypto - -import ( - "maunium.net/go/mautrix/crypto/goolm" -) - -func init() { - goolm.Register() -} diff --git a/mautrix-patched/crypto/registerlibolm.go b/mautrix-patched/crypto/registerlibolm.go deleted file mode 100644 index ef78b6b5..00000000 --- a/mautrix-patched/crypto/registerlibolm.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !goolm - -package crypto - -import "maunium.net/go/mautrix/crypto/libolm" - -func init() { - libolm.Register() -} diff --git a/mautrix-patched/crypto/sessions.go b/mautrix-patched/crypto/sessions.go deleted file mode 100644 index e93a8f52..00000000 --- a/mautrix-patched/crypto/sessions.go +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "errors" - "fmt" - "time" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/event" - - "maunium.net/go/mautrix/id" -) - -var ( - ErrSessionNotShared = errors.New("session has not been shared") - ErrSessionExpired = errors.New("session has expired") -) - -// Deprecated: use variables prefixed with Err -var ( - SessionNotShared = ErrSessionNotShared - SessionExpired = ErrSessionExpired -) - -// OlmSessionList is a list of OlmSessions. -// It implements sort.Interface so that the session with recent successful decryptions comes first. -type OlmSessionList []*OlmSession - -func (o OlmSessionList) Len() int { - return len(o) -} - -func (o OlmSessionList) Less(i, j int) bool { - return o[i].LastDecryptedTime.After(o[j].LastEncryptedTime) -} - -func (o OlmSessionList) Swap(i, j int) { - o[i], o[j] = o[j], o[i] -} - -type OlmSession struct { - Internal olm.Session - ExpirationMixin - id id.SessionID -} - -func (session *OlmSession) ID() id.SessionID { - if session.id == "" { - session.id = session.Internal.ID() - } - return session.id -} - -func (session *OlmSession) Describe() string { - return session.Internal.Describe() -} - -func wrapSession(session olm.Session) *OlmSession { - return &OlmSession{ - Internal: session, - ExpirationMixin: ExpirationMixin{ - TimeMixin: TimeMixin{ - CreationTime: time.Now(), - LastEncryptedTime: time.Now(), - LastDecryptedTime: time.Now(), - }, - }, - } -} - -func (account *OlmAccount) NewInboundSessionFrom(senderKey id.Curve25519, ciphertext string) (*OlmSession, error) { - session, err := account.Internal.NewInboundSessionFrom(&senderKey, ciphertext) - if err != nil { - return nil, err - } - return wrapSession(session), nil -} - -func (session *OlmSession) Encrypt(plaintext []byte) (id.OlmMsgType, []byte, error) { - session.LastEncryptedTime = time.Now() - return session.Internal.Encrypt(plaintext) -} - -func (session *OlmSession) Decrypt(ciphertext string, msgType id.OlmMsgType) ([]byte, error) { - msg, err := session.Internal.Decrypt(ciphertext, msgType) - if err == nil { - session.LastDecryptedTime = time.Now() - } - return msg, err -} - -type RatchetSafety struct { - NextIndex uint `json:"next_index"` - MissedIndices []uint `json:"missed_indices,omitempty"` - LostIndices []uint `json:"lost_indices,omitempty"` -} - -type InboundGroupSession struct { - Internal olm.InboundGroupSession - - SigningKey id.Ed25519 - SenderKey id.Curve25519 - RoomID id.RoomID - - ForwardingChains []string - RatchetSafety RatchetSafety - - ReceivedAt time.Time - MaxAge int64 - MaxMessages int - IsScheduled bool - KeyBackupVersion id.KeyBackupVersion - KeySource id.KeySource - - id id.SessionID -} - -func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomID id.RoomID, sessionKey string, maxAge time.Duration, maxMessages int, isScheduled bool) (*InboundGroupSession, error) { - igs, err := olm.NewInboundGroupSession([]byte(sessionKey)) - if err != nil { - return nil, err - } - return &InboundGroupSession{ - Internal: igs, - SigningKey: signingKey, - SenderKey: senderKey, - RoomID: roomID, - ForwardingChains: []string{}, - ReceivedAt: time.Now().UTC(), - MaxAge: maxAge.Milliseconds(), - MaxMessages: maxMessages, - IsScheduled: isScheduled, - KeySource: id.KeySourceDirect, - }, nil -} - -func (igs *InboundGroupSession) ID() id.SessionID { - if igs.id == "" { - igs.id = igs.Internal.ID() - } - return igs.id -} - -func (igs *InboundGroupSession) RatchetTo(index uint32) error { - exported, err := igs.Internal.Export(index) - if err != nil { - return err - } - imported, err := olm.InboundGroupSessionImport(exported) - if err != nil { - return err - } - igs.Internal = imported - return nil -} - -func (igs *InboundGroupSession) export() (*ExportedSession, error) { - key, err := igs.Internal.Export(igs.Internal.FirstKnownIndex()) - if err != nil { - return nil, fmt.Errorf("failed to export session: %w", err) - } - return &ExportedSession{ - Algorithm: id.AlgorithmMegolmV1, - ForwardingChains: igs.ForwardingChains, - RoomID: igs.RoomID, - SenderKey: igs.SenderKey, - SenderClaimedKeys: SenderClaimedKeys{Ed25519: igs.SigningKey}, - SessionID: igs.ID(), - SessionKey: string(key), - }, nil -} - -type OGSState int - -const ( - OGSNotShared OGSState = iota - OGSAlreadyShared - OGSIgnored -) - -type UserDevice struct { - UserID id.UserID - DeviceID id.DeviceID -} - -type OutboundGroupSession struct { - Internal olm.OutboundGroupSession - - ExpirationMixin - MaxMessages int - MessageCount int - - Users map[UserDevice]OGSState - RoomID id.RoomID - Shared bool - - id id.SessionID - content *event.RoomKeyEventContent -} - -func NewOutboundGroupSession(roomID id.RoomID, encryptionContent *event.EncryptionEventContent) (*OutboundGroupSession, error) { - internal, err := olm.NewOutboundGroupSession() - if err != nil { - return nil, err - } - ogs := &OutboundGroupSession{ - Internal: internal, - ExpirationMixin: ExpirationMixin{ - TimeMixin: TimeMixin{ - CreationTime: time.Now(), - LastEncryptedTime: time.Now(), - }, - MaxAge: 7 * 24 * time.Hour, - }, - MaxMessages: 100, - Shared: false, - Users: make(map[UserDevice]OGSState), - RoomID: roomID, - } - if encryptionContent != nil { - // Clamp rotation period to prevent unreasonable values - // Similar to https://github.com/matrix-org/matrix-rust-sdk/blob/matrix-sdk-crypto-0.7.1/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs#L415-L441 - if encryptionContent.RotationPeriodMillis != 0 { - ogs.MaxAge = time.Duration(encryptionContent.RotationPeriodMillis) * time.Millisecond - ogs.MaxAge = min(max(ogs.MaxAge, 1*time.Hour), 365*24*time.Hour) - } - if encryptionContent.RotationPeriodMessages != 0 { - ogs.MaxMessages = min(max(encryptionContent.RotationPeriodMessages, 1), 10000) - } - } - return ogs, nil -} - -func (ogs *OutboundGroupSession) ShareContent() event.Content { - if ogs.content == nil { - ogs.content = &event.RoomKeyEventContent{ - Algorithm: id.AlgorithmMegolmV1, - RoomID: ogs.RoomID, - SessionID: ogs.ID(), - SessionKey: ogs.Internal.Key(), - } - } - return event.Content{Parsed: ogs.content} -} - -func (ogs *OutboundGroupSession) ID() id.SessionID { - if ogs.id == "" { - ogs.id = ogs.Internal.ID() - } - return ogs.id -} - -func (ogs *OutboundGroupSession) Expired() bool { - return ogs.MessageCount >= ogs.MaxMessages || ogs.ExpirationMixin.Expired() -} - -func (ogs *OutboundGroupSession) Encrypt(plaintext []byte) ([]byte, error) { - if !ogs.Shared { - return nil, ErrSessionNotShared - } else if ogs.Expired() { - return nil, ErrSessionExpired - } - ogs.MessageCount++ - ogs.LastEncryptedTime = time.Now() - return ogs.Internal.Encrypt(plaintext) -} - -type TimeMixin struct { - CreationTime time.Time - LastEncryptedTime time.Time - LastDecryptedTime time.Time -} - -type ExpirationMixin struct { - TimeMixin - MaxAge time.Duration -} - -func (exp *ExpirationMixin) Expired() bool { - if exp.MaxAge == 0 { - return false - } - return exp.CreationTime.Add(exp.MaxAge).Before(time.Now()) -} diff --git a/mautrix-patched/crypto/sharing.go b/mautrix-patched/crypto/sharing.go deleted file mode 100644 index 03df34ab..00000000 --- a/mautrix-patched/crypto/sharing.go +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "time" - - "go.mau.fi/util/ptr" - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// Callback function to process a received secret. -// -// Returning true or an error will immediately return from the wait loop, returning false will continue waiting for new responses. -type SecretReceiverFunc func(string) (bool, error) - -func (mach *OlmMachine) GetOrRequestSecret(ctx context.Context, name id.Secret, receiver SecretReceiverFunc, timeout time.Duration) (err error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // always offer our stored secret first, if any - secret, err := mach.CryptoStore.GetSecret(ctx, name) - if err != nil { - return err - } else if secret != "" { - if ok, err := receiver(secret); ok || err != nil { - return err - } - } - - requestID, secretChan := random.String(64), make(chan string, 5) - mach.secretLock.Lock() - mach.secretListeners[requestID] = secretChan - mach.secretLock.Unlock() - defer func() { - mach.secretLock.Lock() - delete(mach.secretListeners, requestID) - mach.secretLock.Unlock() - }() - - // request secret from any device - err = mach.sendToOneDevice(ctx, mach.Client.UserID, id.DeviceID("*"), event.ToDeviceSecretRequest, &event.SecretRequestEventContent{ - Action: event.SecretRequestRequest, - RequestID: requestID, - Name: name, - RequestingDeviceID: mach.Client.DeviceID, - }) - if err != nil { - return - } - - // best effort cancel request from all devices when returning - defer func() { - go mach.sendToOneDevice(context.Background(), mach.Client.UserID, id.DeviceID("*"), event.ToDeviceSecretRequest, &event.SecretRequestEventContent{ - Action: event.SecretRequestCancellation, - RequestID: requestID, - RequestingDeviceID: mach.Client.DeviceID, - }) - }() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case secret = <-secretChan: - if ok, err := receiver(secret); err != nil { - return err - } else if ok { - return mach.CryptoStore.PutSecret(ctx, name, secret) - } - } - } -} - -func (mach *OlmMachine) HandleSecretRequest(ctx context.Context, userID id.UserID, content *event.SecretRequestEventContent) { - log := mach.machOrContextLog(ctx).With(). - Stringer("user_id", userID). - Stringer("requesting_device_id", content.RequestingDeviceID). - Stringer("action", content.Action). - Str("request_id", content.RequestID). - Stringer("secret", content.Name). - Logger() - - log.Trace().Msg("Handling secret request") - - if content.Action == event.SecretRequestCancellation { - log.Trace().Msg("Secret request cancellation is unimplemented, ignoring") - return - } else if content.Action != event.SecretRequestRequest { - log.Warn().Msg("Ignoring unknown secret request action") - return - } - - // immediately ignore requests from other users - if userID != mach.Client.UserID || content.RequestingDeviceID == "" { - log.Debug().Msg("Secret request was not from our own device, ignoring") - return - } - - if content.RequestingDeviceID == mach.Client.DeviceID { - log.Debug().Msg("Secret request was from this device, ignoring") - return - } - - device, err := mach.GetOrFetchDevice(ctx, mach.Client.UserID, content.RequestingDeviceID) - if err != nil { - log.Err(err).Msg("Failed to get or fetch requesting device") - return - } - trust, err := mach.ResolveTrustContext(ctx, device) - if err != nil { - log.Err(err).Msg("Failed to check if requesting device is verified") - return - } - - if trust < id.TrustStateCrossSignedVerified { - log.Warn().Stringer("trust_level", trust).Msg("Requesting device is not verified, ignoring request") - return - } - - secret, err := mach.CryptoStore.GetSecret(ctx, content.Name) - if err != nil { - log.Err(err).Msg("Failed to get secret from store") - return - } else if secret != "" { - log.Debug().Msg("Responding to secret request") - mach.SendEncryptedToDevice(ctx, device, event.ToDeviceSecretSend, event.Content{ - Parsed: event.SecretSendEventContent{ - RequestID: content.RequestID, - Secret: secret, - }, - }) - } else { - log.Debug().Msg("No stored secret found, secret request ignored") - } -} - -func (mach *OlmMachine) receiveSecret(ctx context.Context, evt *DecryptedOlmEvent, content *event.SecretSendEventContent) { - log := mach.machOrContextLog(ctx).With(). - Stringer("sender", evt.Sender). - Stringer("sender_key", evt.SenderKey). - Stringer("sender_device", ptr.Val(evt.SenderDevice).DeviceID). - Str("request_id", content.RequestID). - Logger() - - log.Trace().Msg("Handling secret send request") - - // immediately ignore secrets from other users - if evt.Sender != mach.Client.UserID { - log.Warn().Msg("Secret send was not from our own device") - return - } else if content.Secret == "" { - log.Warn().Msg("We were sent an empty secret") - return - } else if evt.SenderDevice == nil { - log.Warn().Msg("We were sent a secret from an unknown device") - return - } - - // https://spec.matrix.org/v1.10/client-server-api/#msecretsend - // "The recipient must ensure... that the device is a verified device owned by the recipient" - if !mach.IsDeviceTrusted(ctx, evt.SenderDevice) { - log.Warn().Msg("Sender device is not verified, rejecting secret") - return - } - - mach.secretLock.Lock() - secretChan := mach.secretListeners[content.RequestID] - mach.secretLock.Unlock() - - if secretChan == nil { - log.Warn().Msg("We were sent a secret we didn't request") - return - } - - // secret channel is buffered and we don't want to block - // at worst we drop _some_ of the responses - select { - case secretChan <- content.Secret: - default: - } -} diff --git a/mautrix-patched/crypto/signatures/signatures.go b/mautrix-patched/crypto/signatures/signatures.go deleted file mode 100644 index 072d9147..00000000 --- a/mautrix-patched/crypto/signatures/signatures.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package signatures - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - "go.mau.fi/util/exgjson" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/crypto/goolm/crypto" - "maunium.net/go/mautrix/id" -) - -var ( - ErrEmptyInput = errors.New("empty input") - ErrSignatureNotFound = errors.New("input JSON doesn't contain signature from specified device") -) - -// Signatures represents a set of signatures for some data from multiple users -// and keys. -type Signatures map[id.UserID]map[id.KeyID]string - -// NewSingleSignature creates a new [Signatures] object with a single -// signature. -func NewSingleSignature(userID id.UserID, algorithm id.KeyAlgorithm, keyID string, signature string) Signatures { - return Signatures{ - userID: { - id.NewKeyID(algorithm, keyID): signature, - }, - } -} - -// VerifySignature verifies an Ed25519 signature. -func VerifySignature(message []byte, key id.Ed25519, signature []byte) (ok bool, err error) { - if len(message) == 0 || len(key) == 0 || len(signature) == 0 { - return false, ErrEmptyInput - } - keyDecoded, err := base64.RawStdEncoding.DecodeString(key.String()) - if err != nil { - return false, err - } - publicKey := crypto.Ed25519PublicKey(keyDecoded) - return publicKey.Verify(message, signature), nil -} - -// VerifySignatureJSON verifies the signature in the given JSON object "obj" -// as described in [Appendix 3] of the Matrix Spec. -// -// This function is a wrapper over [Utility.VerifySignatureJSON] that creates -// and destroys the [Utility] object transparently. -// -// If the "obj" is not already a [json.RawMessage], it will re-encoded as JSON -// for the verification, so "json" tags will be honored. -// -// [Appendix 3]: https://spec.matrix.org/v1.9/appendices/#signing-json -func VerifySignatureJSON(obj any, userID id.UserID, keyName string, key id.Ed25519) (bool, error) { - var err error - objJSON, ok := obj.(json.RawMessage) - if !ok { - objJSON, err = canonicaljson.Marshal(obj) - if err != nil { - return false, err - } - } else { - // Canonicalize later may mutate the data, so clone the input - objJSON = bytes.Clone(objJSON) - } - - sig := gjson.GetBytes(objJSON, exgjson.Path("signatures", string(userID), fmt.Sprintf("ed25519:%s", keyName))) - if !sig.Exists() || sig.Type != gjson.String { - return false, ErrSignatureNotFound - } - objJSON, err = sjson.DeleteBytes(objJSON, "unsigned") - if err != nil { - return false, err - } - objJSON, err = sjson.DeleteBytes(objJSON, "signatures") - if err != nil { - return false, err - } - err = canonicaljson.Canonicalize(&objJSON) - if err != nil { - return false, fmt.Errorf("failed to canonicalize JSON after deleting unsigned and signatures: %w", err) - } - sigBytes, err := base64.RawStdEncoding.DecodeString(sig.Str) - if err != nil { - return false, err - } - return VerifySignature(objJSON, key, sigBytes) -} diff --git a/mautrix-patched/crypto/sql_store.go b/mautrix-patched/crypto/sql_store.go deleted file mode 100644 index 413c2094..00000000 --- a/mautrix-patched/crypto/sql_store.go +++ /dev/null @@ -1,983 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "database/sql" - "database/sql/driver" - "encoding/json" - "errors" - "fmt" - "slices" - "strings" - "sync" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/goolm/libolmpickle" - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/crypto/sql_store_upgrade" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var PostgresArrayWrapper func(any) interface { - driver.Valuer - sql.Scanner -} - -// SQLCryptoStore is an implementation of a crypto Store for a database backend. -type SQLCryptoStore struct { - DB *dbutil.Database - - AccountID string - DeviceID id.DeviceID - SyncToken string - PickleKey []byte - Account *OlmAccount - - olmSessionCache map[id.SenderKey]map[id.SessionID]*OlmSession - olmSessionCacheLock sync.Mutex -} - -var _ Store = (*SQLCryptoStore)(nil) - -// NewSQLCryptoStore initializes a new crypto Store using the given database, for a device's crypto material. -// The stored material will be encrypted with the given key. -func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, accountID string, deviceID id.DeviceID, pickleKey []byte) *SQLCryptoStore { - store := &SQLCryptoStore{ - DB: db.Child(sql_store_upgrade.VersionTableName, sql_store_upgrade.Table, log), - PickleKey: pickleKey, - AccountID: accountID, - DeviceID: deviceID, - } - store.InitFields() - return store -} - -func (store *SQLCryptoStore) InitFields() { - store.olmSessionCache = make(map[id.SenderKey]map[id.SessionID]*OlmSession) -} - -// Flush does nothing for this implementation as data is already persisted in the database. -func (store *SQLCryptoStore) Flush(_ context.Context) error { - return nil -} - -// PutNextBatch stores the next sync batch token for the current account. -func (store *SQLCryptoStore) PutNextBatch(ctx context.Context, nextBatch string) error { - store.SyncToken = nextBatch - _, err := store.DB.Exec(ctx, `UPDATE crypto_account SET sync_token=$1 WHERE account_id=$2`, store.SyncToken, store.AccountID) - return err -} - -// GetNextBatch retrieves the next sync batch token for the current account. -func (store *SQLCryptoStore) GetNextBatch(ctx context.Context) (string, error) { - if store.SyncToken == "" { - err := store.DB. - QueryRow(ctx, "SELECT sync_token FROM crypto_account WHERE account_id=$1", store.AccountID). - Scan(&store.SyncToken) - if !errors.Is(err, sql.ErrNoRows) { - return "", err - } - } - return store.SyncToken, nil -} - -var _ mautrix.SyncStore = (*SQLCryptoStore)(nil) - -func (store *SQLCryptoStore) SaveFilterID(ctx context.Context, _ id.UserID, _ string) error { - return nil -} -func (store *SQLCryptoStore) LoadFilterID(ctx context.Context, _ id.UserID) (string, error) { - return "", nil -} - -func (store *SQLCryptoStore) SaveNextBatch(ctx context.Context, _ id.UserID, nextBatchToken string) error { - err := store.PutNextBatch(ctx, nextBatchToken) - if err != nil { - return fmt.Errorf("unable to store batch: %w", err) - } - return nil -} - -func (store *SQLCryptoStore) LoadNextBatch(ctx context.Context, _ id.UserID) (string, error) { - nb, err := store.GetNextBatch(ctx) - if err != nil { - return "", fmt.Errorf("unable to load batch: %w", err) - } - return nb, nil -} - -func (store *SQLCryptoStore) FindDeviceID(ctx context.Context) (deviceID id.DeviceID, err error) { - err = store.DB.QueryRow(ctx, "SELECT device_id FROM crypto_account WHERE account_id=$1", store.AccountID).Scan(&deviceID) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return -} - -// PutAccount stores an OlmAccount in the database. -func (store *SQLCryptoStore) PutAccount(ctx context.Context, account *OlmAccount) error { - store.Account = account - bytes, err := account.Internal.Pickle(store.PickleKey) - if err != nil { - return err - } - _, err = store.DB.Exec(ctx, ` - INSERT INTO crypto_account (device_id, shared, sync_token, account, account_id, key_backup_version) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (account_id) DO UPDATE SET shared=excluded.shared, sync_token=excluded.sync_token, - account=excluded.account, account_id=excluded.account_id, - key_backup_version=excluded.key_backup_version - `, store.DeviceID, account.Shared, store.SyncToken, bytes, store.AccountID, account.KeyBackupVersion) - return err -} - -// GetAccount retrieves an OlmAccount from the database. -func (store *SQLCryptoStore) GetAccount(ctx context.Context) (*OlmAccount, error) { - if store.Account == nil { - row := store.DB.QueryRow(ctx, "SELECT shared, sync_token, account, key_backup_version FROM crypto_account WHERE account_id=$1", store.AccountID) - acc := &OlmAccount{Internal: olm.NewBlankAccount()} - var accountBytes []byte - err := row.Scan(&acc.Shared, &store.SyncToken, &accountBytes, &acc.KeyBackupVersion) - if err == sql.ErrNoRows { - return nil, nil - } else if err != nil { - return nil, err - } - err = acc.Internal.Unpickle(accountBytes, store.PickleKey) - if err != nil { - return nil, err - } - store.Account = acc - } - return store.Account, nil -} - -// HasSession returns whether there is an Olm session for the given sender key. -func (store *SQLCryptoStore) HasSession(ctx context.Context, key id.SenderKey) bool { - store.olmSessionCacheLock.Lock() - cache, ok := store.olmSessionCache[key] - store.olmSessionCacheLock.Unlock() - if ok && len(cache) > 0 { - return true - } - var sessionID id.SessionID - err := store.DB.QueryRow(ctx, "SELECT session_id FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 LIMIT 1", - key, store.AccountID).Scan(&sessionID) - if errors.Is(err, sql.ErrNoRows) { - return false - } - return len(sessionID) > 0 -} - -// GetSessions returns all the known Olm sessions for a sender key. -func (store *SQLCryptoStore) GetSessions(ctx context.Context, key id.SenderKey) (OlmSessionList, error) { - rows, err := store.DB.Query(ctx, "SELECT session_id, session, created_at, last_encrypted, last_decrypted FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 ORDER BY last_decrypted DESC", - key, store.AccountID) - if err != nil { - return nil, err - } - list := OlmSessionList{} - store.olmSessionCacheLock.Lock() - defer store.olmSessionCacheLock.Unlock() - cache := store.getOlmSessionCache(key) - for rows.Next() { - sess := OlmSession{Internal: olm.NewBlankSession()} - var sessionBytes []byte - var sessionID id.SessionID - err = rows.Scan(&sessionID, &sessionBytes, &sess.CreationTime, &sess.LastEncryptedTime, &sess.LastDecryptedTime) - if err != nil { - return nil, err - } else if existing, ok := cache[sessionID]; ok { - list = append(list, existing) - } else { - err = sess.Internal.Unpickle(sessionBytes, store.PickleKey) - if err != nil { - return nil, err - } - list = append(list, &sess) - cache[sess.ID()] = &sess - } - } - return list, nil -} - -func (store *SQLCryptoStore) getOlmSessionCache(key id.SenderKey) map[id.SessionID]*OlmSession { - data, ok := store.olmSessionCache[key] - if !ok { - data = make(map[id.SessionID]*OlmSession) - store.olmSessionCache[key] = data - } - return data -} - -// GetLatestSession retrieves the Olm session for a given sender key from the database that had the most recent successful decryption. -func (store *SQLCryptoStore) GetLatestSession(ctx context.Context, key id.SenderKey) (*OlmSession, error) { - store.olmSessionCacheLock.Lock() - defer store.olmSessionCacheLock.Unlock() - - row := store.DB.QueryRow(ctx, "SELECT session_id, session, created_at, last_encrypted, last_decrypted FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 ORDER BY last_decrypted DESC LIMIT 1", - key, store.AccountID) - - sess := OlmSession{Internal: olm.NewBlankSession()} - var sessionBytes []byte - var sessionID id.SessionID - - err := row.Scan(&sessionID, &sessionBytes, &sess.CreationTime, &sess.LastEncryptedTime, &sess.LastDecryptedTime) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - - cache := store.getOlmSessionCache(key) - if oldSess, ok := cache[sessionID]; ok { - return oldSess, nil - } else if err = sess.Internal.Unpickle(sessionBytes, store.PickleKey); err != nil { - return nil, err - } else { - cache[sessionID] = &sess - return &sess, nil - } -} - -// GetNewestSessionCreationTS gets the creation timestamp of the most recently created session with the given sender key. -// This will exclude sessions that have never been used to encrypt or decrypt a message. -func (store *SQLCryptoStore) GetNewestSessionCreationTS(ctx context.Context, key id.SenderKey) (createdAt time.Time, err error) { - err = store.DB.QueryRow(ctx, "SELECT created_at FROM crypto_olm_session WHERE sender_key=$1 AND account_id=$2 AND (last_encrypted <> created_at OR last_decrypted <> created_at) ORDER BY created_at DESC LIMIT 1", - key, store.AccountID).Scan(&createdAt) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return -} - -// AddSession persists an Olm session for a sender in the database. -func (store *SQLCryptoStore) AddSession(ctx context.Context, key id.SenderKey, session *OlmSession) error { - store.olmSessionCacheLock.Lock() - defer store.olmSessionCacheLock.Unlock() - sessionBytes, err := session.Internal.Pickle(store.PickleKey) - if err != nil { - return err - } - _, err = store.DB.Exec(ctx, "INSERT INTO crypto_olm_session (session_id, sender_key, session, created_at, last_encrypted, last_decrypted, account_id) VALUES ($1, $2, $3, $4, $5, $6, $7)", - session.ID(), key, sessionBytes, session.CreationTime, session.LastEncryptedTime, session.LastDecryptedTime, store.AccountID) - store.getOlmSessionCache(key)[session.ID()] = session - return err -} - -// UpdateSession replaces the Olm session for a sender in the database. -func (store *SQLCryptoStore) UpdateSession(ctx context.Context, _ id.SenderKey, session *OlmSession) error { - sessionBytes, err := session.Internal.Pickle(store.PickleKey) - if err != nil { - return err - } - _, err = store.DB.Exec(ctx, "UPDATE crypto_olm_session SET session=$1, last_encrypted=$2, last_decrypted=$3 WHERE session_id=$4 AND account_id=$5", - sessionBytes, session.LastEncryptedTime, session.LastDecryptedTime, session.ID(), store.AccountID) - return err -} - -func (store *SQLCryptoStore) DeleteSession(ctx context.Context, _ id.SenderKey, session *OlmSession) error { - _, err := store.DB.Exec(ctx, "DELETE FROM crypto_olm_session WHERE session_id=$1 AND account_id=$2", session.ID(), store.AccountID) - return err -} - -func (store *SQLCryptoStore) PutOlmHash(ctx context.Context, messageHash [32]byte, receivedAt time.Time) error { - _, err := store.DB.Exec(ctx, "INSERT INTO crypto_olm_message_hash (account_id, received_at, message_hash) VALUES ($1, $2, $3) ON CONFLICT (message_hash) DO NOTHING", store.AccountID, receivedAt.UnixMilli(), messageHash[:]) - return err -} - -func (store *SQLCryptoStore) GetOlmHash(ctx context.Context, messageHash [32]byte) (receivedAt time.Time, err error) { - var receivedAtInt int64 - err = store.DB.QueryRow(ctx, "SELECT received_at FROM crypto_olm_message_hash WHERE message_hash=$1", messageHash[:]).Scan(&receivedAtInt) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return - } - receivedAt = time.UnixMilli(receivedAtInt) - return -} - -func (store *SQLCryptoStore) DeleteOldOlmHashes(ctx context.Context, beforeTS time.Time) error { - _, err := store.DB.Exec(ctx, "DELETE FROM crypto_olm_message_hash WHERE account_id = $1 AND received_at < $2", store.AccountID, beforeTS.UnixMilli()) - return err -} - -func datePtr(t time.Time) *time.Time { - if t.IsZero() { - return nil - } - return &t -} - -// PutGroupSession stores an inbound Megolm group session for a room, sender and session. -func (store *SQLCryptoStore) PutGroupSession(ctx context.Context, session *InboundGroupSession) error { - sessionBytes, err := session.Internal.Pickle(store.PickleKey) - if err != nil { - return err - } - if session.ForwardingChains == nil { - session.ForwardingChains = []string{} - } - forwardingChains := strings.Join(session.ForwardingChains, ",") - ratchetSafety, err := json.Marshal(&session.RatchetSafety) - if err != nil { - return fmt.Errorf("failed to marshal ratchet safety info: %w", err) - } - zerolog.Ctx(ctx).Debug(). - Stringer("session_id", session.ID()). - Str("account_id", store.AccountID). - Stringer("sender_key", session.SenderKey). - Stringer("signing_key", session.SigningKey). - Stringer("room_id", session.RoomID). - Time("received_at", session.ReceivedAt). - Int64("max_age", session.MaxAge). - Int("max_messages", session.MaxMessages). - Bool("is_scheduled", session.IsScheduled). - Stringer("key_backup_version", session.KeyBackupVersion). - Stringer("key_source", session.KeySource). - Msg("Upserting megolm inbound group session") - _, err = store.DB.Exec(ctx, ` - INSERT INTO crypto_megolm_inbound_session ( - session_id, sender_key, signing_key, room_id, session, forwarding_chains, - ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source, account_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - ON CONFLICT (session_id, account_id) DO UPDATE - SET withheld_code=NULL, withheld_reason=NULL, sender_key=excluded.sender_key, signing_key=excluded.signing_key, - room_id=excluded.room_id, session=excluded.session, forwarding_chains=excluded.forwarding_chains, - ratchet_safety=excluded.ratchet_safety, received_at=excluded.received_at, - max_age=excluded.max_age, max_messages=excluded.max_messages, is_scheduled=excluded.is_scheduled, - key_backup_version=excluded.key_backup_version, key_source=excluded.key_source - `, - session.ID(), session.SenderKey, session.SigningKey, session.RoomID, sessionBytes, forwardingChains, - ratchetSafety, datePtr(session.ReceivedAt), dbutil.NumPtr(session.MaxAge), dbutil.NumPtr(session.MaxMessages), - session.IsScheduled, session.KeyBackupVersion, session.KeySource, store.AccountID, - ) - return err -} - -// GetGroupSession retrieves an inbound Megolm group session for a room, sender and session. -func (store *SQLCryptoStore) GetGroupSession(ctx context.Context, roomID id.RoomID, sessionID id.SessionID) (*InboundGroupSession, error) { - var senderKey, signingKey, forwardingChains, withheldCode, withheldReason sql.NullString - var sessionBytes, ratchetSafetyBytes []byte - var receivedAt sql.NullTime - var maxAge, maxMessages sql.NullInt64 - var isScheduled bool - var version id.KeyBackupVersion - var keySource id.KeySource - err := store.DB.QueryRow(ctx, ` - SELECT sender_key, signing_key, session, forwarding_chains, withheld_code, withheld_reason, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source - FROM crypto_megolm_inbound_session - WHERE room_id=$1 AND session_id=$2 AND account_id=$3`, - roomID, sessionID, store.AccountID, - ).Scan(&senderKey, &signingKey, &sessionBytes, &forwardingChains, &withheldCode, &withheldReason, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } else if withheldCode.Valid { - return nil, &event.RoomKeyWithheldEventContent{ - RoomID: roomID, - Algorithm: id.AlgorithmMegolmV1, - SessionID: sessionID, - SenderKey: id.Curve25519(senderKey.String), - Code: event.RoomKeyWithheldCode(withheldCode.String), - Reason: withheldReason.String, - } - } - igs, chains, rs, err := store.postScanInboundGroupSession(sessionBytes, ratchetSafetyBytes, forwardingChains.String) - if err != nil { - return nil, err - } - return &InboundGroupSession{ - Internal: igs, - SigningKey: id.Ed25519(signingKey.String), - SenderKey: id.Curve25519(senderKey.String), - RoomID: roomID, - ForwardingChains: chains, - RatchetSafety: rs, - ReceivedAt: receivedAt.Time, - MaxAge: maxAge.Int64, - MaxMessages: int(maxMessages.Int64), - IsScheduled: isScheduled, - KeyBackupVersion: version, - KeySource: keySource, - }, nil -} - -func (store *SQLCryptoStore) RedactGroupSession(ctx context.Context, _ id.RoomID, sessionID id.SessionID, reason string) error { - _, err := store.DB.Exec(ctx, ` - UPDATE crypto_megolm_inbound_session - SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL - WHERE session_id=$3 AND account_id=$4 AND session IS NOT NULL - `, event.RoomKeyWithheldBeeperRedacted, "Session redacted: "+reason, sessionID, store.AccountID) - return err -} - -func (store *SQLCryptoStore) RedactGroupSessions(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, reason string) ([]id.SessionID, error) { - if roomID == "" && senderKey == "" { - return nil, fmt.Errorf("room ID or sender key must be provided for redacting sessions") - } - res, err := store.DB.Query(ctx, ` - UPDATE crypto_megolm_inbound_session - SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL - WHERE (room_id=$3 OR $3='') AND (sender_key=$4 OR $4='') AND account_id=$5 - AND session IS NOT NULL AND is_scheduled=false AND received_at IS NOT NULL - RETURNING session_id - `, event.RoomKeyWithheldBeeperRedacted, "Session redacted: "+reason, roomID, senderKey, store.AccountID) - return dbutil.NewRowIterWithError(res, dbutil.ScanSingleColumn[id.SessionID], err).AsList() -} - -func (store *SQLCryptoStore) RedactExpiredGroupSessions(ctx context.Context) ([]id.SessionID, error) { - var query string - switch store.DB.Dialect { - case dbutil.Postgres: - query = ` - UPDATE crypto_megolm_inbound_session - SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL - WHERE account_id=$3 AND session IS NOT NULL AND is_scheduled=false - AND received_at IS NOT NULL and max_age IS NOT NULL - AND received_at + 2 * (max_age * interval '1 millisecond') < now() - RETURNING session_id - ` - case dbutil.SQLite: - query = ` - UPDATE crypto_megolm_inbound_session - SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL - WHERE account_id=$3 AND session IS NOT NULL AND is_scheduled=false - AND received_at IS NOT NULL and max_age IS NOT NULL - AND unixepoch(received_at) + (2 * max_age / 1000) < unixepoch(date('now')) - RETURNING session_id - ` - default: - return nil, fmt.Errorf("unsupported dialect") - } - res, err := store.DB.Query(ctx, query, event.RoomKeyWithheldBeeperRedacted, "Session redacted: expired", store.AccountID) - return dbutil.NewRowIterWithError(res, dbutil.ScanSingleColumn[id.SessionID], err).AsList() -} - -func (store *SQLCryptoStore) RedactOutdatedGroupSessions(ctx context.Context) ([]id.SessionID, error) { - res, err := store.DB.Query(ctx, ` - UPDATE crypto_megolm_inbound_session - SET withheld_code=$1, withheld_reason=$2, session=NULL, forwarding_chains=NULL - WHERE account_id=$3 AND session IS NOT NULL AND received_at IS NULL - RETURNING session_id - `, event.RoomKeyWithheldBeeperRedacted, "Session redacted: outdated", store.AccountID) - return dbutil.NewRowIterWithError(res, dbutil.ScanSingleColumn[id.SessionID], err).AsList() -} - -func (store *SQLCryptoStore) PutWithheldGroupSession(ctx context.Context, content event.RoomKeyWithheldEventContent) error { - _, err := store.DB.Exec(ctx, ` - INSERT INTO crypto_megolm_inbound_session (session_id, sender_key, room_id, withheld_code, withheld_reason, received_at, account_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (session_id, account_id) DO NOTHING - `, content.SessionID, content.SenderKey, content.RoomID, content.Code, content.Reason, time.Now().UTC(), store.AccountID) - return err -} - -func (store *SQLCryptoStore) GetWithheldGroupSession(ctx context.Context, roomID id.RoomID, sessionID id.SessionID) (*event.RoomKeyWithheldEventContent, error) { - var senderKey, code, reason sql.NullString - err := store.DB.QueryRow(ctx, ` - SELECT withheld_code, withheld_reason, sender_key FROM crypto_megolm_inbound_session - WHERE room_id=$1 AND session_id=$2 AND account_id=$3`, - roomID, sessionID, store.AccountID, - ).Scan(&code, &reason, &senderKey) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil || !code.Valid { - return nil, err - } - return &event.RoomKeyWithheldEventContent{ - RoomID: roomID, - Algorithm: id.AlgorithmMegolmV1, - SessionID: sessionID, - SenderKey: id.Curve25519(senderKey.String), - Code: event.RoomKeyWithheldCode(code.String), - Reason: reason.String, - }, nil -} - -func (store *SQLCryptoStore) postScanInboundGroupSession(sessionBytes, ratchetSafetyBytes []byte, forwardingChains string) (igs olm.InboundGroupSession, chains []string, safety RatchetSafety, err error) { - igs = olm.NewBlankInboundGroupSession() - err = igs.Unpickle(sessionBytes, store.PickleKey) - if err != nil { - return - } - if forwardingChains != "" { - chains = strings.Split(forwardingChains, ",") - } else { - chains = []string{} - } - var rs RatchetSafety - if len(ratchetSafetyBytes) > 0 { - err = json.Unmarshal(ratchetSafetyBytes, &rs) - if err != nil { - err = fmt.Errorf("failed to unmarshal ratchet safety info: %w", err) - } - } - return -} - -func (store *SQLCryptoStore) scanInboundGroupSession(rows dbutil.Scannable) (*InboundGroupSession, error) { - var roomID id.RoomID - var signingKey, senderKey, forwardingChains sql.NullString - var sessionBytes, ratchetSafetyBytes []byte - var receivedAt sql.NullTime - var maxAge, maxMessages sql.NullInt64 - var isScheduled bool - var version id.KeyBackupVersion - var keySource id.KeySource - err := rows.Scan(&roomID, &senderKey, &signingKey, &sessionBytes, &forwardingChains, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource) - if err != nil { - return nil, err - } - igs, chains, rs, err := store.postScanInboundGroupSession(sessionBytes, ratchetSafetyBytes, forwardingChains.String) - if err != nil { - return nil, err - } - return &InboundGroupSession{ - Internal: igs, - SigningKey: id.Ed25519(signingKey.String), - SenderKey: id.Curve25519(senderKey.String), - RoomID: roomID, - ForwardingChains: chains, - RatchetSafety: rs, - ReceivedAt: receivedAt.Time, - MaxAge: maxAge.Int64, - MaxMessages: int(maxMessages.Int64), - IsScheduled: isScheduled, - KeyBackupVersion: version, - KeySource: keySource, - }, nil -} - -func (store *SQLCryptoStore) GetGroupSessionsForRoom(ctx context.Context, roomID id.RoomID) dbutil.RowIter[*InboundGroupSession] { - rows, err := store.DB.Query(ctx, ` - SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source - FROM crypto_megolm_inbound_session WHERE room_id=$1 AND account_id=$2 AND session IS NOT NULL`, - roomID, store.AccountID, - ) - return dbutil.NewRowIterWithError(rows, store.scanInboundGroupSession, err) -} - -func (store *SQLCryptoStore) GetAllGroupSessions(ctx context.Context) dbutil.RowIter[*InboundGroupSession] { - rows, err := store.DB.Query(ctx, ` - SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source - FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL`, - store.AccountID, - ) - return dbutil.NewRowIterWithError(rows, store.scanInboundGroupSession, err) -} - -func (store *SQLCryptoStore) GetGroupSessionsWithoutKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] { - rows, err := store.DB.Query(ctx, ` - SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source - FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL AND key_backup_version != $2`, - store.AccountID, version, - ) - return dbutil.NewRowIterWithError(rows, store.scanInboundGroupSession, err) -} - -// AddOutboundGroupSession stores an outbound Megolm session, along with the information about the room and involved devices. -func (store *SQLCryptoStore) AddOutboundGroupSession(ctx context.Context, session *OutboundGroupSession) error { - sessionBytes, err := session.Internal.Pickle(store.PickleKey) - if err != nil { - return err - } - _, err = store.DB.Exec(ctx, ` - INSERT INTO crypto_megolm_outbound_session - (room_id, session_id, session, shared, max_messages, message_count, max_age, created_at, last_used, account_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (account_id, room_id) DO UPDATE - SET session_id=excluded.session_id, session=excluded.session, shared=excluded.shared, - max_messages=excluded.max_messages, message_count=excluded.message_count, max_age=excluded.max_age, - created_at=excluded.created_at, last_used=excluded.last_used, account_id=excluded.account_id - `, session.RoomID, session.ID(), sessionBytes, session.Shared, session.MaxMessages, session.MessageCount, - session.MaxAge.Milliseconds(), session.CreationTime, session.LastEncryptedTime, store.AccountID) - return err -} - -// UpdateOutboundGroupSession replaces an outbound Megolm session with for same room and session ID. -func (store *SQLCryptoStore) UpdateOutboundGroupSession(ctx context.Context, session *OutboundGroupSession) error { - sessionBytes, err := session.Internal.Pickle(store.PickleKey) - if err != nil { - return err - } - _, err = store.DB.Exec(ctx, "UPDATE crypto_megolm_outbound_session SET session=$1, message_count=$2, last_used=$3 WHERE room_id=$4 AND session_id=$5 AND account_id=$6", - sessionBytes, session.MessageCount, session.LastEncryptedTime, session.RoomID, session.ID(), store.AccountID) - return err -} - -// GetOutboundGroupSession retrieves the outbound Megolm session for the given room ID. -func (store *SQLCryptoStore) GetOutboundGroupSession(ctx context.Context, roomID id.RoomID) (*OutboundGroupSession, error) { - var ogs OutboundGroupSession - var sessionBytes []byte - var maxAgeMS int64 - err := store.DB.QueryRow(ctx, ` - SELECT session, shared, max_messages, message_count, max_age, created_at, last_used - FROM crypto_megolm_outbound_session WHERE room_id=$1 AND account_id=$2`, - roomID, store.AccountID, - ).Scan(&sessionBytes, &ogs.Shared, &ogs.MaxMessages, &ogs.MessageCount, &maxAgeMS, &ogs.CreationTime, &ogs.LastEncryptedTime) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - intOGS := olm.NewBlankOutboundGroupSession() - err = intOGS.Unpickle(sessionBytes, store.PickleKey) - if err != nil { - return nil, err - } - ogs.Internal = intOGS - ogs.RoomID = roomID - ogs.MaxAge = time.Duration(maxAgeMS) * time.Millisecond - return &ogs, nil -} - -// RemoveOutboundGroupSession removes the outbound Megolm session for the given room ID. -func (store *SQLCryptoStore) RemoveOutboundGroupSession(ctx context.Context, roomID id.RoomID) error { - _, err := store.DB.Exec(ctx, "DELETE FROM crypto_megolm_outbound_session WHERE room_id=$1 AND account_id=$2", - roomID, store.AccountID) - return err -} - -func (store *SQLCryptoStore) MarkOutboundGroupSessionShared(ctx context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) error { - _, err := store.DB.Exec(ctx, "INSERT INTO crypto_megolm_outbound_session_shared (user_id, identity_key, session_id) VALUES ($1, $2, $3)", userID, identityKey, sessionID) - return err -} - -func (store *SQLCryptoStore) IsOutboundGroupSessionShared(ctx context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) (shared bool, err error) { - err = store.DB.QueryRow(ctx, `SELECT TRUE FROM crypto_megolm_outbound_session_shared WHERE user_id=$1 AND identity_key=$2 AND session_id=$3`, - userID, identityKey, sessionID).Scan(&shared) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return -} - -// ValidateMessageIndex returns whether the given event information match the ones stored in the database -// for the given sender key, session ID and index. If the index hasn't been stored, this will store it. -func (store *SQLCryptoStore) ValidateMessageIndex(ctx context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { - if eventID == "" && timestamp == 0 { - var notOK bool - const validateEmptyQuery = ` - SELECT EXISTS(SELECT 1 FROM crypto_message_index WHERE session_id=$1 AND "index"=$2) - ` - err := store.DB.QueryRow(ctx, validateEmptyQuery, sessionID, index).Scan(¬OK) - if notOK { - zerolog.Ctx(ctx).Debug(). - Uint("message_index", index). - Msg("Rejecting event without event ID and timestamp due to already knowing them") - } - return !notOK, err - } - - const validateQuery = ` - INSERT INTO crypto_message_index (session_id, "index", event_id, timestamp) - VALUES ($1, $2, $3, $4) - -- have to update something so that RETURNING * always returns the row - ON CONFLICT (session_id, "index") DO UPDATE SET timestamp=crypto_message_index.timestamp - RETURNING event_id, timestamp - ` - var expectedEventID id.EventID - var expectedTimestamp int64 - err := store.DB.QueryRow(ctx, validateQuery, sessionID, index, eventID, timestamp).Scan(&expectedEventID, &expectedTimestamp) - if err != nil { - return false, err - } else if expectedEventID != eventID || expectedTimestamp != timestamp { - zerolog.Ctx(ctx).Debug(). - Uint("message_index", index). - Str("expected_event_id", expectedEventID.String()). - Int64("expected_timestamp", expectedTimestamp). - Int64("actual_timestamp", timestamp). - Msg("Rejecting different event with duplicate message index") - return false, nil - } - return true, nil -} - -func scanDevice(rows dbutil.Scannable) (*id.Device, error) { - var device id.Device - err := rows.Scan(&device.UserID, &device.DeviceID, &device.IdentityKey, &device.SigningKey, &device.Trust, &device.Deleted, &device.Name) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - return &device, nil -} - -// GetDevices returns a map of device IDs to device identities, including the identity and signing keys, for a given user ID. -func (store *SQLCryptoStore) GetDevices(ctx context.Context, userID id.UserID) (map[id.DeviceID]*id.Device, error) { - var ignore id.UserID - err := store.DB.QueryRow(ctx, "SELECT user_id FROM crypto_tracked_user WHERE user_id=$1", userID).Scan(&ignore) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - - rows, err := store.DB.Query(ctx, "SELECT user_id, device_id, identity_key, signing_key, trust, deleted, name FROM crypto_device WHERE user_id=$1 AND deleted=false", userID) - data := make(map[id.DeviceID]*id.Device) - err = dbutil.NewRowIterWithError(rows, scanDevice, err).Iter(func(device *id.Device) (bool, error) { - data[device.DeviceID] = device - return true, nil - }) - if err != nil { - return nil, err - } - return data, nil -} - -// GetDevice returns the device dentity for a given user and device ID. -func (store *SQLCryptoStore) GetDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*id.Device, error) { - return scanDevice(store.DB.QueryRow(ctx, ` - SELECT user_id, device_id, identity_key, signing_key, trust, deleted, name - FROM crypto_device WHERE user_id=$1 AND device_id=$2`, - userID, deviceID, - )) -} - -// FindDeviceByKey finds a specific device by its sender key. -func (store *SQLCryptoStore) FindDeviceByKey(ctx context.Context, userID id.UserID, identityKey id.IdentityKey) (*id.Device, error) { - return scanDevice(store.DB.QueryRow(ctx, ` - SELECT user_id, device_id, identity_key, signing_key, trust, deleted, name - FROM crypto_device WHERE user_id=$1 AND identity_key=$2`, - userID, identityKey, - )) -} - -const deviceInsertQuery = ` -INSERT INTO crypto_device (user_id, device_id, identity_key, signing_key, trust, deleted, name) -VALUES ($1, $2, $3, $4, $5, $6, $7) -ON CONFLICT (user_id, device_id) DO UPDATE - SET identity_key=excluded.identity_key, deleted=excluded.deleted, trust=excluded.trust, name=excluded.name -` - -var deviceMassInsertTemplate = strings.ReplaceAll(deviceInsertQuery, "($1, $2, $3, $4, $5, $6, $7)", "%s") - -// PutDevice stores a single device for a user, replacing it if it exists already. -func (store *SQLCryptoStore) PutDevice(ctx context.Context, userID id.UserID, device *id.Device) error { - _, err := store.DB.Exec(ctx, deviceInsertQuery, - userID, device.DeviceID, device.IdentityKey, device.SigningKey, device.Trust, device.Deleted, device.Name) - return err -} - -const trackedUserUpsertQuery = ` -INSERT INTO crypto_tracked_user (user_id, devices_outdated) -VALUES ($1, false) -ON CONFLICT (user_id) DO UPDATE - SET devices_outdated = EXCLUDED.devices_outdated -` - -// PutDevices stores the device identity information for the given user ID. -func (store *SQLCryptoStore) PutDevices(ctx context.Context, userID id.UserID, devices map[id.DeviceID]*id.Device) error { - return store.DB.DoTxn(ctx, nil, func(ctx context.Context) error { - _, err := store.DB.Exec(ctx, trackedUserUpsertQuery, userID) - if err != nil { - return fmt.Errorf("failed to upsert user to tracked users list: %w", err) - } - - _, err = store.DB.Exec(ctx, "UPDATE crypto_device SET deleted=true WHERE user_id=$1", userID) - if err != nil { - return fmt.Errorf("failed to delete old devices: %w", err) - } - if len(devices) == 0 { - return nil - } - deviceBatchLen := 5 // how many devices will be inserted per query - deviceIDs := make([]id.DeviceID, 0, len(devices)) - for deviceID := range devices { - deviceIDs = append(deviceIDs, deviceID) - } - const valueStringFormat = "($1, $%d, $%d, $%d, $%d, $%d, $%d)" - for batchDeviceIdx := 0; batchDeviceIdx < len(deviceIDs); batchDeviceIdx += deviceBatchLen { - var batchDevices []id.DeviceID - if batchDeviceIdx+deviceBatchLen < len(deviceIDs) { - batchDevices = deviceIDs[batchDeviceIdx : batchDeviceIdx+deviceBatchLen] - } else { - batchDevices = deviceIDs[batchDeviceIdx:] - } - values := make([]interface{}, 1, len(devices)*6+1) - values[0] = userID - valueStrings := make([]string, 0, len(devices)) - i := 2 - for _, deviceID := range batchDevices { - identity := devices[deviceID] - values = append(values, deviceID, identity.IdentityKey, identity.SigningKey, identity.Trust, identity.Deleted, identity.Name) - valueStrings = append(valueStrings, fmt.Sprintf(valueStringFormat, i, i+1, i+2, i+3, i+4, i+5)) - i += 6 - } - valueString := strings.Join(valueStrings, ",") - _, err = store.DB.Exec(ctx, fmt.Sprintf(deviceMassInsertTemplate, valueString), values...) - if err != nil { - return fmt.Errorf("failed to insert new devices: %w", err) - } - } - return nil - }) -} - -func userIDsToParams(users []id.UserID) (placeholders string, params []any) { - queryString := make([]string, len(users)) - params = make([]any, len(users)) - for i, user := range users { - queryString[i] = fmt.Sprintf("$%d", i+1) - params[i] = user - } - placeholders = strings.Join(queryString, ",") - return -} - -// FilterTrackedUsers finds all the user IDs out of the given ones for which the database contains identity information. -func (store *SQLCryptoStore) FilterTrackedUsers(ctx context.Context, users []id.UserID) ([]id.UserID, error) { - var rows dbutil.Rows - var err error - if store.DB.Dialect == dbutil.Postgres && PostgresArrayWrapper != nil { - rows, err = store.DB.Query(ctx, "SELECT user_id FROM crypto_tracked_user WHERE user_id = ANY($1)", PostgresArrayWrapper(users)) - } else { - placeholders, params := userIDsToParams(users) - rows, err = store.DB.Query(ctx, "SELECT user_id FROM crypto_tracked_user WHERE user_id IN ("+placeholders+")", params...) - } - return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() -} - -// MarkTrackedUsersOutdated flags that the device list for given users are outdated. -func (store *SQLCryptoStore) MarkTrackedUsersOutdated(ctx context.Context, users []id.UserID) (err error) { - for chunk := range slices.Chunk(users, 1000) { - if store.DB.Dialect == dbutil.Postgres && PostgresArrayWrapper != nil { - _, err = store.DB.Exec(ctx, "UPDATE crypto_tracked_user SET devices_outdated = true WHERE user_id = ANY($1)", PostgresArrayWrapper(chunk)) - } else { - placeholders, params := userIDsToParams(chunk) - _, err = store.DB.Exec(ctx, "UPDATE crypto_tracked_user SET devices_outdated = true WHERE user_id IN ("+placeholders+")", params...) - } - } - return -} - -// GetOutdatedTrackerUsers gets all tracked users whose devices need to be updated. -func (store *SQLCryptoStore) GetOutdatedTrackedUsers(ctx context.Context) ([]id.UserID, error) { - rows, err := store.DB.Query(ctx, "SELECT user_id FROM crypto_tracked_user WHERE devices_outdated = TRUE") - return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() -} - -// PutCrossSigningKey stores a cross-signing key of some user along with its usage. -func (store *SQLCryptoStore) PutCrossSigningKey(ctx context.Context, userID id.UserID, usage id.CrossSigningUsage, key id.Ed25519) error { - _, err := store.DB.Exec(ctx, ` - INSERT INTO crypto_cross_signing_keys (user_id, usage, key, first_seen_key) VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, usage) DO UPDATE SET key=excluded.key - `, userID, usage, key, key) - return err -} - -// GetCrossSigningKeys retrieves a user's stored cross-signing keys. -func (store *SQLCryptoStore) GetCrossSigningKeys(ctx context.Context, userID id.UserID) (map[id.CrossSigningUsage]id.CrossSigningKey, error) { - rows, err := store.DB.Query(ctx, "SELECT usage, key, first_seen_key FROM crypto_cross_signing_keys WHERE user_id=$1", userID) - if err != nil { - return nil, err - } - data := make(map[id.CrossSigningUsage]id.CrossSigningKey) - for rows.Next() { - var usage id.CrossSigningUsage - var key, first id.Ed25519 - err = rows.Scan(&usage, &key, &first) - if err != nil { - return nil, err - } - data[usage] = id.CrossSigningKey{Key: key, First: first} - } - - return data, nil -} - -// PutSignature stores a signature of a cross-signing or device key along with the signer's user ID and key. -func (store *SQLCryptoStore) PutSignature(ctx context.Context, signedUserID id.UserID, signedKey id.Ed25519, signerUserID id.UserID, signerKey id.Ed25519, signature string) error { - _, err := store.DB.Exec(ctx, ` - INSERT INTO crypto_cross_signing_signatures (signed_user_id, signed_key, signer_user_id, signer_key, signature) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (signed_user_id, signed_key, signer_user_id, signer_key) DO UPDATE SET signature=excluded.signature - `, signedUserID, signedKey, signerUserID, signerKey, signature) - return err -} - -// GetSignaturesForKeyBy retrieves the stored signatures for a given cross-signing or device key, by the given signer. -func (store *SQLCryptoStore) GetSignaturesForKeyBy(ctx context.Context, userID id.UserID, key id.Ed25519, signerID id.UserID) (map[id.Ed25519]string, error) { - rows, err := store.DB.Query(ctx, "SELECT signer_key, signature FROM crypto_cross_signing_signatures WHERE signed_user_id=$1 AND signed_key=$2 AND signer_user_id=$3", userID, key, signerID) - if err != nil { - return nil, err - } - data := make(map[id.Ed25519]string) - for rows.Next() { - var signerKey id.Ed25519 - var signature string - err = rows.Scan(&signerKey, &signature) - if err != nil { - return nil, err - } - data[signerKey] = signature - } - - return data, nil -} - -// IsKeySignedBy returns whether a cross-signing or device key is signed by the given signer. -func (store *SQLCryptoStore) IsKeySignedBy(ctx context.Context, signedUserID id.UserID, signedKey id.Ed25519, signerUserID id.UserID, signerKey id.Ed25519) (isSigned bool, err error) { - q := `SELECT EXISTS( - SELECT 1 FROM crypto_cross_signing_signatures - WHERE signed_user_id=$1 AND signed_key=$2 AND signer_user_id=$3 AND signer_key=$4 - )` - err = store.DB.QueryRow(ctx, q, signedUserID, signedKey, signerUserID, signerKey).Scan(&isSigned) - return -} - -// DropSignaturesByKey deletes the signatures made by the given user and key from the store. It returns the number of signatures deleted. -func (store *SQLCryptoStore) DropSignaturesByKey(ctx context.Context, userID id.UserID, key id.Ed25519) (int64, error) { - res, err := store.DB.Exec(ctx, "DELETE FROM crypto_cross_signing_signatures WHERE signer_user_id=$1 AND signer_key=$2", userID, key) - if err != nil { - return 0, err - } - count, err := res.RowsAffected() - if err != nil { - return 0, err - } - return count, nil -} - -func (store *SQLCryptoStore) PutSecret(ctx context.Context, name id.Secret, value string) error { - bytes, err := libolmpickle.Pickle(store.PickleKey, []byte(value)) - if err != nil { - return err - } - _, err = store.DB.Exec(ctx, ` - INSERT INTO crypto_secrets (account_id, name, secret) VALUES ($1, $2, $3) - ON CONFLICT (account_id, name) DO UPDATE SET secret=excluded.secret - `, store.AccountID, name, bytes) - return err -} - -func (store *SQLCryptoStore) GetSecret(ctx context.Context, name id.Secret) (value string, err error) { - var bytes []byte - err = store.DB.QueryRow(ctx, `SELECT secret FROM crypto_secrets WHERE account_id=$1 AND name=$2`, store.AccountID, name).Scan(&bytes) - if errors.Is(err, sql.ErrNoRows) { - return "", nil - } else if err != nil { - return "", err - } - bytes, err = libolmpickle.Unpickle(store.PickleKey, bytes) - return string(bytes), err -} - -func (store *SQLCryptoStore) DeleteSecret(ctx context.Context, name id.Secret) (err error) { - _, err = store.DB.Exec(ctx, "DELETE FROM crypto_secrets WHERE account_id=$1 AND name=$2", store.AccountID, name) - return -} diff --git a/mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql b/mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql deleted file mode 100644 index a5f1d04c..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/00-latest-revision.sql +++ /dev/null @@ -1,126 +0,0 @@ --- v0 -> v20 (compatible with v20+): Latest revision -CREATE TABLE crypto_account ( - account_id TEXT PRIMARY KEY, - device_id TEXT NOT NULL, - shared BOOLEAN NOT NULL, - sync_token TEXT NOT NULL, - account bytea NOT NULL, - key_backup_version TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE crypto_message_index ( - session_id CHAR(43), - "index" INTEGER, - event_id TEXT NOT NULL, - timestamp BIGINT NOT NULL, - PRIMARY KEY (session_id, "index") -); - -CREATE TABLE crypto_tracked_user ( - user_id TEXT PRIMARY KEY, - devices_outdated BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE TABLE crypto_device ( - user_id TEXT, - device_id TEXT, - identity_key CHAR(43) NOT NULL, - signing_key CHAR(43) NOT NULL, - trust SMALLINT NOT NULL, - deleted BOOLEAN NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (user_id, device_id) -); - -CREATE TABLE crypto_olm_session ( - account_id TEXT, - session_id CHAR(43), - sender_key CHAR(43) NOT NULL, - session bytea NOT NULL, - created_at timestamp NOT NULL, - last_decrypted timestamp NOT NULL, - last_encrypted timestamp NOT NULL, - PRIMARY KEY (account_id, session_id) -); -CREATE INDEX crypto_olm_session_sender_key_idx ON crypto_olm_session (account_id, sender_key); - -CREATE TABLE crypto_olm_message_hash ( - account_id TEXT NOT NULL, - received_at BIGINT NOT NULL, - message_hash bytea NOT NULL PRIMARY KEY, - - CONSTRAINT crypto_olm_message_hash_account_fkey FOREIGN KEY (account_id) - REFERENCES crypto_account (account_id) ON DELETE CASCADE ON UPDATE CASCADE -); -CREATE INDEX crypto_olm_message_hash_account_idx ON crypto_olm_message_hash (account_id); - -CREATE TABLE crypto_megolm_inbound_session ( - account_id TEXT, - session_id CHAR(43), - sender_key CHAR(43) NOT NULL, - signing_key CHAR(43), - room_id TEXT NOT NULL, - session bytea, - forwarding_chains bytea, - withheld_code TEXT, - withheld_reason TEXT, - ratchet_safety jsonb, - received_at timestamp, - max_age BIGINT, - max_messages INTEGER, - is_scheduled BOOLEAN NOT NULL DEFAULT false, - key_backup_version TEXT NOT NULL DEFAULT '', - key_source TEXT NOT NULL DEFAULT '', - PRIMARY KEY (account_id, session_id) -); --- Useful index to find keys that need backing up -CREATE INDEX crypto_megolm_inbound_session_backup_idx ON crypto_megolm_inbound_session(account_id, key_backup_version) WHERE session IS NOT NULL; - -CREATE TABLE crypto_megolm_outbound_session ( - account_id TEXT, - room_id TEXT, - session_id CHAR(43) NOT NULL UNIQUE, - session bytea NOT NULL, - shared BOOLEAN NOT NULL, - max_messages INTEGER NOT NULL, - message_count INTEGER NOT NULL, - max_age BIGINT NOT NULL, - created_at timestamp NOT NULL, - last_used timestamp NOT NULL, - PRIMARY KEY (account_id, room_id) -); - -CREATE TABLE crypto_megolm_outbound_session_shared ( - user_id TEXT NOT NULL, - identity_key CHAR(43) NOT NULL, - session_id CHAR(43) NOT NULL, - - PRIMARY KEY (user_id, identity_key, session_id) -); - -CREATE TABLE crypto_cross_signing_keys ( - user_id TEXT, - usage TEXT, - key CHAR(43) NOT NULL, - - first_seen_key CHAR(43) NOT NULL, - - PRIMARY KEY (user_id, usage) -); - -CREATE TABLE crypto_cross_signing_signatures ( - signed_user_id TEXT, - signed_key TEXT, - signer_user_id TEXT, - signer_key TEXT, - signature CHAR(88) NOT NULL, - PRIMARY KEY (signed_user_id, signed_key, signer_user_id, signer_key) -); - -CREATE TABLE crypto_secrets ( - account_id TEXT NOT NULL, - name TEXT NOT NULL, - secret bytea NOT NULL, - - PRIMARY KEY (account_id, name) -); diff --git a/mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql b/mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql deleted file mode 100644 index dc25b155..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/04-cross-signing-keys.sql +++ /dev/null @@ -1,16 +0,0 @@ --- v4: Add tables for cross-signing keys -CREATE TABLE IF NOT EXISTS crypto_cross_signing_keys ( - user_id VARCHAR(255) NOT NULL, - usage VARCHAR(20) NOT NULL, - key CHAR(43) NOT NULL, - PRIMARY KEY (user_id, usage) -); - -CREATE TABLE IF NOT EXISTS crypto_cross_signing_signatures ( - signed_user_id VARCHAR(255) NOT NULL, - signed_key VARCHAR(255) NOT NULL, - signer_user_id VARCHAR(255) NOT NULL, - signer_key VARCHAR(255) NOT NULL, - signature CHAR(88) NOT NULL, - PRIMARY KEY (signed_user_id, signed_key, signer_user_id, signer_key) -) diff --git a/mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql b/mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql deleted file mode 100644 index 868f87e8..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/05-varchar-to-text.sql +++ /dev/null @@ -1,31 +0,0 @@ --- v5: Switch from VARCHAR(255) to TEXT --- only: postgres - -ALTER TABLE crypto_account ALTER COLUMN device_id TYPE TEXT; -ALTER TABLE crypto_account ALTER COLUMN account_id TYPE TEXT; - -ALTER TABLE crypto_device ALTER COLUMN user_id TYPE TEXT; -ALTER TABLE crypto_device ALTER COLUMN device_id TYPE TEXT; -ALTER TABLE crypto_device ALTER COLUMN name TYPE TEXT; - -ALTER TABLE crypto_megolm_inbound_session ALTER COLUMN room_id TYPE TEXT; -ALTER TABLE crypto_megolm_inbound_session ALTER COLUMN account_id TYPE TEXT; -ALTER TABLE crypto_megolm_inbound_session ALTER COLUMN withheld_code TYPE TEXT; - -ALTER TABLE crypto_megolm_outbound_session ALTER COLUMN room_id TYPE TEXT; -ALTER TABLE crypto_megolm_outbound_session ALTER COLUMN account_id TYPE TEXT; - -ALTER TABLE crypto_message_index ALTER COLUMN event_id TYPE TEXT; - -ALTER TABLE crypto_olm_session ALTER COLUMN account_id TYPE TEXT; - -ALTER TABLE crypto_tracked_user ALTER COLUMN user_id TYPE TEXT; - -ALTER TABLE crypto_cross_signing_keys ALTER COLUMN user_id TYPE TEXT; -ALTER TABLE crypto_cross_signing_keys ALTER COLUMN usage TYPE TEXT; - -ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signed_user_id TYPE TEXT; -ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signed_key TYPE TEXT; -ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signer_user_id TYPE TEXT; -ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signer_key TYPE TEXT; -ALTER TABLE crypto_cross_signing_signatures ALTER COLUMN signature TYPE TEXT; diff --git a/mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql b/mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql deleted file mode 100644 index af5168f1..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/06-olm-session-last-used-split.sql +++ /dev/null @@ -1,6 +0,0 @@ --- v6: Split last_used into last_encrypted and last_decrypted for Olm sessions -ALTER TABLE crypto_olm_session RENAME COLUMN last_used TO last_decrypted; -ALTER TABLE crypto_olm_session ADD COLUMN last_encrypted timestamp; -UPDATE crypto_olm_session SET last_encrypted=last_decrypted; --- only: postgres (too complicated on SQLite) -ALTER TABLE crypto_olm_session ALTER COLUMN last_encrypted SET NOT NULL; diff --git a/mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql b/mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql deleted file mode 100644 index fd71f44b..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/07-trust-state-value-change.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v7: Update trust state values -UPDATE crypto_device SET trust=300 WHERE trust=1; -- verified -UPDATE crypto_device SET trust=-100 WHERE trust=2; -- blacklisted -UPDATE crypto_device SET trust=0 WHERE trust=3; -- ignored -> unset diff --git a/mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql b/mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql deleted file mode 100644 index 80c22f19..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/08-cs-key-expired-field.sql +++ /dev/null @@ -1,5 +0,0 @@ --- v8: Add expired field to cross signing keys -ALTER TABLE crypto_cross_signing_keys ADD COLUMN first_seen_key CHAR(43); -UPDATE crypto_cross_signing_keys SET first_seen_key=key; --- only: postgres -ALTER TABLE crypto_cross_signing_keys ALTER COLUMN first_seen_key SET NOT NULL; diff --git a/mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql b/mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql deleted file mode 100644 index 144f8a3c..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/09-max-age-ms.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v9: Change outbound megolm session max_age column to milliseconds -UPDATE crypto_megolm_outbound_session SET max_age=max_age/1000000; diff --git a/mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql b/mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql deleted file mode 100644 index 6dabc2df..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/10-mark-ratchetable-keys.sql +++ /dev/null @@ -1,6 +0,0 @@ --- v10: Add metadata for detecting when megolm sessions are safe to delete -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN ratchet_safety jsonb; -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN received_at timestamp; -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN max_age BIGINT; -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN max_messages INTEGER; -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN is_scheduled BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql b/mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql deleted file mode 100644 index f0f0ba5b..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/11-outdated-devices.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v11: Add devices_outdated field to crypto_tracked_user -ALTER TABLE crypto_tracked_user ADD COLUMN devices_outdated BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql b/mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql deleted file mode 100644 index d9f30ee7..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/12-secrets.sql +++ /dev/null @@ -1,5 +0,0 @@ --- v12 (compatible with v9+): Add crypto_secrets table -CREATE TABLE IF NOT EXISTS crypto_secrets ( - name TEXT PRIMARY KEY NOT NULL, - secret bytea NOT NULL -); diff --git a/mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql b/mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql deleted file mode 100644 index ea69f3cf..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/13-megolm-session-sharing.sql +++ /dev/null @@ -1,9 +0,0 @@ --- v13 (compatible with v9+): Add crypto_megolm_outbound_session_shared table - -CREATE TABLE IF NOT EXISTS crypto_megolm_outbound_session_shared ( - user_id TEXT NOT NULL, - identity_key CHAR(43) NOT NULL, - session_id CHAR(43) NOT NULL, - - PRIMARY KEY (user_id, identity_key, session_id) -); diff --git a/mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql b/mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql deleted file mode 100644 index e5236b62..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/14-account-key-backup-version.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v14 (compatible with v9+): Add key_backup_version column to account and igs - -ALTER TABLE crypto_account ADD COLUMN key_backup_version TEXT NOT NULL DEFAULT ''; -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN key_backup_version TEXT NOT NULL DEFAULT ''; diff --git a/mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql b/mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql deleted file mode 100644 index d49cffae..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/15-fix-secrets.sql +++ /dev/null @@ -1,21 +0,0 @@ --- v15: Fix crypto_secrets table -CREATE TABLE crypto_secrets_new ( - account_id TEXT NOT NULL, - name TEXT NOT NULL, - secret bytea NOT NULL, - - PRIMARY KEY (account_id, name) -); - -INSERT INTO crypto_secrets_new (account_id, name, secret) -SELECT '', name, secret -FROM crypto_secrets; - -DROP TABLE crypto_secrets; - -ALTER TABLE crypto_secrets_new RENAME TO crypto_secrets; - --- only: sqlite -UPDATE crypto_secrets SET account_id=(SELECT account_id FROM crypto_account ORDER BY rowid DESC LIMIT 1); --- only: postgres -UPDATE crypto_secrets SET account_id=(SELECT account_id FROM crypto_account LIMIT 1); diff --git a/mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql b/mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql deleted file mode 100644 index f0c3a0c5..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/16-crypto-olm-sessions-index.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v16 (compatible with v15+): Add index to crypto_olm_sessions to speedup lookups by sender_key -CREATE INDEX crypto_olm_session_sender_key_idx ON crypto_olm_session (account_id, sender_key); diff --git a/mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql b/mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql deleted file mode 100644 index 525bbb52..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/17-decrypted-olm-messages.sql +++ /dev/null @@ -1,11 +0,0 @@ --- v17 (compatible with v15+): Add table for decrypted Olm message hashes -CREATE TABLE crypto_olm_message_hash ( - account_id TEXT NOT NULL, - received_at BIGINT NOT NULL, - message_hash bytea NOT NULL PRIMARY KEY, - - CONSTRAINT crypto_olm_message_hash_account_fkey FOREIGN KEY (account_id) - REFERENCES crypto_account (account_id) ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE INDEX crypto_olm_message_hash_account_idx ON crypto_olm_message_hash (account_id); diff --git a/mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql b/mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql deleted file mode 100644 index da26da0f..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/18-megolm-inbound-session-backup-index.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v18 (compatible with v15+): Add an index to the megolm_inbound_session table to make finding sessions to backup faster -CREATE INDEX crypto_megolm_inbound_session_backup_idx ON crypto_megolm_inbound_session(account_id, key_backup_version) WHERE session IS NOT NULL; diff --git a/mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql b/mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql deleted file mode 100644 index f624222f..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/19-megolm-session-source.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v19 (compatible with v15+): Store megolm session source -ALTER TABLE crypto_megolm_inbound_session ADD COLUMN key_source TEXT NOT NULL DEFAULT ''; diff --git a/mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go b/mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go deleted file mode 100644 index f4db082a..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/20-message-key-index.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package sql_store_upgrade - -import ( - "context" - - "go.mau.fi/util/dbutil" -) - -var DropCryptoMessageIndexForRewrite = false - -func init() { - const rewritePostgres = ` - ALTER TABLE crypto_message_index DROP CONSTRAINT crypto_message_index_pkey; - ALTER TABLE crypto_message_index DROP COLUMN sender_key; - ALTER TABLE crypto_message_index ADD PRIMARY KEY (session_id, "index"); - ` - const createNewSQLite = ` - CREATE TABLE new_crypto_message_index ( - session_id CHAR(43), - "index" INTEGER, - event_id TEXT NOT NULL, - timestamp BIGINT NOT NULL, - PRIMARY KEY (session_id, "index") - ); - ` - const migrateSQLite = ` - INSERT INTO new_crypto_message_index (session_id, "index", event_id, timestamp) - SELECT session_id, "index", event_id, timestamp FROM crypto_message_index; - ` - const dropSQLite = ` - DROP TABLE crypto_message_index; - ALTER TABLE new_crypto_message_index RENAME TO crypto_message_index; - ` - Table.Register(-1, 20, 20, "Remove sender_key from crypto_message_index", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) (err error) { - switch db.Dialect { - case dbutil.Postgres: - _, err = db.Exec(ctx, rewritePostgres) - case dbutil.SQLite: - if DropCryptoMessageIndexForRewrite { - _, err = db.Exec(ctx, createNewSQLite+dropSQLite) - } else { - _, err = db.Exec(ctx, createNewSQLite+migrateSQLite+dropSQLite) - } - default: - err = dbutil.ErrUnsupportedDialect - } - return - }) -} diff --git a/mautrix-patched/crypto/sql_store_upgrade/upgrade.go b/mautrix-patched/crypto/sql_store_upgrade/upgrade.go deleted file mode 100644 index 10c0c0c0..00000000 --- a/mautrix-patched/crypto/sql_store_upgrade/upgrade.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package sql_store_upgrade - -import ( - "context" - "embed" - "fmt" - - "go.mau.fi/util/dbutil" -) - -var Table dbutil.UpgradeTable - -const VersionTableName = "crypto_version" - -//go:embed *.sql -var fs embed.FS - -func init() { - Table.Register(-1, 3, 0, "Unsupported version", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error { - return fmt.Errorf("upgrading from versions 1 and 2 of the crypto store is no longer supported in mautrix-go v0.12+") - }) - Table.RegisterFS(fs) -} diff --git a/mautrix-patched/crypto/ssss/client.go b/mautrix-patched/crypto/ssss/client.go deleted file mode 100644 index 865be4a0..00000000 --- a/mautrix-patched/crypto/ssss/client.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package ssss - -import ( - "context" - "errors" - "fmt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" -) - -// Machine contains utility methods for interacting with SSSS data on the server. -type Machine struct { - Client *mautrix.Client -} - -func NewSSSSMachine(client *mautrix.Client) *Machine { - return &Machine{ - Client: client, - } -} - -type DefaultSecretStorageKeyContent struct { - KeyID string `json:"key"` -} - -// GetDefaultKeyID retrieves the default key ID for this account from SSSS. -func (mach *Machine) GetDefaultKeyID(ctx context.Context) (string, error) { - var data DefaultSecretStorageKeyContent - err := mach.Client.GetAccountData(ctx, event.AccountDataSecretStorageDefaultKey.Type, &data) - if errors.Is(err, mautrix.MNotFound) { - return "", ErrNoDefaultKeyAccountDataEvent - } else if err != nil { - return "", fmt.Errorf("failed to get default key account data from server: %w", err) - } - if len(data.KeyID) == 0 { - return "", ErrNoKeyFieldInAccountDataEvent - } - return data.KeyID, nil -} - -// SetDefaultKeyID sets the default key ID for this account on the server. -func (mach *Machine) SetDefaultKeyID(ctx context.Context, keyID string) error { - return mach.Client.SetAccountData(ctx, event.AccountDataSecretStorageDefaultKey.Type, &DefaultSecretStorageKeyContent{keyID}) -} - -// GetKeyData gets the details about the given key ID. -func (mach *Machine) GetKeyData(ctx context.Context, keyID string) (keyData *KeyMetadata, err error) { - keyData = &KeyMetadata{} - err = mach.Client.GetAccountData(ctx, fmt.Sprintf("%s.%s", event.AccountDataSecretStorageKey.Type, keyID), keyData) - return -} - -// SetKeyData stores SSSS key metadata on the server. -func (mach *Machine) SetKeyData(ctx context.Context, keyID string, keyData *KeyMetadata) error { - return mach.Client.SetAccountData(ctx, fmt.Sprintf("%s.%s", event.AccountDataSecretStorageKey.Type, keyID), keyData) -} - -// GetDefaultKeyData gets the details about the default key ID (see GetDefaultKeyID). -func (mach *Machine) GetDefaultKeyData(ctx context.Context) (keyID string, keyData *KeyMetadata, err error) { - keyID, err = mach.GetDefaultKeyID(ctx) - if err != nil { - return - } - keyData, err = mach.GetKeyData(ctx, keyID) - return -} - -// GetDecryptedAccountData gets the account data event with the given event type and decrypts it using the given key. -func (mach *Machine) GetDecryptedAccountData(ctx context.Context, eventType event.Type, key *Key) ([]byte, error) { - var encData EncryptedAccountDataEventContent - err := mach.Client.GetAccountData(ctx, eventType.Type, &encData) - if err != nil { - return nil, err - } - return encData.Decrypt(eventType.Type, key) -} - -// SetEncryptedAccountData encrypts the given data with the given keys and stores it on the server. -func (mach *Machine) SetEncryptedAccountData(ctx context.Context, eventType event.Type, data []byte, keys ...*Key) error { - if len(keys) == 0 { - return ErrNoKeyGiven - } - encrypted := make(map[string]EncryptedKeyData, len(keys)) - for _, key := range keys { - encrypted[key.ID] = key.Encrypt(eventType.Type, data) - } - return mach.Client.SetAccountData(ctx, eventType.Type, &EncryptedAccountDataEventContent{Encrypted: encrypted}) -} - -// SetEncryptedAccountDataWithMetadata encrypts the given data with the given keys and stores it, -// alongside the unencrypted metadata, on the server. -func (mach *Machine) SetEncryptedAccountDataWithMetadata(ctx context.Context, eventType event.Type, data []byte, metadata map[string]any, keys ...*Key) error { - if len(keys) == 0 { - return ErrNoKeyGiven - } - encrypted := make(map[string]EncryptedKeyData, len(keys)) - for _, key := range keys { - encrypted[key.ID] = key.Encrypt(eventType.Type, data) - } - return mach.Client.SetAccountData(ctx, eventType.Type, &EncryptedAccountDataEventContent{ - Encrypted: encrypted, - Metadata: metadata, - }) -} - -// GenerateAndUploadKey generates a new SSSS key and stores the metadata on the server. -func (mach *Machine) GenerateAndUploadKey(ctx context.Context, passphrase string) (key *Key, err error) { - key, err = NewKey(passphrase) - if err != nil { - return nil, fmt.Errorf("failed to generate new key: %w", err) - } - - err = mach.SetKeyData(ctx, key.ID, key.Metadata) - if err != nil { - err = fmt.Errorf("failed to upload key: %w", err) - } - return key, err -} diff --git a/mautrix-patched/crypto/ssss/key.go b/mautrix-patched/crypto/ssss/key.go deleted file mode 100644 index 763a3253..00000000 --- a/mautrix-patched/crypto/ssss/key.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package ssss - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "fmt" - "strings" - - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/crypto/utils" -) - -// Key represents a SSSS private key and related metadata. -type Key struct { - ID string `json:"-"` - Key []byte `json:"-"` - Metadata *KeyMetadata `json:"-"` -} - -// NewKey generates a new SSSS key, optionally based on the given passphrase. -// -// Errors are only returned if crypto/rand runs out of randomness. -func NewKey(passphrase string) (*Key, error) { - if len(passphrase) > 0 { - // There's a passphrase. We need to generate a salt for it, set the metadata - // and then compute the key using the passphrase and the metadata. - passphraseMeta := NewPassphraseMetadata() - ssssKey, err := passphraseMeta.GetKey(passphrase) - if err != nil { - return nil, fmt.Errorf("failed to get key from passphrase: %w", err) - } - return WrapKey(ssssKey, passphraseMeta) - } - // No passphrase, just generate a random key - return WrapKey(random.Bytes(32), nil) -} - -func WrapKey(ssssKey []byte, passphraseMeta *PassphraseMetadata) (*Key, error) { - if len(ssssKey) != 32 { - return nil, fmt.Errorf("%w: must be 32 bytes", ErrInvalidRecoveryKey) - } - // We don't support any other algorithms currently. - keyData := KeyMetadata{ - Algorithm: AlgorithmAESHMACSHA2, - Passphrase: passphraseMeta, - } - - // Generate a random ID for the key. It's what identifies the key in account data. - keyIDBytes := random.Bytes(24) - - // We store a certain hash in the key metadata so that clients can check if the user entered the correct key. - ivBytes := random.Bytes(utils.AESCTRIVLength) - keyData.IV = base64.RawStdEncoding.EncodeToString(ivBytes) - macBytes, err := keyData.calculateHash(ssssKey) - if err != nil { - // This should never happen because we just generated the IV and key. - return nil, fmt.Errorf("failed to calculate hash: %w", err) - } - keyData.MAC = base64.RawStdEncoding.EncodeToString(macBytes) - - return &Key{ - Key: ssssKey, - ID: base64.RawStdEncoding.EncodeToString(keyIDBytes), - Metadata: &keyData, - }, nil -} - -// RecoveryKey gets the recovery key for this SSSS key. -func (key *Key) RecoveryKey() string { - return utils.EncodeBase58RecoveryKey(key.Key) -} - -// Encrypt encrypts the given data with this key. -func (key *Key) Encrypt(eventType string, data []byte) EncryptedKeyData { - aesKey, hmacKey := utils.DeriveKeysSHA256(key.Key, eventType) - - iv := utils.GenA256CTRIV() - // For some reason, keys in secret storage are base64 encoded before encrypting. - // Even more confusingly, it's a part of each key type's spec rather than the secrets spec. - // Key backup (`m.megolm_backup.v1`): https://spec.matrix.org/v1.9/client-server-api/#recovery-key - // Cross-signing (master, etc): https://spec.matrix.org/v1.9/client-server-api/#cross-signing (the very last paragraph) - // It's also not clear whether unpadded base64 is the right option, but assuming it is because everything else is unpadded. - payload := make([]byte, base64.RawStdEncoding.EncodedLen(len(data))) - base64.RawStdEncoding.Encode(payload, data) - utils.XorA256CTR(payload, aesKey, iv) - - return EncryptedKeyData{ - Ciphertext: base64.RawStdEncoding.EncodeToString(payload), - IV: base64.RawStdEncoding.EncodeToString(iv[:]), - MAC: utils.HMACSHA256B64(payload, hmacKey), - } -} - -// Decrypt decrypts the given encrypted data with this key. -func (key *Key) Decrypt(eventType string, data EncryptedKeyData) ([]byte, error) { - var ivBytes [utils.AESCTRIVLength]byte - decodedIV, _ := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.IV, "=")) - copy(ivBytes[:], decodedIV) - - payload, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.Ciphertext, "=")) - if err != nil { - return nil, err - } - - mac, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(data.MAC, "=")) - if err != nil { - return nil, err - } - - // derive the AES and HMAC keys for the requested event type using the SSSS key - aesKey, hmacKey := utils.DeriveKeysSHA256(key.Key, eventType) - - // compare the stored MAC with the one we calculated from the ciphertext - h := hmac.New(sha256.New, hmacKey[:]) - h.Write(payload) - if !hmac.Equal(h.Sum(nil), mac) { - return nil, ErrKeyDataMACMismatch - } - - utils.XorA256CTR(payload, aesKey, ivBytes) - decryptedDecoded, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(string(payload), "=")) - return decryptedDecoded, err -} diff --git a/mautrix-patched/crypto/ssss/key_test.go b/mautrix-patched/crypto/ssss/key_test.go deleted file mode 100644 index 5fd879db..00000000 --- a/mautrix-patched/crypto/ssss/key_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package ssss_test - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/ssss" - "maunium.net/go/mautrix/event" -) - -const key1CrossSigningMasterKey = ` -{ - "encrypted": { - "gEJqbfSEMnP5JXXcukpXEX1l0aI3MDs0": { - "iv": "BpKP9nQJTE9jrsAssoxPqQ==", - "ciphertext": "fNRiiiidezjerTgV+G6pUtmeF3izzj5re/mVvY0hO2kM6kYGrxLuIu2ej80=", - "mac": "/gWGDGMyOLmbJp+aoSLh5JxCs0AdS6nAhjzpe+9G2Q0=" - } - } -} -` - -var key1CrossSigningMasterKeyDecrypted = []byte{ - 0x68, 0xf9, 0x7f, 0xd1, 0x92, 0x2e, 0xec, 0xf6, - 0xb8, 0x2b, 0xb8, 0x90, 0xd2, 0x4d, 0x06, 0x52, - 0x98, 0x4e, 0x7a, 0x1d, 0x70, 0x3b, 0x9e, 0x86, - 0x7b, 0x7e, 0xba, 0xf7, 0xfe, 0xb9, 0x5b, 0x6f, -} - -func getEncryptedMasterKey() *ssss.EncryptedAccountDataEventContent { - var eadec ssss.EncryptedAccountDataEventContent - err := json.Unmarshal([]byte(key1CrossSigningMasterKey), &eadec) - if err != nil { - panic(err) - } - return &eadec -} - -func TestKey_Decrypt_Success(t *testing.T) { - key := getKey1() - emk := getEncryptedMasterKey() - decrypted, err := emk.Decrypt(event.AccountDataCrossSigningMaster.Type, key) - assert.NoError(t, err) - assert.Equal(t, key1CrossSigningMasterKeyDecrypted, decrypted) -} - -func TestKey_Decrypt_WrongKey(t *testing.T) { - key := getKey2() - emk := getEncryptedMasterKey() - decrypted, err := emk.Decrypt(event.AccountDataCrossSigningMaster.Type, key) - assert.True(t, errors.Is(err, ssss.ErrNotEncryptedForKey), "unexpected error %v", err) - assert.Nil(t, decrypted) -} - -func TestKey_Decrypt_FakeKey(t *testing.T) { - key := getKey2() - key.ID = key1ID - emk := getEncryptedMasterKey() - decrypted, err := emk.Decrypt(event.AccountDataCrossSigningMaster.Type, key) - assert.True(t, errors.Is(err, ssss.ErrKeyDataMACMismatch), "unexpected error %v", err) - assert.Nil(t, decrypted) -} - -func TestKey_Decrypt_WrongType(t *testing.T) { - key := getKey1() - emk := getEncryptedMasterKey() - decrypted, err := emk.Decrypt(event.AccountDataCrossSigningSelf.Type, key) - assert.True(t, errors.Is(err, ssss.ErrKeyDataMACMismatch), "unexpected error %v", err) - assert.Nil(t, decrypted) -} - -func TestKey_Encrypt(t *testing.T) { - key1 := getKey1() - var evtType = "net.maunium.data" - var data = []byte{0xde, 0xad, 0xbe, 0xef} - encrypted := key1.Encrypt(evtType, data) - decrypted, err := key1.Decrypt(evtType, encrypted) - assert.NoError(t, err) - assert.Equal(t, data, decrypted) -} diff --git a/mautrix-patched/crypto/ssss/meta.go b/mautrix-patched/crypto/ssss/meta.go deleted file mode 100644 index 7d88e4b9..00000000 --- a/mautrix-patched/crypto/ssss/meta.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package ssss - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "strings" - - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/crypto/utils" -) - -// KeyMetadata represents server-side metadata about a SSSS key. The metadata can be used to get -// the actual SSSS key from a passphrase or recovery key. -type KeyMetadata struct { - Name string `json:"name,omitempty"` - Algorithm Algorithm `json:"algorithm"` - - // Note: as per https://spec.matrix.org/v1.9/client-server-api/#msecret_storagev1aes-hmac-sha2, - // these fields are "maybe padded" base64, so both unpadded and padded values must be supported. - IV string `json:"iv,omitempty"` - MAC string `json:"mac,omitempty"` - - Passphrase *PassphraseMetadata `json:"passphrase,omitempty"` -} - -// VerifyRecoveryKey verifies that the given passphrase is valid and returns the computed SSSS key. -func (kd *KeyMetadata) VerifyPassphrase(keyID, passphrase string) (*Key, error) { - ssssKey, err := kd.Passphrase.GetKey(passphrase) - if err != nil { - return nil, err - } - err = kd.verifyKey(ssssKey) - if err != nil && !errors.Is(err, ErrUnverifiableKey) { - return nil, err - } - - return &Key{ - ID: keyID, - Key: ssssKey, - Metadata: kd, - }, nil -} - -// VerifyRecoveryKey verifies that the given recovery key is valid and returns the decoded SSSS key. -func (kd *KeyMetadata) VerifyRecoveryKey(keyID, recoveryKey string) (*Key, error) { - ssssKey := utils.DecodeBase58RecoveryKey(recoveryKey) - if ssssKey == nil { - return nil, ErrInvalidRecoveryKey - } - err := kd.verifyKey(ssssKey) - if err != nil && !errors.Is(err, ErrUnverifiableKey) { - return nil, err - } - - return &Key{ - ID: keyID, - Key: ssssKey, - Metadata: kd, - }, err -} - -func (kd *KeyMetadata) verifyKey(key []byte) error { - if kd.MAC == "" || kd.IV == "" { - return ErrUnverifiableKey - } - unpaddedMAC := strings.TrimRight(kd.MAC, "=") - expectedMACLength := base64.RawStdEncoding.EncodedLen(utils.SHAHashLength) - if len(unpaddedMAC) != expectedMACLength { - return fmt.Errorf("%w: invalid mac length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedMAC), expectedMACLength) - } - expectedMAC, err := base64.RawStdEncoding.DecodeString(unpaddedMAC) - if err != nil { - return fmt.Errorf("%w: failed to decode mac: %w", ErrCorruptedKeyMetadata, err) - } - calculatedMAC, err := kd.calculateHash(key) - if err != nil { - return err - } - // This doesn't really need to be constant time since it's fully local, but might as well be. - if !hmac.Equal(expectedMAC, calculatedMAC) { - return ErrIncorrectSSSSKey - } - return nil -} - -// VerifyKey verifies the SSSS key is valid by calculating and comparing its MAC. -func (kd *KeyMetadata) VerifyKey(key []byte) bool { - return kd.verifyKey(key) == nil -} - -// calculateHash calculates the hash used for checking if the key is entered correctly as described -// in the spec: https://matrix.org/docs/spec/client_server/unstable#m-secret-storage-v1-aes-hmac-sha2 -func (kd *KeyMetadata) calculateHash(key []byte) ([]byte, error) { - aesKey, hmacKey := utils.DeriveKeysSHA256(key, "") - unpaddedIV := strings.TrimRight(kd.IV, "=") - expectedIVLength := base64.RawStdEncoding.EncodedLen(utils.AESCTRIVLength) - if len(unpaddedIV) < expectedIVLength || len(unpaddedIV) > expectedIVLength*3 { - return nil, fmt.Errorf("%w: invalid iv length %d (expected %d)", ErrCorruptedKeyMetadata, len(unpaddedIV), expectedIVLength) - } - rawIVBytes, err := base64.RawStdEncoding.DecodeString(unpaddedIV) - if err != nil { - return nil, fmt.Errorf("%w: failed to decode iv: %w", ErrCorruptedKeyMetadata, err) - } - // TODO log a warning for non-16 byte IVs? - // Certain broken clients like nheko generated 32-byte IVs where only the first 16 bytes were used. - ivBytes := *(*[utils.AESCTRIVLength]byte)(rawIVBytes[:utils.AESCTRIVLength]) - - zeroes := make([]byte, utils.AESCTRKeyLength) - encryptedZeroes := utils.XorA256CTR(zeroes, aesKey, ivBytes) - h := hmac.New(sha256.New, hmacKey[:]) - h.Write(encryptedZeroes) - return h.Sum(nil), nil -} - -// PassphraseMetadata represents server-side metadata about a SSSS key passphrase. -type PassphraseMetadata struct { - Algorithm PassphraseAlgorithm `json:"algorithm"` - Iterations int `json:"iterations"` - Salt string `json:"salt"` - Bits int `json:"bits"` -} - -func NewPassphraseMetadata() *PassphraseMetadata { - return &PassphraseMetadata{ - Algorithm: PassphraseAlgorithmPBKDF2, - Iterations: 500000, - Salt: base64.StdEncoding.EncodeToString(random.Bytes(24)), - Bits: 256, - } -} - -// GetKey gets the SSSS key from the passphrase. -func (pd *PassphraseMetadata) GetKey(passphrase string) ([]byte, error) { - if pd == nil { - return nil, ErrNoPassphrase - } - - if pd.Algorithm != PassphraseAlgorithmPBKDF2 { - return nil, fmt.Errorf("%w: %s", ErrUnsupportedPassphraseAlgorithm, pd.Algorithm) - } - - bits := 256 - if pd.Bits != 0 { - bits = pd.Bits - } - - return utils.PBKDF2SHA512([]byte(passphrase), []byte(pd.Salt), pd.Iterations, bits), nil -} diff --git a/mautrix-patched/crypto/ssss/meta_test.go b/mautrix-patched/crypto/ssss/meta_test.go deleted file mode 100644 index d59809c7..00000000 --- a/mautrix-patched/crypto/ssss/meta_test.go +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package ssss_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/crypto/ssss" -) - -const key1Meta = ` -{ - "algorithm": "m.secret_storage.v1.aes-hmac-sha2", - "passphrase": { - "algorithm": "m.pbkdf2", - "iterations": 500000, - "salt": "y863BOoqOadgDp8S3FtHXikDJEalsQ7d" - }, - "iv": "xxkTK0L4UzxgAFkQ6XPwsw", - "mac": "MEhooO0ZhFJNxUhvRMSxBnJfL20wkLgle3ocY0ee/eA" -} -` -const key1ID = "gEJqbfSEMnP5JXXcukpXEX1l0aI3MDs0" - -const key1RecoveryKey = "EsTE s92N EtaX s2h6 VQYF 9Kao tHYL mkyL GKMh isZb KJ4E tvoC" -const key1Passphrase = "correct horse battery staple" - -const key2Meta = ` -{ - "algorithm": "m.secret_storage.v1.aes-hmac-sha2", - "iv": "O0BOvTqiIAYjC+RMcyHfWw==", - "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" -} -` - -const key2MetaUnverified = ` -{ - "algorithm": "m.secret_storage.v1.aes-hmac-sha2" -} -` - -const key2MetaLongIV = ` -{ - "algorithm": "m.secret_storage.v1.aes-hmac-sha2", - "iv": "O0BOvTqiIAYjC+RMcyHfW2f/gdxjceTxoYtNlpPduJ8=", - "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" -} -` - -const key2MetaBrokenIV = ` -{ - "algorithm": "m.secret_storage.v1.aes-hmac-sha2", - "iv": "MeowMeowMeow", - "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtI=" -} -` - -const key2MetaBrokenMAC = ` -{ - "algorithm": "m.secret_storage.v1.aes-hmac-sha2", - "iv": "O0BOvTqiIAYjC+RMcyHfWw==", - "mac": "7k6OruQlWg0UmQjxGZ0ad4Q6DdwkgnoI7G6X3IjBYtIMeowMeowMeow" -} -` - -const key2ID = "NVe5vK6lZS9gEMQLJw0yqkzmE5Mr7dLv" -const key2RecoveryKey = "EsUC xSxt XJgQ dz19 8WBZ rHdE GZo7 ybsn EFmG Y5HY MDAG GNWe" - -func getKeyMeta(meta string) *ssss.KeyMetadata { - var km ssss.KeyMetadata - err := json.Unmarshal([]byte(meta), &km) - if err != nil { - panic(err) - } - return &km -} - -func getKey1() *ssss.Key { - return exerrors.Must(getKeyMeta(key1Meta).VerifyRecoveryKey(key1ID, key1RecoveryKey)) -} - -func getKey2() *ssss.Key { - return exerrors.Must(getKeyMeta(key2Meta).VerifyRecoveryKey(key2ID, key2RecoveryKey)) -} - -func TestKeyMetadata_VerifyRecoveryKey_Correct(t *testing.T) { - km := getKeyMeta(key1Meta) - key, err := km.VerifyRecoveryKey(key1ID, key1RecoveryKey) - assert.NoError(t, err) - assert.NotNil(t, key) - assert.Equal(t, key1RecoveryKey, key.RecoveryKey()) -} - -func TestKeyMetadata_VerifyRecoveryKey_Correct2(t *testing.T) { - km := getKeyMeta(key2Meta) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.NoError(t, err) - assert.NotNil(t, key) - assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) -} - -func TestKeyMetadata_VerifyRecoveryKey_NonCompliant_LongIV(t *testing.T) { - km := getKeyMeta(key2MetaLongIV) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.NoError(t, err) - assert.NotNil(t, key) - assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) -} - -func TestKeyMetadata_VerifyRecoveryKey_Unverified(t *testing.T) { - km := getKeyMeta(key2MetaUnverified) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.ErrorIs(t, err, ssss.ErrUnverifiableKey) - assert.NotNil(t, key) - assert.Equal(t, key2RecoveryKey, key.RecoveryKey()) -} - -func TestKeyMetadata_VerifyRecoveryKey_Invalid(t *testing.T) { - km := getKeyMeta(key1Meta) - key, err := km.VerifyRecoveryKey(key1ID, "foo") - assert.ErrorIs(t, err, ssss.ErrInvalidRecoveryKey) - assert.Nil(t, key) -} - -func TestKeyMetadata_VerifyRecoveryKey_Incorrect(t *testing.T) { - km := getKeyMeta(key1Meta) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey) - assert.Nil(t, key) -} - -func TestKeyMetadata_VerifyPassphrase_Correct(t *testing.T) { - km := getKeyMeta(key1Meta) - key, err := km.VerifyPassphrase(key1ID, key1Passphrase) - assert.NoError(t, err) - assert.NotNil(t, key) - assert.Equal(t, key1RecoveryKey, key.RecoveryKey()) -} - -func TestKeyMetadata_VerifyPassphrase_Incorrect(t *testing.T) { - km := getKeyMeta(key1Meta) - key, err := km.VerifyPassphrase(key1ID, "incorrect horse battery staple") - assert.ErrorIs(t, err, ssss.ErrIncorrectSSSSKey) - assert.Nil(t, key) -} - -func TestKeyMetadata_VerifyPassphrase_NotSet(t *testing.T) { - km := getKeyMeta(key2Meta) - key, err := km.VerifyPassphrase(key2ID, "hmm") - assert.ErrorIs(t, err, ssss.ErrNoPassphrase) - assert.Nil(t, key) -} - -func TestKeyMetadata_VerifyRecoveryKey_CorruptedIV(t *testing.T) { - km := getKeyMeta(key2MetaBrokenIV) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata) - assert.Nil(t, key) -} - -func TestKeyMetadata_VerifyRecoveryKey_CorruptedMAC(t *testing.T) { - km := getKeyMeta(key2MetaBrokenMAC) - key, err := km.VerifyRecoveryKey(key2ID, key2RecoveryKey) - assert.ErrorIs(t, err, ssss.ErrCorruptedKeyMetadata) - assert.Nil(t, key) -} diff --git a/mautrix-patched/crypto/ssss/types.go b/mautrix-patched/crypto/ssss/types.go deleted file mode 100644 index b7465d3e..00000000 --- a/mautrix-patched/crypto/ssss/types.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package ssss - -import ( - "errors" - "fmt" - "reflect" - - "maunium.net/go/mautrix/event" -) - -var ( - ErrNoDefaultKeyID = errors.New("could not find default key ID") - ErrNoDefaultKeyAccountDataEvent = fmt.Errorf("%w: no %s event in account data", ErrNoDefaultKeyID, event.AccountDataSecretStorageDefaultKey.Type) - ErrNoKeyFieldInAccountDataEvent = fmt.Errorf("%w: missing key field in account data event", ErrNoDefaultKeyID) - ErrNoKeyGiven = errors.New("must provide at least one key to encrypt for") - - ErrNotEncryptedForKey = errors.New("data is not encrypted for given key ID") - ErrKeyDataMACMismatch = errors.New("key data MAC mismatch") - ErrNoPassphrase = errors.New("no passphrase data has been set for the default key") - ErrUnsupportedPassphraseAlgorithm = errors.New("unsupported passphrase KDF algorithm") - ErrIncorrectSSSSKey = errors.New("incorrect SSSS key") - ErrInvalidRecoveryKey = errors.New("invalid recovery key") - ErrCorruptedKeyMetadata = errors.New("corrupted recovery key metadata") - ErrUnverifiableKey = errors.New("cannot verify recovery key: missing MAC or IV in metadata") -) - -// Algorithm is the identifier for an SSSS encryption algorithm. -type Algorithm string - -const ( - // AlgorithmAESHMACSHA2 is the current main algorithm. - AlgorithmAESHMACSHA2 Algorithm = "m.secret_storage.v1.aes-hmac-sha2" - // AlgorithmCurve25519AESSHA2 is the old algorithm - AlgorithmCurve25519AESSHA2 Algorithm = "m.secret_storage.v1.curve25519-aes-sha2" -) - -// PassphraseAlgorithm is the identifier for an algorithm used to derive a key from a passphrase for SSSS. -type PassphraseAlgorithm string - -const ( - // PassphraseAlgorithmPBKDF2 is the current main algorithm - PassphraseAlgorithmPBKDF2 PassphraseAlgorithm = "m.pbkdf2" -) - -type EncryptedKeyData struct { - // Note: as per https://spec.matrix.org/v1.9/client-server-api/#msecret_storagev1aes-hmac-sha2-1, - // these fields are "maybe padded" base64, so both unpadded and padded values must be supported. - Ciphertext string `json:"ciphertext"` - IV string `json:"iv"` - MAC string `json:"mac"` -} - -type EncryptedAccountDataEventContent struct { - Encrypted map[string]EncryptedKeyData `json:"encrypted"` - Metadata map[string]any `json:"com.beeper.metadata,omitzero"` -} - -func (ed *EncryptedAccountDataEventContent) Decrypt(eventType string, key *Key) ([]byte, error) { - keyEncData, ok := ed.Encrypted[key.ID] - if !ok { - return nil, ErrNotEncryptedForKey - } - - return key.Decrypt(eventType, keyEncData) -} - -func init() { - encryptedContent := reflect.TypeOf(&EncryptedAccountDataEventContent{}) - event.TypeMap[event.AccountDataCrossSigningMaster] = encryptedContent - event.TypeMap[event.AccountDataCrossSigningSelf] = encryptedContent - event.TypeMap[event.AccountDataCrossSigningUser] = encryptedContent - event.TypeMap[event.AccountDataSecretStorageDefaultKey] = reflect.TypeOf(&DefaultSecretStorageKeyContent{}) - event.TypeMap[event.AccountDataSecretStorageKey] = reflect.TypeOf(&KeyMetadata{}) - event.TypeMap[event.AccountDataMegolmBackupKey] = reflect.TypeOf(&EncryptedAccountDataEventContent{}) -} diff --git a/mautrix-patched/crypto/store.go b/mautrix-patched/crypto/store.go deleted file mode 100644 index ba810ae1..00000000 --- a/mautrix-patched/crypto/store.go +++ /dev/null @@ -1,755 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "fmt" - "slices" - "sort" - "sync" - "time" - - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exsync" - "golang.org/x/exp/maps" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ErrGroupSessionWithheld error = &event.RoomKeyWithheldEventContent{} - -// Store is used by OlmMachine to store Olm and Megolm sessions, user device lists and message indices. -// -// General implementation details: -// * Get methods should not return errors if the requested data does not exist in the store, they should simply return nil. -// * Update methods may assume that the pointer is the same as what has earlier been added to or fetched from the store. -type Store interface { - // Flush ensures that everything in the store is persisted to disk. - // This doesn't have to do anything, e.g. for database-backed implementations that persist everything immediately. - Flush(context.Context) error - - // PutAccount updates the OlmAccount in the store. - PutAccount(context.Context, *OlmAccount) error - // GetAccount returns the OlmAccount in the store that was previously inserted with PutAccount. - GetAccount(ctx context.Context) (*OlmAccount, error) - - // AddSession inserts an Olm session into the store. - AddSession(context.Context, id.SenderKey, *OlmSession) error - // HasSession returns whether or not the store has an Olm session with the given sender key. - HasSession(context.Context, id.SenderKey) bool - // GetSessions returns all Olm sessions in the store with the given sender key. - GetSessions(context.Context, id.SenderKey) (OlmSessionList, error) - // GetLatestSession returns the most recent session that should be used for encrypting outbound messages. - // It's usually the one with the most recent successful decryption or the highest ID lexically. - GetLatestSession(context.Context, id.SenderKey) (*OlmSession, error) - // GetNewestSessionCreationTS returns the creation timestamp of the most recently created session for the given sender key. - GetNewestSessionCreationTS(context.Context, id.SenderKey) (time.Time, error) - // UpdateSession updates a session that has previously been inserted with AddSession. - UpdateSession(context.Context, id.SenderKey, *OlmSession) error - // DeleteSession deletes the given session that has been previously inserted with AddSession. - DeleteSession(context.Context, id.SenderKey, *OlmSession) error - - // PutOlmHash marks a given olm message hash as handled. - PutOlmHash(context.Context, [32]byte, time.Time) error - // GetOlmHash gets the time that a given olm hash was handled. - GetOlmHash(context.Context, [32]byte) (time.Time, error) - // DeleteOldOlmHashes deletes all olm hashes that were handled before the given time. - DeleteOldOlmHashes(context.Context, time.Time) error - - // PutGroupSession inserts an inbound Megolm session into the store. If an earlier withhold event has been inserted - // with PutWithheldGroupSession, this call should replace that. However, PutWithheldGroupSession must not replace - // sessions inserted with this call. - PutGroupSession(context.Context, *InboundGroupSession) error - // GetGroupSession gets an inbound Megolm session from the store. If the group session has been withheld - // (i.e. a room key withheld event has been saved with PutWithheldGroupSession), this should return the - // ErrGroupSessionWithheld error. The caller may use GetWithheldGroupSession to find more details. - GetGroupSession(context.Context, id.RoomID, id.SessionID) (*InboundGroupSession, error) - // RedactGroupSession removes the session data for the given inbound Megolm session from the store. - RedactGroupSession(context.Context, id.RoomID, id.SessionID, string) error - // RedactGroupSessions removes the session data for all inbound Megolm sessions from a specific device and/or in a specific room. - RedactGroupSessions(context.Context, id.RoomID, id.SenderKey, string) ([]id.SessionID, error) - // RedactExpiredGroupSessions removes the session data for all inbound Megolm sessions that have expired. - RedactExpiredGroupSessions(context.Context) ([]id.SessionID, error) - // RedactOutdatedGroupSessions removes the session data for all inbound Megolm sessions that are lacking the expiration metadata. - RedactOutdatedGroupSessions(context.Context) ([]id.SessionID, error) - // PutWithheldGroupSession tells the store that a specific Megolm session was withheld. - PutWithheldGroupSession(context.Context, event.RoomKeyWithheldEventContent) error - // GetWithheldGroupSession gets the event content that was previously inserted with PutWithheldGroupSession. - GetWithheldGroupSession(context.Context, id.RoomID, id.SessionID) (*event.RoomKeyWithheldEventContent, error) - - // GetGroupSessionsForRoom gets all the inbound Megolm sessions for a specific room. This is used for creating key - // export files. Unlike GetGroupSession, this should not return any errors about withheld keys. - GetGroupSessionsForRoom(context.Context, id.RoomID) dbutil.RowIter[*InboundGroupSession] - // GetAllGroupSessions gets all the inbound Megolm sessions in the store. This is used for creating key export - // files. Unlike GetGroupSession, this should not return any errors about withheld keys. - GetAllGroupSessions(context.Context) dbutil.RowIter[*InboundGroupSession] - // GetGroupSessionsWithoutKeyBackupVersion gets all the inbound Megolm sessions in the store that do not match given key backup version. - GetGroupSessionsWithoutKeyBackupVersion(context.Context, id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] - - // AddOutboundGroupSession inserts the given outbound Megolm session into the store. - // - // The store should index inserted sessions by the RoomID field to support getting and removing sessions. - // There will only be one outbound session per room ID at a time. - AddOutboundGroupSession(context.Context, *OutboundGroupSession) error - // UpdateOutboundGroupSession updates the given outbound Megolm session in the store. - UpdateOutboundGroupSession(context.Context, *OutboundGroupSession) error - // GetOutboundGroupSession gets the stored outbound Megolm session for the given room ID from the store. - GetOutboundGroupSession(context.Context, id.RoomID) (*OutboundGroupSession, error) - // RemoveOutboundGroupSession removes the stored outbound Megolm session for the given room ID. - RemoveOutboundGroupSession(context.Context, id.RoomID) error - // MarkOutboutGroupSessionShared flags that the currently known device has been shared the keys for the specified session. - MarkOutboundGroupSessionShared(context.Context, id.UserID, id.IdentityKey, id.SessionID) error - // IsOutboutGroupSessionShared checks if the specified session has been shared with the device. - IsOutboundGroupSessionShared(context.Context, id.UserID, id.IdentityKey, id.SessionID) (bool, error) - - // ValidateMessageIndex validates that the given message details aren't from a replay attack. - // - // Implementations should store a map from (sessionID, index) to (eventID, timestamp), then use that map - // to check whether or not the message index is valid: - // - // * If the map key doesn't exist, the given values should be stored and this should return true. - // * If the map key exists and the stored values match the given values, this should return true. - // * If the map key exists, but the stored values do not match the given values, this should return false. - ValidateMessageIndex(ctx context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) - - // GetDevices returns a map from device ID to id.Device struct containing all devices of a given user. - GetDevices(context.Context, id.UserID) (map[id.DeviceID]*id.Device, error) - // GetDevice returns a specific device of a given user. - GetDevice(context.Context, id.UserID, id.DeviceID) (*id.Device, error) - // PutDevice stores a single device for a user, replacing it if it exists already. - PutDevice(context.Context, id.UserID, *id.Device) error - // PutDevices overrides the stored device list for the given user with the given list. - PutDevices(context.Context, id.UserID, map[id.DeviceID]*id.Device) error - // FindDeviceByKey finds a specific device by its identity key. - FindDeviceByKey(context.Context, id.UserID, id.IdentityKey) (*id.Device, error) - // FilterTrackedUsers returns a filtered version of the given list that only includes user IDs whose device lists - // have been stored with PutDevices. A user is considered tracked even if the PutDevices list was empty. - FilterTrackedUsers(context.Context, []id.UserID) ([]id.UserID, error) - // MarkTrackedUsersOutdated flags that the device list for given users are outdated. - MarkTrackedUsersOutdated(context.Context, []id.UserID) error - // GetOutdatedTrackerUsers gets all tracked users whose devices need to be updated. - GetOutdatedTrackedUsers(context.Context) ([]id.UserID, error) - - // PutCrossSigningKey stores a cross-signing key of some user along with its usage. - PutCrossSigningKey(context.Context, id.UserID, id.CrossSigningUsage, id.Ed25519) error - // GetCrossSigningKeys retrieves a user's stored cross-signing keys. - GetCrossSigningKeys(context.Context, id.UserID) (map[id.CrossSigningUsage]id.CrossSigningKey, error) - // PutSignature stores a signature of a cross-signing or device key along with the signer's user ID and key. - PutSignature(ctx context.Context, signedUser id.UserID, signedKey id.Ed25519, signerUser id.UserID, signerKey id.Ed25519, signature string) error - // IsKeySignedBy returns whether a cross-signing or device key is signed by the given signer. - IsKeySignedBy(ctx context.Context, userID id.UserID, key id.Ed25519, signedByUser id.UserID, signedByKey id.Ed25519) (bool, error) - // DropSignaturesByKey deletes the signatures made by the given user and key from the store. It returns the number of signatures deleted. - DropSignaturesByKey(context.Context, id.UserID, id.Ed25519) (int64, error) - // GetSignaturesForKeyBy retrieves the stored signatures for a given cross-signing or device key, by the given signer. - GetSignaturesForKeyBy(context.Context, id.UserID, id.Ed25519, id.UserID) (map[id.Ed25519]string, error) - - // PutSecret stores a named secret, replacing it if it exists already. - PutSecret(context.Context, id.Secret, string) error - // GetSecret returns a named secret. - GetSecret(context.Context, id.Secret) (string, error) - // DeleteSecret removes a named secret. - DeleteSecret(context.Context, id.Secret) error -} - -type messageIndexKey struct { - SessionID id.SessionID - Index uint -} - -type messageIndexValue struct { - EventID id.EventID - Timestamp int64 -} - -// MemoryStore is a simple in-memory Store implementation. It can optionally have a callback function for saving data, -// but the actual storage must be implemented manually. -type MemoryStore struct { - lock sync.RWMutex - - save func() error - - Account *OlmAccount - Sessions map[id.SenderKey]OlmSessionList - GroupSessions map[id.RoomID]map[id.SessionID]*InboundGroupSession - WithheldGroupSessions map[id.RoomID]map[id.SessionID]*event.RoomKeyWithheldEventContent - OutGroupSessions map[id.RoomID]*OutboundGroupSession - SharedGroupSessions map[id.UserID]map[id.IdentityKey]map[id.SessionID]struct{} - MessageIndices map[messageIndexKey]messageIndexValue - Devices map[id.UserID]map[id.DeviceID]*id.Device - CrossSigningKeys map[id.UserID]map[id.CrossSigningUsage]id.CrossSigningKey - KeySignatures map[id.UserID]map[id.Ed25519]map[id.UserID]map[id.Ed25519]string - OutdatedUsers map[id.UserID]struct{} - Secrets map[id.Secret]string - OlmHashes *exsync.Set[[32]byte] -} - -var _ Store = (*MemoryStore)(nil) - -func NewMemoryStore(saveCallback func() error) *MemoryStore { - if saveCallback == nil { - saveCallback = func() error { return nil } - } - return &MemoryStore{ - save: saveCallback, - - Sessions: make(map[id.SenderKey]OlmSessionList), - GroupSessions: make(map[id.RoomID]map[id.SessionID]*InboundGroupSession), - WithheldGroupSessions: make(map[id.RoomID]map[id.SessionID]*event.RoomKeyWithheldEventContent), - OutGroupSessions: make(map[id.RoomID]*OutboundGroupSession), - SharedGroupSessions: make(map[id.UserID]map[id.IdentityKey]map[id.SessionID]struct{}), - MessageIndices: make(map[messageIndexKey]messageIndexValue), - Devices: make(map[id.UserID]map[id.DeviceID]*id.Device), - CrossSigningKeys: make(map[id.UserID]map[id.CrossSigningUsage]id.CrossSigningKey), - KeySignatures: make(map[id.UserID]map[id.Ed25519]map[id.UserID]map[id.Ed25519]string), - OutdatedUsers: make(map[id.UserID]struct{}), - Secrets: make(map[id.Secret]string), - OlmHashes: exsync.NewSet[[32]byte](), - } -} - -func (gs *MemoryStore) Flush(_ context.Context) error { - gs.lock.Lock() - defer gs.lock.Unlock() - return gs.save() -} - -func (gs *MemoryStore) GetAccount(_ context.Context) (*OlmAccount, error) { - return gs.Account, nil -} - -func (gs *MemoryStore) PutAccount(_ context.Context, account *OlmAccount) error { - gs.lock.Lock() - defer gs.lock.Unlock() - gs.Account = account - return gs.save() -} - -func (gs *MemoryStore) GetSessions(_ context.Context, senderKey id.SenderKey) (OlmSessionList, error) { - gs.lock.Lock() - defer gs.lock.Unlock() - sessions, ok := gs.Sessions[senderKey] - if !ok { - sessions = []*OlmSession{} - gs.Sessions[senderKey] = sessions - } - return sessions, nil -} - -func (gs *MemoryStore) AddSession(_ context.Context, senderKey id.SenderKey, session *OlmSession) error { - gs.lock.Lock() - defer gs.lock.Unlock() - sessions := gs.Sessions[senderKey] - gs.Sessions[senderKey] = append(sessions, session) - sort.Sort(gs.Sessions[senderKey]) - return gs.save() -} - -func (gs *MemoryStore) DeleteSession(ctx context.Context, senderKey id.SenderKey, target *OlmSession) error { - gs.lock.Lock() - defer gs.lock.Unlock() - sessions, ok := gs.Sessions[senderKey] - if !ok { - return nil - } - gs.Sessions[senderKey] = slices.DeleteFunc(sessions, func(session *OlmSession) bool { - return session == target - }) - return gs.save() -} - -func (gs *MemoryStore) UpdateSession(_ context.Context, _ id.SenderKey, _ *OlmSession) error { - // we don't need to do anything here because the session is a pointer and already stored in our map - return gs.save() -} - -func (gs *MemoryStore) HasSession(_ context.Context, senderKey id.SenderKey) bool { - gs.lock.RLock() - defer gs.lock.RUnlock() - sessions, ok := gs.Sessions[senderKey] - return ok && len(sessions) > 0 && !sessions[0].Expired() -} - -func (gs *MemoryStore) PutOlmHash(_ context.Context, hash [32]byte, receivedAt time.Time) error { - gs.OlmHashes.Add(hash) - return nil -} - -func (gs *MemoryStore) GetOlmHash(_ context.Context, hash [32]byte) (time.Time, error) { - if gs.OlmHashes.Has(hash) { - // The time isn't that important, so we just return the current time - return time.Now(), nil - } - return time.Time{}, nil -} - -func (gs *MemoryStore) DeleteOldOlmHashes(_ context.Context, beforeTS time.Time) error { - return nil -} - -func (gs *MemoryStore) GetLatestSession(_ context.Context, senderKey id.SenderKey) (*OlmSession, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - sessions, ok := gs.Sessions[senderKey] - if !ok || len(sessions) == 0 { - return nil, nil - } - return sessions[len(sessions)-1], nil -} - -func (gs *MemoryStore) GetNewestSessionCreationTS(ctx context.Context, senderKey id.SenderKey) (createdAt time.Time, err error) { - var sess *OlmSession - sess, err = gs.GetLatestSession(ctx, senderKey) - if sess != nil { - createdAt = sess.CreationTime - } - return -} - -func (gs *MemoryStore) getGroupSessions(roomID id.RoomID) map[id.SessionID]*InboundGroupSession { - room, ok := gs.GroupSessions[roomID] - if !ok { - room = make(map[id.SessionID]*InboundGroupSession) - gs.GroupSessions[roomID] = room - } - return room -} - -func (gs *MemoryStore) PutGroupSession(_ context.Context, igs *InboundGroupSession) error { - gs.lock.Lock() - defer gs.lock.Unlock() - gs.getGroupSessions(igs.RoomID)[igs.ID()] = igs - return gs.save() -} - -func (gs *MemoryStore) GetGroupSession(_ context.Context, roomID id.RoomID, sessionID id.SessionID) (*InboundGroupSession, error) { - gs.lock.Lock() - defer gs.lock.Unlock() - session, ok := gs.getGroupSessions(roomID)[sessionID] - if !ok { - withheld, ok := gs.getWithheldGroupSessions(roomID)[sessionID] - if ok { - return nil, fmt.Errorf("%w (%s)", ErrGroupSessionWithheld, withheld.Code) - } - return nil, nil - } - return session, nil -} - -func (gs *MemoryStore) RedactGroupSession(_ context.Context, roomID id.RoomID, sessionID id.SessionID, reason string) error { - gs.lock.Lock() - defer gs.lock.Unlock() - delete(gs.getGroupSessions(roomID), sessionID) - return gs.save() -} - -func (gs *MemoryStore) RedactGroupSessions(_ context.Context, roomID id.RoomID, senderKey id.SenderKey, reason string) ([]id.SessionID, error) { - gs.lock.Lock() - defer gs.lock.Unlock() - var sessionIDs []id.SessionID - if roomID != "" && senderKey != "" { - sessions := gs.getGroupSessions(roomID) - for sessionID, session := range sessions { - if session.SenderKey == senderKey { - sessionIDs = append(sessionIDs, sessionID) - delete(sessions, sessionID) - } - } - } else if senderKey != "" { - for _, room := range gs.GroupSessions { - for sessionID, session := range room { - if session.SenderKey == senderKey { - sessionIDs = append(sessionIDs, sessionID) - delete(room, sessionID) - } - } - } - } else if roomID != "" { - sessionIDs = maps.Keys(gs.GroupSessions[roomID]) - delete(gs.GroupSessions, roomID) - } else { - return nil, fmt.Errorf("room ID or sender key must be provided for redacting sessions") - } - return sessionIDs, gs.save() -} - -func (gs *MemoryStore) RedactExpiredGroupSessions(_ context.Context) ([]id.SessionID, error) { - return nil, fmt.Errorf("not implemented") -} - -func (gs *MemoryStore) RedactOutdatedGroupSessions(_ context.Context) ([]id.SessionID, error) { - return nil, fmt.Errorf("not implemented") -} - -func (gs *MemoryStore) getWithheldGroupSessions(roomID id.RoomID) map[id.SessionID]*event.RoomKeyWithheldEventContent { - room, ok := gs.WithheldGroupSessions[roomID] - if !ok { - room = make(map[id.SessionID]*event.RoomKeyWithheldEventContent) - gs.WithheldGroupSessions[roomID] = room - } - return room -} - -func (gs *MemoryStore) PutWithheldGroupSession(_ context.Context, content event.RoomKeyWithheldEventContent) error { - gs.lock.Lock() - defer gs.lock.Unlock() - gs.getWithheldGroupSessions(content.RoomID)[content.SessionID] = &content - return gs.save() -} - -func (gs *MemoryStore) GetWithheldGroupSession(_ context.Context, roomID id.RoomID, sessionID id.SessionID) (*event.RoomKeyWithheldEventContent, error) { - gs.lock.Lock() - defer gs.lock.Unlock() - session, ok := gs.getWithheldGroupSessions(roomID)[sessionID] - if !ok { - return nil, nil - } - return session, nil -} - -func (gs *MemoryStore) GetGroupSessionsForRoom(_ context.Context, roomID id.RoomID) dbutil.RowIter[*InboundGroupSession] { - gs.lock.Lock() - defer gs.lock.Unlock() - room, ok := gs.GroupSessions[roomID] - if !ok { - return nil - } - return dbutil.NewSliceIter(maps.Values(room)) -} - -func (gs *MemoryStore) GetAllGroupSessions(_ context.Context) dbutil.RowIter[*InboundGroupSession] { - gs.lock.Lock() - defer gs.lock.Unlock() - var result []*InboundGroupSession - for _, room := range gs.GroupSessions { - result = append(result, maps.Values(room)...) - } - return dbutil.NewSliceIter(result) -} - -func (gs *MemoryStore) GetGroupSessionsWithoutKeyBackupVersion(_ context.Context, version id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] { - gs.lock.Lock() - defer gs.lock.Unlock() - var result []*InboundGroupSession - for _, room := range gs.GroupSessions { - for _, session := range room { - if session.KeyBackupVersion != version { - result = append(result, session) - } - } - } - return dbutil.NewSliceIter(result) -} - -func (gs *MemoryStore) AddOutboundGroupSession(_ context.Context, session *OutboundGroupSession) error { - gs.lock.Lock() - defer gs.lock.Unlock() - gs.OutGroupSessions[session.RoomID] = session - return gs.save() -} - -func (gs *MemoryStore) UpdateOutboundGroupSession(_ context.Context, _ *OutboundGroupSession) error { - // we don't need to do anything here because the session is a pointer and already stored in our map - return gs.save() -} - -func (gs *MemoryStore) GetOutboundGroupSession(_ context.Context, roomID id.RoomID) (*OutboundGroupSession, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - session, ok := gs.OutGroupSessions[roomID] - if !ok { - return nil, nil - } - return session, nil -} - -func (gs *MemoryStore) RemoveOutboundGroupSession(_ context.Context, roomID id.RoomID) error { - gs.lock.Lock() - defer gs.lock.Unlock() - session, ok := gs.OutGroupSessions[roomID] - if !ok || session == nil { - return nil - } - delete(gs.OutGroupSessions, roomID) - return nil -} - -func (gs *MemoryStore) MarkOutboundGroupSessionShared(_ context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) error { - gs.lock.Lock() - defer gs.lock.Unlock() - - if _, ok := gs.SharedGroupSessions[userID]; !ok { - gs.SharedGroupSessions[userID] = make(map[id.IdentityKey]map[id.SessionID]struct{}) - } - identities := gs.SharedGroupSessions[userID] - - if _, ok := identities[identityKey]; !ok { - identities[identityKey] = make(map[id.SessionID]struct{}) - } - - identities[identityKey][sessionID] = struct{}{} - - return nil -} - -func (gs *MemoryStore) IsOutboundGroupSessionShared(_ context.Context, userID id.UserID, identityKey id.IdentityKey, sessionID id.SessionID) (isShared bool, err error) { - gs.lock.Lock() - defer gs.lock.Unlock() - - if _, ok := gs.SharedGroupSessions[userID]; !ok { - return - } - identities := gs.SharedGroupSessions[userID] - - if _, ok := identities[identityKey]; !ok { - return - } - - _, isShared = identities[identityKey][sessionID] - return -} - -func (gs *MemoryStore) ValidateMessageIndex(_ context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { - gs.lock.Lock() - defer gs.lock.Unlock() - key := messageIndexKey{ - SessionID: sessionID, - Index: index, - } - val, ok := gs.MessageIndices[key] - if !ok { - if eventID == "" && timestamp == 0 { - return true, nil - } - gs.MessageIndices[key] = messageIndexValue{ - EventID: eventID, - Timestamp: timestamp, - } - _ = gs.save() - return true, nil - } - if val.EventID != eventID || val.Timestamp != timestamp { - return false, nil - } - return true, nil -} - -func (gs *MemoryStore) GetDevices(_ context.Context, userID id.UserID) (map[id.DeviceID]*id.Device, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - devices, ok := gs.Devices[userID] - if !ok { - devices = nil - } - return devices, nil -} - -func (gs *MemoryStore) GetDevice(_ context.Context, userID id.UserID, deviceID id.DeviceID) (*id.Device, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - devices, ok := gs.Devices[userID] - if !ok { - return nil, nil - } - device, ok := devices[deviceID] - if !ok { - return nil, nil - } - return device, nil -} - -func (gs *MemoryStore) FindDeviceByKey(_ context.Context, userID id.UserID, identityKey id.IdentityKey) (*id.Device, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - devices, ok := gs.Devices[userID] - if !ok { - return nil, nil - } - for _, device := range devices { - if device.IdentityKey == identityKey { - return device, nil - } - } - return nil, nil -} - -func (gs *MemoryStore) PutDevice(_ context.Context, userID id.UserID, device *id.Device) error { - gs.lock.Lock() - defer gs.lock.Unlock() - devices, ok := gs.Devices[userID] - if !ok { - devices = make(map[id.DeviceID]*id.Device) - gs.Devices[userID] = devices - } - devices[device.DeviceID] = device - return gs.save() -} - -func (gs *MemoryStore) PutDevices(_ context.Context, userID id.UserID, devices map[id.DeviceID]*id.Device) error { - gs.lock.Lock() - defer gs.lock.Unlock() - gs.Devices[userID] = devices - err := gs.save() - if err == nil { - delete(gs.OutdatedUsers, userID) - } - return err -} - -func (gs *MemoryStore) FilterTrackedUsers(_ context.Context, users []id.UserID) ([]id.UserID, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - var ptr int - for _, userID := range users { - _, ok := gs.Devices[userID] - if ok { - users[ptr] = userID - ptr++ - } - } - return users[:ptr], nil -} - -func (gs *MemoryStore) MarkTrackedUsersOutdated(_ context.Context, users []id.UserID) error { - gs.lock.Lock() - defer gs.lock.Unlock() - for _, userID := range users { - if _, ok := gs.Devices[userID]; ok { - gs.OutdatedUsers[userID] = struct{}{} - } - } - return nil -} - -func (gs *MemoryStore) GetOutdatedTrackedUsers(_ context.Context) ([]id.UserID, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - users := make([]id.UserID, 0, len(gs.OutdatedUsers)) - for userID := range gs.OutdatedUsers { - users = append(users, userID) - } - return users, nil -} - -func (gs *MemoryStore) PutCrossSigningKey(_ context.Context, userID id.UserID, usage id.CrossSigningUsage, key id.Ed25519) error { - gs.lock.RLock() - defer gs.lock.RUnlock() - userKeys, ok := gs.CrossSigningKeys[userID] - if !ok { - userKeys = make(map[id.CrossSigningUsage]id.CrossSigningKey) - gs.CrossSigningKeys[userID] = userKeys - } - existing, ok := userKeys[usage] - if ok { - existing.Key = key - userKeys[usage] = existing - } else { - userKeys[usage] = id.CrossSigningKey{ - Key: key, - First: key, - } - } - err := gs.save() - return err -} - -func (gs *MemoryStore) GetCrossSigningKeys(_ context.Context, userID id.UserID) (map[id.CrossSigningUsage]id.CrossSigningKey, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - keys, ok := gs.CrossSigningKeys[userID] - if !ok { - return map[id.CrossSigningUsage]id.CrossSigningKey{}, nil - } - return keys, nil -} - -func (gs *MemoryStore) PutSignature(_ context.Context, signedUserID id.UserID, signedKey id.Ed25519, signerUserID id.UserID, signerKey id.Ed25519, signature string) error { - gs.lock.RLock() - defer gs.lock.RUnlock() - signedUserSigs, ok := gs.KeySignatures[signedUserID] - if !ok { - signedUserSigs = make(map[id.Ed25519]map[id.UserID]map[id.Ed25519]string) - gs.KeySignatures[signedUserID] = signedUserSigs - } - signaturesForKey, ok := signedUserSigs[signedKey] - if !ok { - signaturesForKey = make(map[id.UserID]map[id.Ed25519]string) - signedUserSigs[signedKey] = signaturesForKey - } - signedByUser, ok := signaturesForKey[signerUserID] - if !ok { - signedByUser = make(map[id.Ed25519]string) - signaturesForKey[signerUserID] = signedByUser - } - signedByUser[signerKey] = signature - return gs.save() -} - -func (gs *MemoryStore) GetSignaturesForKeyBy(_ context.Context, userID id.UserID, key id.Ed25519, signerID id.UserID) (map[id.Ed25519]string, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - userKeys, ok := gs.KeySignatures[userID] - if !ok { - return map[id.Ed25519]string{}, nil - } - sigsForKey, ok := userKeys[key] - if !ok { - return map[id.Ed25519]string{}, nil - } - sigsBySigner, ok := sigsForKey[signerID] - if !ok { - return map[id.Ed25519]string{}, nil - } - return sigsBySigner, nil -} - -func (gs *MemoryStore) IsKeySignedBy(ctx context.Context, userID id.UserID, key id.Ed25519, signerID id.UserID, signerKey id.Ed25519) (bool, error) { - sigs, err := gs.GetSignaturesForKeyBy(ctx, userID, key, signerID) - if err != nil { - return false, err - } - _, ok := sigs[signerKey] - return ok, nil -} - -func (gs *MemoryStore) DropSignaturesByKey(_ context.Context, userID id.UserID, key id.Ed25519) (int64, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - var count int64 - for _, userSigs := range gs.KeySignatures { - for _, keySigs := range userSigs { - if signedBySigner, ok := keySigs[userID]; ok { - if _, ok := signedBySigner[key]; ok { - count++ - delete(signedBySigner, key) - } - } - } - } - return count, nil -} - -func (gs *MemoryStore) PutSecret(_ context.Context, name id.Secret, value string) error { - gs.lock.Lock() - defer gs.lock.Unlock() - gs.Secrets[name] = value - return nil -} - -func (gs *MemoryStore) GetSecret(_ context.Context, name id.Secret) (string, error) { - gs.lock.RLock() - defer gs.lock.RUnlock() - return gs.Secrets[name], nil -} - -func (gs *MemoryStore) DeleteSecret(_ context.Context, name id.Secret) error { - gs.lock.Lock() - defer gs.lock.Unlock() - delete(gs.Secrets, name) - return nil -} diff --git a/mautrix-patched/crypto/store_test.go b/mautrix-patched/crypto/store_test.go deleted file mode 100644 index 8c237c57..00000000 --- a/mautrix-patched/crypto/store_test.go +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package crypto - -import ( - "context" - "database/sql" - "strconv" - "testing" - - _ "github.com/mattn/go-sqlite3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/crypto/olm" - "maunium.net/go/mautrix/id" -) - -const olmSessID = "sJlikQQKXp7UQjmS9/lyZCNUVJ2AmKyHbufPBaC7tpk" -const olmPickled = "L6cdv3JYO9OzhXbcjNSwl7ldN5bDvwmGyin+hISePETE6bO71DIlhqTC9YIhg21RDqRPH2HNl1MCyCw0hEXICWQyeJ9S7JLie" + - "5PYxhqSSaTYaybvlvw34jvuSgEx0iotM6WNuWu5ocrsOo5Ye/3Nz7lBvxaw2rpS0jZnn7eV1n9GbINZk4YEVWrHOn7OxYfaGECJHDeAk/ameStiy" + - "o1Gru0a/cmR0O3oKMyYnlXir0jS7oETMCsWk59GeVlz++j4aK0FK4g8/3fCMmLDXSatFjE9hoWDmeRwal58Y+XwX76Te/PiWtrFrinvCDEQJcZTa" + - "qcCwp6sZrgLbmfBUBb0zJCogCmYw8m2" -const groupSession = "9ZbsRqJuETbjnxPpKv29n3dubP/m5PSLbr9I9CIWS2O86F/Og1JZXhqT+4fA5tovoPfdpk5QLh7PfDyjmgOcO9sSA37maJyzCy6Ap+uBZLAXp6VLJ0mjSvxi+PAbzGKDMqpn+pa+oeEIH6SFPG/2GGDSRoXVi5fttAClCIoav5RflWiMypKqnQRfkZR2Gx8glOaBiTzAd7m0X6XGfYIPol41JUIHfBLuJBfXQ0Uu5GScV4eKUWdJP2J6zzC2Hx8cZAhiBBzAza0CbGcnUK+YJXMYaJg92HiIo++l317LlsYUJ/P+gKOLafYR9/l8bAzxH7j5s31PnRs7mD1Bl6G1LFM+dPsGXUOLx6PlvlTlYYM/opai0uKKzT0Wk6zPoq9fN/smlXEPBtKlw2fqcytL4gOF0MrBPEca" - -func getCryptoStores(t *testing.T) map[string]Store { - rawDB, err := sql.Open("sqlite3", ":memory:?_busy_timeout=5000") - require.NoError(t, err, "Error opening raw database") - db, err := dbutil.NewWithDB(rawDB, "sqlite3") - require.NoError(t, err, "Error creating database wrapper") - sqlStore := NewSQLCryptoStore(db, nil, "accid", id.DeviceID("dev"), []byte("test")) - err = sqlStore.DB.Upgrade(context.TODO()) - require.NoError(t, err, "Error upgrading database") - - gobStore := NewMemoryStore(nil) - - return map[string]Store{ - "sql": sqlStore, - "gob": gobStore, - } -} - -func TestPutNextBatch(t *testing.T) { - stores := getCryptoStores(t) - store := stores["sql"].(*SQLCryptoStore) - store.PutNextBatch(context.Background(), "batch1") - - batch, err := store.GetNextBatch(context.Background()) - require.NoError(t, err, "Error retrieving next batch") - assert.Equal(t, "batch1", batch) -} - -func TestPutAccount(t *testing.T) { - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - acc := NewOlmAccount() - store.PutAccount(context.TODO(), acc) - retrieved, err := store.GetAccount(context.TODO()) - require.NoError(t, err, "Error retrieving account") - assert.Equal(t, acc.IdentityKey(), retrieved.IdentityKey(), "Identity key does not match") - assert.Equal(t, acc.SigningKey(), retrieved.SigningKey(), "Signing key does not match") - }) - } -} - -func TestValidateMessageIndex(t *testing.T) { - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - // Validating without event ID and timestamp before we have them should work - ok, err := store.ValidateMessageIndex(context.TODO(), "sess1", "", 0, 0) - require.NoError(t, err, "Error validating message index") - assert.True(t, ok, "First message validation should be valid") - - // First message should validate successfully - ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event1", 0, 1000) - require.NoError(t, err, "Error validating message index") - assert.True(t, ok, "First message validation should be valid") - - // Edit the timestamp and ensure validate fails - ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event1", 0, 1001) - require.NoError(t, err, "Error validating message index after timestamp change") - assert.False(t, ok, "First message validation should fail after timestamp change") - - // Edit the event ID and ensure validate fails - ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event2", 0, 1000) - require.NoError(t, err, "Error validating message index after event ID change") - assert.False(t, ok, "First message validation should fail after event ID change") - - // Validate again with the original parameters and ensure that it still passes - ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "event1", 0, 1000) - require.NoError(t, err, "Error validating message index") - assert.True(t, ok, "First message validation should be valid") - - // Validating without event ID and timestamp must fail if we already know them - ok, err = store.ValidateMessageIndex(context.TODO(), "sess1", "", 0, 0) - require.NoError(t, err, "Error validating message index") - assert.False(t, ok, "First message validation should be invalid") - }) - } -} - -func TestStoreOlmSession(t *testing.T) { - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - require.False(t, store.HasSession(context.TODO(), olmSessID), "Found Olm session before inserting it") - - olmInternal, err := olm.SessionFromPickled([]byte(olmPickled), []byte("test")) - require.NoError(t, err, "Error creating internal Olm session") - - olmSess := OlmSession{ - id: olmSessID, - Internal: olmInternal, - } - err = store.AddSession(context.TODO(), olmSessID, &olmSess) - require.NoError(t, err, "Error storing Olm session") - assert.True(t, store.HasSession(context.TODO(), olmSessID), "Olm session not found after inserting it") - - retrieved, err := store.GetLatestSession(context.TODO(), olmSessID) - require.NoError(t, err, "Error retrieving Olm session") - assert.EqualValues(t, olmSessID, retrieved.ID()) - - pickled, err := retrieved.Internal.Pickle([]byte("test")) - require.NoError(t, err, "Error pickling Olm session") - assert.EqualValues(t, pickled, olmPickled, "Pickled Olm session does not match original") - }) - } -} - -func TestStoreMegolmSession(t *testing.T) { - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - acc := NewOlmAccount() - - internal, err := olm.InboundGroupSessionFromPickled([]byte(groupSession), []byte("test")) - require.NoError(t, err, "Error creating internal inbound group session") - - igs := &InboundGroupSession{ - Internal: internal, - SigningKey: acc.SigningKey(), - SenderKey: acc.IdentityKey(), - RoomID: "room1", - } - - err = store.PutGroupSession(context.TODO(), igs) - require.NoError(t, err, "Error storing inbound group session") - - retrieved, err := store.GetGroupSession(context.TODO(), "room1", igs.ID()) - require.NoError(t, err, "Error retrieving inbound group session") - - pickled, err := retrieved.Internal.Pickle([]byte("test")) - require.NoError(t, err, "Error pickling inbound group session") - assert.EqualValues(t, pickled, groupSession, "Pickled inbound group session does not match original") - }) - } -} - -func TestStoreOutboundMegolmSession(t *testing.T) { - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - sess, err := store.GetOutboundGroupSession(context.TODO(), "room1") - require.NoError(t, err, "Error retrieving outbound session") - require.Nil(t, sess, "Got outbound session before inserting") - - outbound, err := NewOutboundGroupSession("room1", nil) - require.NoError(t, err) - err = store.AddOutboundGroupSession(context.TODO(), outbound) - require.NoError(t, err, "Error inserting outbound session") - - sess, err = store.GetOutboundGroupSession(context.TODO(), "room1") - require.NoError(t, err, "Error retrieving outbound session") - assert.NotNil(t, sess, "Did not get outbound session after inserting") - - err = store.RemoveOutboundGroupSession(context.TODO(), "room1") - require.NoError(t, err, "Error deleting outbound session") - - sess, err = store.GetOutboundGroupSession(context.TODO(), "room1") - require.NoError(t, err, "Error retrieving outbound session after deletion") - assert.Nil(t, sess, "Got outbound session after deleting") - }) - } -} - -func TestStoreOutboundMegolmSessionSharing(t *testing.T) { - stores := getCryptoStores(t) - - resetDevice := func() *id.Device { - acc := NewOlmAccount() - return &id.Device{ - UserID: "user1", - DeviceID: id.DeviceID("dev1"), - IdentityKey: acc.IdentityKey(), - SigningKey: acc.SigningKey(), - } - } - - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - device := resetDevice() - err := store.PutDevice(context.TODO(), "user1", device) - require.NoError(t, err, "Error storing device") - - shared, err := store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - require.NoError(t, err, "Error checking if outbound group session is shared") - assert.False(t, shared, "Outbound group session should not be shared initially") - - err = store.MarkOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - require.NoError(t, err, "Error marking outbound group session as shared") - - shared, err = store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - require.NoError(t, err, "Error checking if outbound group session is shared") - assert.True(t, shared, "Outbound group session should be shared after marking it as such") - - device = resetDevice() - err = store.PutDevice(context.TODO(), "user1", device) - require.NoError(t, err, "Error storing device after resetting") - - shared, err = store.IsOutboundGroupSessionShared(context.TODO(), device.UserID, device.IdentityKey, "session1") - require.NoError(t, err, "Error checking if outbound group session is shared") - assert.False(t, shared, "Outbound group session should not be shared after resetting device") - }) - } -} - -func TestStoreDevices(t *testing.T) { - devicesToCreate := 17 - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - outdated, err := store.GetOutdatedTrackedUsers(context.TODO()) - require.NoError(t, err, "Error filtering tracked users") - assert.Empty(t, outdated, "Expected no outdated tracked users initially") - - deviceMap := make(map[id.DeviceID]*id.Device) - for i := 0; i < devicesToCreate; i++ { - iStr := strconv.Itoa(i) - acc := NewOlmAccount() - deviceMap[id.DeviceID("dev"+iStr)] = &id.Device{ - UserID: "user1", - DeviceID: id.DeviceID("dev" + iStr), - IdentityKey: acc.IdentityKey(), - SigningKey: acc.SigningKey(), - } - } - err = store.PutDevices(context.TODO(), "user1", deviceMap) - require.NoError(t, err, "Error storing devices") - devs, err := store.GetDevices(context.TODO(), "user1") - require.NoError(t, err, "Error getting devices") - assert.Len(t, devs, devicesToCreate, "Expected to get %d devices back", devicesToCreate) - assert.Equal(t, deviceMap, devs, "Stored devices do not match retrieved devices") - - filtered, err := store.FilterTrackedUsers(context.TODO(), []id.UserID{"user0", "user1", "user2"}) - require.NoError(t, err, "Error filtering tracked users") - assert.Equal(t, []id.UserID{"user1"}, filtered, "Expected to get 'user1' from filter") - - outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) - require.NoError(t, err, "Error filtering tracked users") - assert.Empty(t, outdated, "Expected no outdated tracked users after initial storage") - - err = store.MarkTrackedUsersOutdated(context.TODO(), []id.UserID{"user0", "user1"}) - require.NoError(t, err, "Error marking tracked users outdated") - - outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) - require.NoError(t, err, "Error filtering tracked users") - assert.Equal(t, []id.UserID{"user1"}, outdated, "Expected 'user1' to be marked as outdated") - - err = store.PutDevices(context.TODO(), "user1", deviceMap) - require.NoError(t, err, "Error storing devices again") - - outdated, err = store.GetOutdatedTrackedUsers(context.TODO()) - require.NoError(t, err, "Error filtering tracked users") - assert.Empty(t, outdated, "Expected no outdated tracked users after re-storing devices") - }) - } -} - -func TestStoreSecrets(t *testing.T) { - stores := getCryptoStores(t) - for storeName, store := range stores { - t.Run(storeName, func(t *testing.T) { - storedSecret := "trustno1" - err := store.PutSecret(context.TODO(), id.SecretMegolmBackupV1, storedSecret) - require.NoError(t, err, "Error storing secret") - - secret, err := store.GetSecret(context.TODO(), id.SecretMegolmBackupV1) - require.NoError(t, err, "Error retrieving secret") - assert.Equal(t, storedSecret, secret, "Retrieved secret does not match stored secret") - }) - } -} diff --git a/mautrix-patched/crypto/utils/utils.go b/mautrix-patched/crypto/utils/utils.go deleted file mode 100644 index e2f8a19c..00000000 --- a/mautrix-patched/crypto/utils/utils.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package utils - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "crypto/sha512" - "encoding/base64" - "strings" - - "go.mau.fi/util/base58" - "golang.org/x/crypto/hkdf" - "golang.org/x/crypto/pbkdf2" -) - -const ( - // AESCTRKeyLength is the length of the AES256-CTR key used. - AESCTRKeyLength = 32 - // AESCTRIVLength is the length of the AES256-CTR IV used. - AESCTRIVLength = 16 - // HMACKeyLength is the length of the HMAC key used. - HMACKeyLength = 32 - // SHAHashLength is the length of the SHA hash used. - SHAHashLength = 32 -) - -// XorA256CTR encrypts the input with the keystream generated by the AES256-CTR algorithm with the given arguments. -func XorA256CTR(source []byte, key [AESCTRKeyLength]byte, iv [AESCTRIVLength]byte) []byte { - block, _ := aes.NewCipher(key[:]) - cipher.NewCTR(block, iv[:]).XORKeyStream(source, source) - return source -} - -// GenAttachmentA256CTR generates a new random AES256-CTR key and IV suitable for encrypting attachments. -func GenAttachmentA256CTR() (key [AESCTRKeyLength]byte, iv [AESCTRIVLength]byte) { - _, err := rand.Read(key[:]) - if err != nil { - panic(err) - } - - // The last 8 bytes of the IV act as the counter in AES-CTR, which means they're left empty here - _, err = rand.Read(iv[:8]) - if err != nil { - panic(err) - } - return -} - -// GenA256CTRIV generates a random IV for AES256-CTR with the last bit set to zero. -func GenA256CTRIV() (iv [AESCTRIVLength]byte) { - _, err := rand.Read(iv[:]) - if err != nil { - panic(err) - } - iv[8] &= 0x7F - return -} - -// DeriveKeysSHA256 derives an AES and a HMAC key from the given recovery key. -func DeriveKeysSHA256(key []byte, name string) ([AESCTRKeyLength]byte, [HMACKeyLength]byte) { - var zeroBytes [32]byte - - derivedHkdf := hkdf.New(sha256.New, key[:], zeroBytes[:], []byte(name)) - - var aesKey [AESCTRKeyLength]byte - var hmacKey [HMACKeyLength]byte - derivedHkdf.Read(aesKey[:]) - derivedHkdf.Read(hmacKey[:]) - - return aesKey, hmacKey -} - -// PBKDF2SHA512 generates a key of the given bit-length using the given passphrase, salt and iteration count. -func PBKDF2SHA512(password []byte, salt []byte, iters int, keyLenBits int) []byte { - return pbkdf2.Key(password, salt, iters, keyLenBits/8, sha512.New) -} - -// DecodeBase58RecoveryKey recovers the secret storage from a recovery key. -func DecodeBase58RecoveryKey(recoveryKey string) []byte { - noSpaces := strings.ReplaceAll(recoveryKey, " ", "") - decoded := base58.Decode(noSpaces) - if len(decoded) != AESCTRKeyLength+3 { // AESCTRKeyLength bytes key and 3 bytes prefix / parity - return nil - } - var parity byte - for _, b := range decoded[:34] { - parity ^= b - } - if parity != decoded[34] || decoded[0] != 0x8B || decoded[1] != 1 { - return nil - } - return decoded[2:34] -} - -// EncodeBase58RecoveryKey recovers the secret storage from a recovery key. -func EncodeBase58RecoveryKey(key []byte) string { - var inputBytes [35]byte - copy(inputBytes[2:34], key[:]) - inputBytes[0] = 0x8B - inputBytes[1] = 1 - - var parity byte - for _, b := range inputBytes[:34] { - parity ^= b - } - inputBytes[34] = parity - recoveryKey := base58.Encode(inputBytes[:]) - - var spacedKey string - for i, c := range recoveryKey { - if i > 0 && i%4 == 0 { - spacedKey += " " - } - spacedKey += string(c) - } - return spacedKey -} - -// HMACSHA256B64 calculates the unpadded base64 of the SHA256 hmac of the input with the given key. -func HMACSHA256B64(input []byte, hmacKey [HMACKeyLength]byte) string { - h := hmac.New(sha256.New, hmacKey[:]) - h.Write(input) - return base64.RawStdEncoding.EncodeToString(h.Sum(nil)) -} diff --git a/mautrix-patched/crypto/utils/utils_test.go b/mautrix-patched/crypto/utils/utils_test.go deleted file mode 100644 index b12fd9e2..00000000 --- a/mautrix-patched/crypto/utils/utils_test.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package utils - -import ( - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAES256Ctr(t *testing.T) { - expected := "Hello world" - key, iv := GenAttachmentA256CTR() - enc := XorA256CTR([]byte(expected), key, iv) - dec := XorA256CTR(enc, key, iv) - assert.EqualValues(t, expected, dec, "Decrypted text should match original") - - var key2 [AESCTRKeyLength]byte - var iv2 [AESCTRIVLength]byte - for i := 0; i < AESCTRKeyLength; i++ { - key2[i] = byte(i) - } - for i := 0; i < AESCTRIVLength; i++ { - iv2[i] = byte(i) + 32 - } - dec2 := XorA256CTR([]byte{0x29, 0xc3, 0xff, 0x02, 0x21, 0xaf, 0x67, 0x73, 0x6e, 0xad, 0x9d}, key2, iv2) - assert.EqualValues(t, expected, dec2, "Decrypted text with constant key/iv should match original") -} - -func TestPBKDF(t *testing.T) { - salt := make([]byte, 16) - for i := 0; i < 16; i++ { - salt[i] = byte(i) - } - key := PBKDF2SHA512([]byte("Hello world"), salt, 1000, 256) - expected := "ffk9YdbVE1cgqOWgDaec0lH+rJzO+MuCcxpIn3Z6D0E=" - keyB64 := base64.StdEncoding.EncodeToString([]byte(key)) - assert.Equal(t, expected, keyB64) -} - -func TestDecodeSSSSKey(t *testing.T) { - recoveryKey := "EsTL 2cTx 9Qy1 8TVd qGsn GDrD i5dT EEuX Qz8U P7hi Z7uu U8wZ" - decoded := DecodeBase58RecoveryKey(recoveryKey) - - expected := "QCFDrXZYLEFnwf4NikVm62rYGJS2mNBEmAWLC3CgNPw=" - decodedB64 := base64.StdEncoding.EncodeToString(decoded[:]) - assert.Equal(t, expected, decodedB64) - - encoded := EncodeBase58RecoveryKey(decoded) - assert.Equal(t, recoveryKey, encoded) -} - -func TestKeyDerivationAndHMAC(t *testing.T) { - recoveryKey := "EsUG Ddi6 e1Cm F4um g38u JN72 d37v Q2ry qCf2 rKgL E2MQ ZQz6" - decoded := DecodeBase58RecoveryKey(recoveryKey) - - aesKey, hmacKey := DeriveKeysSHA256(decoded[:], "m.cross_signing.master") - - ciphertextBytes, err := base64.StdEncoding.DecodeString("Fx16KlJ9vkd3Dd6CafIq5spaH5QmK5BALMzbtFbQznG2j1VARKK+klc4/Qo=") - require.NoError(t, err) - - calcMac := HMACSHA256B64(ciphertextBytes, hmacKey) - expectedMac := "0DABPNIZsP9iTOh1o6EM0s7BfHHXb96dN7Eca88jq2E" - assert.Equal(t, expectedMac, calcMac) - - var ivBytes [AESCTRIVLength]byte - decodedIV, _ := base64.StdEncoding.DecodeString("zxT/W5LpZ0Q819pfju6hZw==") - copy(ivBytes[:], decodedIV) - decrypted := string(XorA256CTR(ciphertextBytes, aesKey, ivBytes)) - - expectedDec := "Ec8eZDyvVkO3EDsEG6ej5c0cCHnX7PINqFXZjnaTV2s=" - assert.Equal(t, expectedDec, decrypted) -} diff --git a/mautrix-patched/crypto/verificationhelper/callbacks_test.go b/mautrix-patched/crypto/verificationhelper/callbacks_test.go deleted file mode 100644 index 3b943f28..00000000 --- a/mautrix-patched/crypto/verificationhelper/callbacks_test.go +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper_test - -import ( - "context" - - "maunium.net/go/mautrix/crypto/verificationhelper" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type MockVerificationCallbacks interface { - GetRequestedVerifications() map[id.UserID][]id.VerificationTransactionID - GetScanQRCodeTransactions() []id.VerificationTransactionID - GetVerificationsReadyTransactions() []id.VerificationTransactionID - GetQRCodeShown(id.VerificationTransactionID) *verificationhelper.QRCode -} - -type baseVerificationCallbacks struct { - scanQRCodeTransactions []id.VerificationTransactionID - verificationsRequested map[id.UserID][]id.VerificationTransactionID - verificationsReady []id.VerificationTransactionID - qrCodesShown map[id.VerificationTransactionID]*verificationhelper.QRCode - qrCodesScanned map[id.VerificationTransactionID]struct{} - doneTransactions map[id.VerificationTransactionID]struct{} - verificationCancellation map[id.VerificationTransactionID]*event.VerificationCancelEventContent - emojisShown map[id.VerificationTransactionID][]rune - emojiDescriptionsShown map[id.VerificationTransactionID][]string - decimalsShown map[id.VerificationTransactionID][]int -} - -var _ verificationhelper.RequiredCallbacks = (*baseVerificationCallbacks)(nil) -var _ MockVerificationCallbacks = (*baseVerificationCallbacks)(nil) - -func newBaseVerificationCallbacks() *baseVerificationCallbacks { - return &baseVerificationCallbacks{ - verificationsRequested: map[id.UserID][]id.VerificationTransactionID{}, - qrCodesShown: map[id.VerificationTransactionID]*verificationhelper.QRCode{}, - qrCodesScanned: map[id.VerificationTransactionID]struct{}{}, - doneTransactions: map[id.VerificationTransactionID]struct{}{}, - verificationCancellation: map[id.VerificationTransactionID]*event.VerificationCancelEventContent{}, - emojisShown: map[id.VerificationTransactionID][]rune{}, - emojiDescriptionsShown: map[id.VerificationTransactionID][]string{}, - decimalsShown: map[id.VerificationTransactionID][]int{}, - } -} - -func (c *baseVerificationCallbacks) GetRequestedVerifications() map[id.UserID][]id.VerificationTransactionID { - return c.verificationsRequested -} - -func (c *baseVerificationCallbacks) GetScanQRCodeTransactions() []id.VerificationTransactionID { - return c.scanQRCodeTransactions -} - -func (c *baseVerificationCallbacks) GetVerificationsReadyTransactions() []id.VerificationTransactionID { - return c.verificationsReady -} - -func (c *baseVerificationCallbacks) GetQRCodeShown(txnID id.VerificationTransactionID) *verificationhelper.QRCode { - return c.qrCodesShown[txnID] -} - -func (c *baseVerificationCallbacks) WasOurQRCodeScanned(txnID id.VerificationTransactionID) bool { - _, ok := c.qrCodesScanned[txnID] - return ok -} - -func (c *baseVerificationCallbacks) IsVerificationDone(txnID id.VerificationTransactionID) bool { - _, ok := c.doneTransactions[txnID] - return ok -} - -func (c *baseVerificationCallbacks) GetVerificationCancellation(txnID id.VerificationTransactionID) *event.VerificationCancelEventContent { - return c.verificationCancellation[txnID] -} - -func (c *baseVerificationCallbacks) GetEmojisAndDescriptionsShown(txnID id.VerificationTransactionID) ([]rune, []string) { - return c.emojisShown[txnID], c.emojiDescriptionsShown[txnID] -} - -func (c *baseVerificationCallbacks) GetDecimalsShown(txnID id.VerificationTransactionID) []int { - return c.decimalsShown[txnID] -} - -func (c *baseVerificationCallbacks) VerificationRequested(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) { - c.verificationsRequested[from] = append(c.verificationsRequested[from], txnID) -} - -func (c *baseVerificationCallbacks) VerificationReady(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, allowScanQRCode bool, qrCode *verificationhelper.QRCode) { - c.verificationsReady = append(c.verificationsReady, txnID) - if allowScanQRCode { - c.scanQRCodeTransactions = append(c.scanQRCodeTransactions, txnID) - } - if qrCode != nil { - c.qrCodesShown[txnID] = qrCode - } -} - -func (c *baseVerificationCallbacks) VerificationCancelled(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) { - c.verificationCancellation[txnID] = &event.VerificationCancelEventContent{ - Code: code, - Reason: reason, - } -} - -func (c *baseVerificationCallbacks) VerificationDone(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) { - c.doneTransactions[txnID] = struct{}{} -} - -type sasVerificationCallbacks struct { - *baseVerificationCallbacks -} - -var _ verificationhelper.ShowSASCallbacks = (*sasVerificationCallbacks)(nil) - -func newSASVerificationCallbacks() *sasVerificationCallbacks { - return &sasVerificationCallbacks{newBaseVerificationCallbacks()} -} - -func newSASVerificationCallbacksWithBase(base *baseVerificationCallbacks) *sasVerificationCallbacks { - return &sasVerificationCallbacks{base} -} - -func (c *sasVerificationCallbacks) ShowSAS(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) { - c.emojisShown[txnID] = emojis - c.emojiDescriptionsShown[txnID] = emojiDescriptions - c.decimalsShown[txnID] = decimals -} - -type showQRCodeVerificationCallbacks struct { - *baseVerificationCallbacks -} - -var _ verificationhelper.ShowQRCodeCallbacks = (*showQRCodeVerificationCallbacks)(nil) - -func newShowQRCodeVerificationCallbacks() *showQRCodeVerificationCallbacks { - return &showQRCodeVerificationCallbacks{newBaseVerificationCallbacks()} -} - -func newShowQRCodeVerificationCallbacksWithBase(base *baseVerificationCallbacks) *showQRCodeVerificationCallbacks { - return &showQRCodeVerificationCallbacks{base} -} - -func (c *showQRCodeVerificationCallbacks) QRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) { - c.qrCodesScanned[txnID] = struct{}{} -} - -type allVerificationCallbacks struct { - *baseVerificationCallbacks - *sasVerificationCallbacks - *showQRCodeVerificationCallbacks -} - -func newAllVerificationCallbacks() *allVerificationCallbacks { - base := newBaseVerificationCallbacks() - return &allVerificationCallbacks{ - base, - newSASVerificationCallbacksWithBase(base), - newShowQRCodeVerificationCallbacksWithBase(base), - } -} diff --git a/mautrix-patched/crypto/verificationhelper/doc.go b/mautrix-patched/crypto/verificationhelper/doc.go deleted file mode 100644 index 29931654..00000000 --- a/mautrix-patched/crypto/verificationhelper/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -// Package verificationhelper provides a helper for the interactive -// verification process according to [Section 11.12.2] of the Spec. -// -// [Section 11.12.2]: https://spec.matrix.org/v1.9/client-server-api/#device-verification -package verificationhelper diff --git a/mautrix-patched/crypto/verificationhelper/ecdhkeys.go b/mautrix-patched/crypto/verificationhelper/ecdhkeys.go deleted file mode 100644 index 754530ed..00000000 --- a/mautrix-patched/crypto/verificationhelper/ecdhkeys.go +++ /dev/null @@ -1,57 +0,0 @@ -package verificationhelper - -import ( - "crypto/ecdh" - "encoding/json" -) - -type ECDHPrivateKey struct { - *ecdh.PrivateKey -} - -func (e *ECDHPrivateKey) UnmarshalJSON(data []byte) (err error) { - if len(data) == 0 { - return nil - } - var raw []byte - err = json.Unmarshal(data, &raw) - if err != nil { - return - } - if len(raw) == 0 { - return nil - } - e.PrivateKey, err = ecdh.X25519().NewPrivateKey(raw) - return err -} - -func (e ECDHPrivateKey) MarshalJSON() ([]byte, error) { - if e.PrivateKey == nil { - return json.Marshal(nil) - } - return json.Marshal(e.Bytes()) -} - -type ECDHPublicKey struct { - *ecdh.PublicKey -} - -func (e *ECDHPublicKey) UnmarshalJSON(data []byte) (err error) { - if len(data) == 0 { - return nil - } - var raw []byte - err = json.Unmarshal(data, &raw) - if err != nil { - return - } - if len(raw) == 0 { - return nil - } - e.PublicKey, err = ecdh.X25519().NewPublicKey(raw) - return -} - -func (e ECDHPublicKey) MarshalJSON() ([]byte, error) { - return json.Marshal(e.Bytes()) -} diff --git a/mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go b/mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go deleted file mode 100644 index 109fbf88..00000000 --- a/mautrix-patched/crypto/verificationhelper/ecdhkeys_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package verificationhelper_test - -import ( - "crypto/ecdh" - "crypto/rand" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/verificationhelper" -) - -func TestECDHPrivateKey(t *testing.T) { - pk, err := ecdh.X25519().GenerateKey(rand.Reader) - require.NoError(t, err) - private := verificationhelper.ECDHPrivateKey{pk} - marshalled, err := json.Marshal(private) - require.NoError(t, err) - - assert.Len(t, marshalled, 46) - - var unmarshalled verificationhelper.ECDHPrivateKey - err = json.Unmarshal(marshalled, &unmarshalled) - require.NoError(t, err) - - assert.True(t, private.Equal(unmarshalled.PrivateKey)) -} - -func TestECDHPublicKey(t *testing.T) { - private, err := ecdh.X25519().GenerateKey(rand.Reader) - require.NoError(t, err) - - public := private.PublicKey() - - pub := verificationhelper.ECDHPublicKey{public} - marshalled, err := json.Marshal(pub) - require.NoError(t, err) - - assert.Len(t, marshalled, 46) - - var unmarshalled verificationhelper.ECDHPublicKey - err = json.Unmarshal(marshalled, &unmarshalled) - require.NoError(t, err) - - assert.True(t, public.Equal(unmarshalled.PublicKey)) -} diff --git a/mautrix-patched/crypto/verificationhelper/qrcode.go b/mautrix-patched/crypto/verificationhelper/qrcode.go deleted file mode 100644 index 3f38d97c..00000000 --- a/mautrix-patched/crypto/verificationhelper/qrcode.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - - "go.mau.fi/util/random" - - "maunium.net/go/mautrix/id" -) - -var ( - ErrInvalidQRCodeHeader = errors.New("invalid QR code header") - ErrUnknownQRCodeVersion = errors.New("invalid QR code version") - ErrInvalidQRCodeMode = errors.New("invalid QR code mode") - ErrInvalidQRCodeLength = errors.New("QR data too short") -) - -type QRCodeMode byte - -const ( - QRCodeModeCrossSigning QRCodeMode = 0x00 - QRCodeModeSelfVerifyingMasterKeyTrusted QRCodeMode = 0x01 - QRCodeModeSelfVerifyingMasterKeyUntrusted QRCodeMode = 0x02 -) - -const MinQRSharedSecretLength = 8 - -type QRCode struct { - Mode QRCodeMode - TransactionID id.VerificationTransactionID - Key1, Key2 [32]byte - SharedSecret []byte -} - -func NewQRCode(mode QRCodeMode, txnID id.VerificationTransactionID, key1, key2 [32]byte) *QRCode { - return &QRCode{ - Mode: mode, - TransactionID: txnID, - Key1: key1, - Key2: key2, - SharedSecret: random.Bytes(16), - } -} - -const qrHeaderString = "MATRIX" -const minQRLength = len(qrHeaderString) + 4 + 2*32 + MinQRSharedSecretLength - -var qrHeader = []byte(qrHeaderString) - -// NewQRCodeFromBytes parses the bytes from a QR code scan as defined in -// [Section 11.12.2.4.1] of the Spec. -// -// [Section 11.12.2.4.1]: https://spec.matrix.org/v1.9/client-server-api/#qr-code-format -func NewQRCodeFromBytes(data []byte) (*QRCode, error) { - if !bytes.HasPrefix(data, qrHeader) { - return nil, ErrInvalidQRCodeHeader - } - if len(data) > 6 && data[6] != 0x02 { - return nil, ErrUnknownQRCodeVersion - } - if len(data) < minQRLength { - return nil, fmt.Errorf("%w: expected at least %d bytes, got %d", ErrInvalidQRCodeLength, minQRLength, len(data)) - } - if data[7] != 0x00 && data[7] != 0x01 && data[7] != 0x02 { - return nil, ErrInvalidQRCodeMode - } - transactionIDLength := binary.BigEndian.Uint16(data[8:10]) - if len(data) < int(10+transactionIDLength+64) { - return nil, fmt.Errorf("%w for transaction ID: expected at least %d bytes, got %d", ErrInvalidQRCodeLength, 10+transactionIDLength+64, len(data)) - } - transactionID := data[10 : 10+transactionIDLength] - - var key1, key2 [32]byte - copy(key1[:], data[10+transactionIDLength:10+transactionIDLength+32]) - copy(key2[:], data[10+transactionIDLength+32:10+transactionIDLength+64]) - sharedSecret := data[10+transactionIDLength+64:] - if len(sharedSecret) < MinQRSharedSecretLength { - return nil, fmt.Errorf("%w: expected at least %d bytes for shared secret, got %d", ErrInvalidQRCodeLength, MinQRSharedSecretLength, len(sharedSecret)) - } - - return &QRCode{ - Mode: QRCodeMode(data[7]), - TransactionID: id.VerificationTransactionID(transactionID), - Key1: key1, - Key2: key2, - SharedSecret: sharedSecret, - }, nil -} - -// Bytes returns the bytes that need to be encoded in the QR code as defined in -// [Section 11.12.2.4.1] of the Spec. -// -// [Section 11.12.2.4.1]: https://spec.matrix.org/v1.9/client-server-api/#qr-code-format -func (q *QRCode) Bytes() []byte { - if q == nil { - return nil - } - - var buf bytes.Buffer - buf.WriteString("MATRIX") // Header - buf.WriteByte(0x02) // Version - buf.WriteByte(byte(q.Mode)) // Mode - - // Transaction ID length + Transaction ID - buf.Write(binary.BigEndian.AppendUint16(nil, uint16(len(q.TransactionID.String())))) - buf.WriteString(q.TransactionID.String()) - - buf.Write(q.Key1[:]) // Key 1 - buf.Write(q.Key2[:]) // Key 2 - buf.Write(q.SharedSecret) // Shared secret - return buf.Bytes() -} diff --git a/mautrix-patched/crypto/verificationhelper/qrcode_test.go b/mautrix-patched/crypto/verificationhelper/qrcode_test.go deleted file mode 100644 index 0e9d8520..00000000 --- a/mautrix-patched/crypto/verificationhelper/qrcode_test.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper_test - -import ( - "bytes" - "encoding/base64" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/crypto/verificationhelper" - "maunium.net/go/mautrix/id" -) - -func TestQRCode_Roundtrip(t *testing.T) { - var key1, key2 [32]byte - copy(key1[:], bytes.Repeat([]byte{0x01}, 32)) - copy(key2[:], bytes.Repeat([]byte{0x02}, 32)) - txnID := id.VerificationTransactionID(strings.Repeat("a", 20)) - qrCode := verificationhelper.NewQRCode(verificationhelper.QRCodeModeCrossSigning, txnID, key1, key2) - - encoded := qrCode.Bytes() - decoded, err := verificationhelper.NewQRCodeFromBytes(encoded) - require.NoError(t, err) - - assert.Equal(t, verificationhelper.QRCodeModeCrossSigning, decoded.Mode) - assert.EqualValues(t, txnID, decoded.TransactionID) - assert.Equal(t, key1, decoded.Key1) - assert.Equal(t, key2, decoded.Key2) -} - -func TestQRCodeDecode(t *testing.T) { - testCases := []struct { - b64 string - txnID string - key1 string - key2 string - sharedSecret string - }{ - { - "TUFUUklYAgEAIEduQWVDdnRXanpNT1ZXUVRrdDM1WVJVcnVqbVJQYzhhGDJ8w4zCpsK1wqdQV2cZXsOvwqDCmMKdNsOtehAuGD5Ow4TDgUUMwq4ZeMKZBsKSwpTCjsK3WcKWwq3DvXBqEcK6wqkpw48NwrjCiGdbw7MBwrBjLsKlw7Ngw4IEw6NyfXwdwrbCusKBHsKZwrh/Cg==", - "GnAeCvtWjzMOVWQTkt35YRUrujmRPc8a", - "GDJ8w4zCpsK1wqdQV2cZXsOvwqDCmMKdNsOtehAuGD4=", - "TsOEw4FFDMKuGXjCmQbCksKUwo7Ct1nClsKtw71wahE=", - "wrrCqSnDjw3CuMKIZ1vDswHCsGMuwqXDs2DDggTDo3J9fB3CtsK6woEewpnCuH8K", - }, - { - "TUFUUklYAgEAIGM1YjljNzE3ZWIzYjRmYzBiZDhhZjA0MDQ4NDY5MDdle4oLkpUdO1cTu5M3K3B4BlnpxtAbVgXCuQKOIqMmt+xAjVvaEXF39X0z5waRY9UE0b5PKiWvOBSJHEGkxX28Y2OEDLIWP/kCVUlyXXENlj0=", - "c5b9c717eb3b4fc0bd8af0404846907e", - "e4oLkpUdO1cTu5M3K3B4BlnpxtAbVgXCuQKOIqMmt+w=", - "QI1b2hFxd/V9M+cGkWPVBNG+TyolrzgUiRxBpMV9vGM=", - "Y4QMshY/+QJVSXJdcQ2WPQ==", - }, - } - - for _, tc := range testCases { - t.Run(tc.b64, func(t *testing.T) { - qrcodeData, err := base64.StdEncoding.DecodeString(tc.b64) - require.NoError(t, err) - expectedKey1, err := base64.StdEncoding.DecodeString(tc.key1) - require.NoError(t, err) - expectedKey2, err := base64.StdEncoding.DecodeString(tc.key2) - require.NoError(t, err) - expectedSharedSecret, err := base64.StdEncoding.DecodeString(tc.sharedSecret) - require.NoError(t, err) - - decoded, err := verificationhelper.NewQRCodeFromBytes(qrcodeData) - require.NoError(t, err) - assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyTrusted, decoded.Mode) - assert.EqualValues(t, tc.txnID, decoded.TransactionID) - assert.EqualValues(t, expectedKey1, decoded.Key1) - assert.EqualValues(t, expectedKey2, decoded.Key2) - assert.EqualValues(t, expectedSharedSecret, decoded.SharedSecret) - - // Test that truncating the shared secret causes an error up to the point - // where we have 8 bytes of the shared secret (secret length isn't validated) - for i := 6; i < len(qrcodeData)-len(expectedSharedSecret)+7; i++ { - _, err := verificationhelper.NewQRCodeFromBytes(qrcodeData[:i]) - assert.ErrorIs(t, err, verificationhelper.ErrInvalidQRCodeLength) - } - }) - } -} diff --git a/mautrix-patched/crypto/verificationhelper/reciprocate.go b/mautrix-patched/crypto/verificationhelper/reciprocate.go deleted file mode 100644 index 10658ba6..00000000 --- a/mautrix-patched/crypto/verificationhelper/reciprocate.go +++ /dev/null @@ -1,353 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper - -import ( - "bytes" - "context" - "errors" - "fmt" - - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// HandleScannedQRData verifies the keys from a scanned QR code and if -// successful, sends the m.key.verification.start event and -// m.key.verification.done event. -func (vh *VerificationHelper) HandleScannedQRData(ctx context.Context, data []byte) error { - qrCode, err := NewQRCodeFromBytes(data) - if err != nil { - return err - } - log := vh.getLog(ctx).With(). - Str("verification_action", "handle scanned QR data"). - Stringer("transaction_id", qrCode.TransactionID). - Int("mode", int(qrCode.Mode)). - Logger() - ctx = log.WithContext(ctx) - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - txn, err := vh.store.GetVerificationTransaction(ctx, qrCode.TransactionID) - if err != nil { - return fmt.Errorf("failed to get transaction %s: %w", qrCode.TransactionID, err) - } else if txn.VerificationState != VerificationStateReady { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "transaction found in the QR code is not in the ready state") - } - txn.VerificationState = VerificationStateTheirQRScanned - - // Verify the keys - log.Info().Msg("Verifying keys from QR code") - - ownCrossSigningPublicKeys, err := vh.mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } else if ownCrossSigningPublicKeys == nil { - return crypto.ErrCrossSigningPubkeysNotCached - } - - switch qrCode.Mode { - case QRCodeModeCrossSigning: - theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) - if err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "couldn't get %s's cross-signing keys: %w", txn.TheirUserID, err) - } - if bytes.Equal(theirSigningKeys.MasterKey.Bytes(), qrCode.Key1[:]) { - log.Info().Msg("Verified that the other device has the master key we expected") - } else { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the other device does not have the master key we expected") - } - - // Verify the master key is correct - if bytes.Equal(ownCrossSigningPublicKeys.MasterKey.Bytes(), qrCode.Key2[:]) { - log.Info().Msg("Verified that the other device has the same master key") - } else { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the master key does not match") - } - - if err := vh.mach.SignUser(ctx, txn.TheirUserID, theirSigningKeys.MasterKey); err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign their master key: %w", err) - } - case QRCodeModeSelfVerifyingMasterKeyTrusted: - // The QR was created by a device that trusts the master key, which - // means that we don't trust the key. Key1 is the master key public - // key, and Key2 is what the other device thinks our device key is. - - if vh.client.UserID != txn.TheirUserID { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "mode %d is only allowed when the other user is the same as the current user", qrCode.Mode) - } - - // Verify the master key is correct - if bytes.Equal(ownCrossSigningPublicKeys.MasterKey.Bytes(), qrCode.Key1[:]) { - log.Info().Msg("Verified that the other device has the same master key") - } else { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the master key does not match") - } - - // Verify that the device key that the other device things we have is - // correct. - myKeys := vh.mach.OwnIdentity() - if bytes.Equal(myKeys.SigningKey.Bytes(), qrCode.Key2[:]) { - log.Info().Msg("Verified that the other device has the correct key for this device") - } else { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the other device has the wrong key for this device") - } - - if err := vh.mach.SignOwnMasterKey(ctx); err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign own master key: %w", err) - } - case QRCodeModeSelfVerifyingMasterKeyUntrusted: - // The QR was created by a device that does not trust the master key, - // which means that we do trust the master key. Key1 is the other - // device's device key, and Key2 is what the other device thinks the - // master key is. - - // Check that we actually trust the master key. - if trusted, err := vh.mach.CryptoStore.IsKeySignedBy(ctx, vh.client.UserID, ownCrossSigningPublicKeys.MasterKey, vh.client.UserID, vh.mach.OwnIdentity().SigningKey); err != nil { - return err - } else if !trusted { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeMasterKeyNotTrusted, "the master key is not trusted by this device, cannot verify device that does not trust the master key") - } - - if vh.client.UserID != txn.TheirUserID { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "mode %d is only allowed when the other user is the same as the current user", qrCode.Mode) - } - - // Get their device - theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) - if err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to get their device: %w", err) - } - - // Verify that the other device's key is what we expect. - if bytes.Equal(theirDevice.SigningKey.Bytes(), qrCode.Key1[:]) { - log.Info().Msg("Verified that the other device key is what we expected") - } else { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the other device's key is not what we expected") - } - - // Verify that what they think the master key is is correct. - if bytes.Equal(ownCrossSigningPublicKeys.MasterKey.Bytes(), qrCode.Key2[:]) { - log.Info().Msg("Verified that the other device has the correct master key") - } else { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "the master key does not match") - } - - // Trust their device - theirDevice.Trust = id.TrustStateVerified - err = vh.mach.CryptoStore.PutDevice(ctx, txn.TheirUserID, theirDevice) - if err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to update device trust state after verifying: %+v", err) - } - - // Cross-sign their device with the self-signing key - err = vh.mach.SignOwnDevice(ctx, theirDevice) - if err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign their device: %+v", err) - } - default: - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "unknown QR code mode %d", qrCode.Mode) - } - - // Send a m.key.verification.start event with the secret - txn.StartedByUs = true - txn.StartEventContent = &event.VerificationStartEventContent{ - FromDevice: vh.client.DeviceID, - Method: event.VerificationMethodReciprocate, - Secret: qrCode.SharedSecret, - } - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationStart, txn.StartEventContent) - if err != nil { - return fmt.Errorf("failed to send m.key.verification.start event: %w", err) - } - log.Debug().Msg("Successfully sent the m.key.verification.start event") - - // Immediately send the m.key.verification.done event, as our side of the - // transaction is done. - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) - if err != nil { - return fmt.Errorf("failed to send m.key.verification.done event: %w", err) - } - log.Debug().Msg("Successfully sent the m.key.verification.done event") - txn.SentOurDone = true - if txn.ReceivedTheirDone { - log.Debug().Msg("We already received their done event. Setting verification state to done.") - if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { - return err - } - vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) - } else { - return vh.store.SaveVerificationTransaction(ctx, txn) - } - return nil -} - -// ConfirmQRCodeScanned confirms that our QR code has been scanned and sends -// the m.key.verification.done event to the other device for the given -// transaction ID. The transaction ID should be one received via the -// VerificationRequested callback in [RequiredCallbacks] or the -// [StartVerification] or [StartInRoomVerification] functions. -func (vh *VerificationHelper) ConfirmQRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) error { - log := vh.getLog(ctx).With(). - Str("verification_action", "confirm QR code scanned"). - Stringer("transaction_id", txnID). - Logger() - ctx = log.WithContext(ctx) - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - txn, err := vh.store.GetVerificationTransaction(ctx, txnID) - if err != nil { - return fmt.Errorf("failed to get transaction %s: %w", txnID, err) - } else if txn.VerificationState != VerificationStateOurQRScanned { - return fmt.Errorf("transaction is not in the scanned state") - } - - log.Info().Msg("Confirming QR code scanned") - - // Get their device - theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) - if err != nil { - return err - } - - // Trust their device - theirDevice.Trust = id.TrustStateVerified - err = vh.mach.CryptoStore.PutDevice(ctx, txn.TheirUserID, theirDevice) - if err != nil { - return fmt.Errorf("failed to update device trust state after verifying: %w", err) - } - - if txn.TheirUserID == vh.client.UserID { - // Self-signing situation. - // - // If we have the cross-signing keys, then we need to sign their device - // using the self-signing key. Otherwise, they have the master private - // key, so we need to trust the master public key. - if vh.mach.CrossSigningKeys != nil { - err = vh.mach.SignOwnDevice(ctx, theirDevice) - if err != nil { - return fmt.Errorf("failed to sign our own new device: %w", err) - } - } else { - err = vh.mach.SignOwnMasterKey(ctx) - if err != nil { - return fmt.Errorf("failed to sign our own master key: %w", err) - } - } - } else { - // Cross-signing situation. Sign their master key. - theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) - if err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "couldn't get %s's cross-signing keys: %w", txn.TheirUserID, err) - } - - if err := vh.mach.SignUser(ctx, txn.TheirUserID, theirSigningKeys.MasterKey); err != nil { - return vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to sign their master key: %w", err) - } - } - - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) - if err != nil { - return err - } - txn.SentOurDone = true - if txn.ReceivedTheirDone { - if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { - return err - } - vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) - } else { - return vh.store.SaveVerificationTransaction(ctx, txn) - } - return nil -} - -func (vh *VerificationHelper) generateQRCode(ctx context.Context, txn *VerificationTransaction) (*QRCode, error) { - log := vh.getLog(ctx).With(). - Str("verification_action", "generate and show QR code"). - Stringer("transaction_id", txn.TransactionID). - Logger() - ctx = log.WithContext(ctx) - - if !slices.Contains(vh.supportedMethods, event.VerificationMethodReciprocate) || - !slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodReciprocate) { - log.Info().Msg("Ignoring QR code generation request as reciprocating is not supported by both devices") - return nil, nil - } else if !slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodQRCodeScan) { - log.Info().Msg("Ignoring QR code generation request as other device cannot scan QR codes") - return nil, nil - } - - ownCrossSigningPublicKeys, err := vh.mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } else if ownCrossSigningPublicKeys == nil || len(ownCrossSigningPublicKeys.MasterKey) == 0 { - return nil, errors.New("failed to get own cross-signing master public key") - } - - ownMasterKeyTrusted, err := vh.mach.CryptoStore.IsKeySignedBy(ctx, vh.client.UserID, ownCrossSigningPublicKeys.MasterKey, vh.client.UserID, vh.mach.OwnIdentity().SigningKey) - if err != nil { - return nil, err - } - mode := QRCodeModeCrossSigning - if vh.client.UserID == txn.TheirUserID { - // This is a self-signing situation. - if ownMasterKeyTrusted { - mode = QRCodeModeSelfVerifyingMasterKeyTrusted - } else { - mode = QRCodeModeSelfVerifyingMasterKeyUntrusted - } - } else { - // This is a cross-signing situation. - if !ownMasterKeyTrusted { - return nil, errors.New("cannot cross-sign other device when own master key is not trusted") - } - mode = QRCodeModeCrossSigning - } - - var key1, key2 []byte - switch mode { - case QRCodeModeCrossSigning: - // Key 1 is the current user's master signing key. - key1 = ownCrossSigningPublicKeys.MasterKey.Bytes() - - // Key 2 is the other user's master signing key. - theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) - if err != nil { - return nil, err - } - key2 = theirSigningKeys.MasterKey.Bytes() - case QRCodeModeSelfVerifyingMasterKeyTrusted: - // Key 1 is the current user's master signing key. - key1 = ownCrossSigningPublicKeys.MasterKey.Bytes() - - // Key 2 is the other device's key. - theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) - if err != nil { - return nil, err - } - key2 = theirDevice.SigningKey.Bytes() - case QRCodeModeSelfVerifyingMasterKeyUntrusted: - // Key 1 is the current device's key - key1 = vh.mach.OwnIdentity().SigningKey.Bytes() - - // Key 2 is the master signing key. - key2 = ownCrossSigningPublicKeys.MasterKey.Bytes() - default: - log.Fatal().Int("mode", int(mode)).Msg("Unknown QR code mode") - } - - qrCode := NewQRCode(mode, txn.TransactionID, [32]byte(key1), [32]byte(key2)) - txn.QRCodeSharedSecret = qrCode.SharedSecret - return qrCode, nil -} diff --git a/mautrix-patched/crypto/verificationhelper/sas.go b/mautrix-patched/crypto/verificationhelper/sas.go deleted file mode 100644 index f5f13773..00000000 --- a/mautrix-patched/crypto/verificationhelper/sas.go +++ /dev/null @@ -1,821 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper - -import ( - "bytes" - "context" - "crypto/ecdh" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "strings" - - "go.mau.fi/util/jsonbytes" - "golang.org/x/crypto/hkdf" - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// StartSAS starts a SAS verification flow for the given transaction ID. The -// transaction ID should be one received via the VerificationRequested callback -// in [RequiredCallbacks] or the [StartVerification] or -// [StartInRoomVerification] functions. -func (vh *VerificationHelper) StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error { - log := vh.getLog(ctx).With(). - Str("verification_action", "start SAS"). - Stringer("transaction_id", txnID). - Logger() - ctx = log.WithContext(ctx) - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - txn, err := vh.store.GetVerificationTransaction(ctx, txnID) - if err != nil { - return fmt.Errorf("failed to get verification transaction %s: %w", txnID, err) - } else if txn.VerificationState != VerificationStateReady { - return fmt.Errorf("transaction is not in ready state: %s", txn.VerificationState.String()) - } else if txn.StartEventContent != nil { - return errors.New("start event already sent or received") - } - - txn.VerificationState = VerificationStateSASStarted - txn.StartedByUs = true - if !slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodSAS) { - return fmt.Errorf("the other device does not support SAS verification") - } - - // Ensure that we have their device key. - _, err = vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) - if err != nil { - log.Err(err).Msg("Failed to fetch device") - return err - } - - log.Info().Msg("Sending start event") - startEventContent := event.VerificationStartEventContent{ - FromDevice: vh.client.DeviceID, - Method: event.VerificationMethodSAS, - - Hashes: []event.VerificationHashMethod{event.VerificationHashMethodSHA256}, - KeyAgreementProtocols: []event.KeyAgreementProtocol{event.KeyAgreementProtocolCurve25519HKDFSHA256}, - MessageAuthenticationCodes: []event.MACMethod{ - event.MACMethodHKDFHMACSHA256, - event.MACMethodHKDFHMACSHA256V2, - }, - ShortAuthenticationString: []event.SASMethod{ - event.SASMethodDecimal, - event.SASMethodEmoji, - }, - } - if err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationStart, &startEventContent); err != nil { - return err - } - txn.StartEventContent = &startEventContent - return vh.store.SaveVerificationTransaction(ctx, txn) -} - -// ConfirmSAS indicates that the user has confirmed that the SAS matches SAS -// shown on the other user's device for the given transaction ID. The -// transaction ID should be one received via the VerificationRequested callback -// in [RequiredCallbacks] or the [StartVerification] or -// [StartInRoomVerification] functions. -func (vh *VerificationHelper) ConfirmSAS(ctx context.Context, txnID id.VerificationTransactionID) error { - log := vh.getLog(ctx).With(). - Str("verification_action", "confirm SAS"). - Stringer("transaction_id", txnID). - Logger() - ctx = log.WithContext(ctx) - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - txn, err := vh.store.GetVerificationTransaction(ctx, txnID) - if err != nil { - return fmt.Errorf("failed to get transaction %s: %w", txnID, err) - } else if txn.VerificationState != VerificationStateSASKeysExchanged { - return errors.New("transaction is not in keys exchanged state") - } - - keys := map[id.KeyID]jsonbytes.UnpaddedBytes{} - - log.Info().Msg("Signing keys") - var masterKey string - - // My device key - myDevice := vh.mach.OwnIdentity() - myDeviceKeyID := id.NewKeyID(id.KeyAlgorithmEd25519, myDevice.DeviceID.String()) - keys[myDeviceKeyID], err = vh.verificationMACHKDF(txn, vh.client.UserID, vh.client.DeviceID, txn.TheirUserID, txn.TheirDeviceID, myDeviceKeyID.String(), myDevice.SigningKey.String()) - if err != nil { - return err - } - - // Master signing key - crossSigningKeys, err := vh.mach.GetOwnCrossSigningPublicKeys(ctx) - if err != nil { - return fmt.Errorf("failed to get own cross-signing public keys: %w", err) - } else if crossSigningKeys != nil { - masterKey = crossSigningKeys.MasterKey.String() - crossSigningKeyID := id.NewKeyID(id.KeyAlgorithmEd25519, masterKey) - keys[crossSigningKeyID], err = vh.verificationMACHKDF(txn, vh.client.UserID, vh.client.DeviceID, txn.TheirUserID, txn.TheirDeviceID, crossSigningKeyID.String(), masterKey) - if err != nil { - return err - } - } - - var keyIDs []string - for keyID := range keys { - keyIDs = append(keyIDs, keyID.String()) - } - slices.Sort(keyIDs) - keysMAC, err := vh.verificationMACHKDF(txn, vh.client.UserID, vh.client.DeviceID, txn.TheirUserID, txn.TheirDeviceID, "KEY_IDS", strings.Join(keyIDs, ",")) - if err != nil { - return err - } - - macEventContent := &event.VerificationMACEventContent{ - Keys: keysMAC, - MAC: keys, - } - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationMAC, macEventContent) - if err != nil { - return err - } - log.Info().Msg("Sent our MAC event") - - txn.SentOurMAC = true - if txn.ReceivedTheirMAC { - txn.VerificationState = VerificationStateSASMACExchanged - - if err := vh.trustKeysAfterMACCheck(ctx, txn, masterKey); err != nil { - return fmt.Errorf("failed to trust keys: %w", err) - } - - err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) - if err != nil { - return err - } - txn.SentOurDone = true - } - return vh.store.SaveVerificationTransaction(ctx, txn) -} - -// onVerificationStartSAS handles the m.key.verification.start events with -// method of m.sas.v1 by implementing steps 4-7 of [Section 11.12.2.2] of the -// Spec. -// -// [Section 11.12.2.2]: https://spec.matrix.org/v1.9/client-server-api/#short-authentication-string-sas-verification -func (vh *VerificationHelper) onVerificationStartSAS(ctx context.Context, txn VerificationTransaction, evt *event.Event) error { - startEvt := evt.Content.AsVerificationStart() - log := vh.getLog(ctx).With(). - Str("verification_action", "start SAS"). - Stringer("transaction_id", txn.TransactionID). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Received SAS verification start event") - - _, err := vh.mach.GetOrFetchDevice(ctx, evt.Sender, startEvt.FromDevice) - if err != nil { - log.Err(err).Msg("Failed to fetch device") - return err - } - - keyAggreementProtocol := event.KeyAgreementProtocolCurve25519HKDFSHA256 - if !slices.Contains(startEvt.KeyAgreementProtocols, keyAggreementProtocol) { - return fmt.Errorf("the other device does not support any key agreement protocols that we support") - } - - hashAlgorithm := event.VerificationHashMethodSHA256 - if !slices.Contains(startEvt.Hashes, hashAlgorithm) { - return fmt.Errorf("the other device does not support any hash algorithms that we support") - } - - macMethod := event.MACMethodHKDFHMACSHA256V2 - if !slices.Contains(startEvt.MessageAuthenticationCodes, macMethod) { - if slices.Contains(startEvt.MessageAuthenticationCodes, event.MACMethodHKDFHMACSHA256) { - macMethod = event.MACMethodHKDFHMACSHA256 - } else { - return fmt.Errorf("the other device does not support any message authentication codes that we support") - } - } - - var sasMethods []event.SASMethod - for _, sasMethod := range startEvt.ShortAuthenticationString { - if sasMethod == event.SASMethodDecimal || sasMethod == event.SASMethodEmoji { - sasMethods = append(sasMethods, sasMethod) - } - } - if len(sasMethods) == 0 { - return fmt.Errorf("the other device does not support any short authentication string methods that we support") - } - - ephemeralKey, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - return fmt.Errorf("failed to generate ephemeral key: %w", err) - } - txn.MACMethod = macMethod - txn.EphemeralKey = &ECDHPrivateKey{ephemeralKey} - - if !txn.StartedByUs { - commitment, err := calculateCommitment(ephemeralKey.PublicKey(), txn) - if err != nil { - return fmt.Errorf("failed to calculate commitment: %w", err) - } - - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationAccept, &event.VerificationAcceptEventContent{ - Commitment: commitment, - Hash: hashAlgorithm, - KeyAgreementProtocol: keyAggreementProtocol, - MessageAuthenticationCode: macMethod, - ShortAuthenticationString: sasMethods, - }) - if err != nil { - return fmt.Errorf("failed to send accept event: %w", err) - } - txn.VerificationState = VerificationStateSASAccepted - } - return vh.store.SaveVerificationTransaction(ctx, txn) -} - -func calculateCommitment(ephemeralPubKey *ecdh.PublicKey, txn VerificationTransaction) ([]byte, error) { - // The commitmentHashInput is the hash (encoded as unpadded base64) of the - // concatenation of the device's ephemeral public key (encoded as - // unpadded base64) and the canonical JSON representation of the - // m.key.verification.start message. - // - // I have no idea why they chose to base64-encode the public key before - // hashing it, but we are just stuck on that. - commitmentHashInput := sha256.New() - commitmentHashInput.Write([]byte(base64.RawStdEncoding.EncodeToString(ephemeralPubKey.Bytes()))) - encodedStartEvt, err := canonicaljson.Marshal(txn.StartEventContent) - if err != nil { - return nil, err - } - commitmentHashInput.Write(encodedStartEvt) - return commitmentHashInput.Sum(nil), nil -} - -// onVerificationAccept handles the m.key.verification.accept SAS verification -// event. This follows Step 4 of [Section 11.12.2.2] of the Spec. -// -// [Section 11.12.2.2]: https://spec.matrix.org/v1.9/client-server-api/#short-authentication-string-sas-verification -func (vh *VerificationHelper) onVerificationAccept(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - acceptEvt := evt.Content.AsVerificationAccept() - log := vh.getLog(ctx).With(). - Str("verification_action", "accept"). - Stringer("transaction_id", txn.TransactionID). - Str("commitment", base64.RawStdEncoding.EncodeToString(acceptEvt.Commitment)). - Str("hash", string(acceptEvt.Hash)). - Str("key_agreement_protocol", string(acceptEvt.KeyAgreementProtocol)). - Str("message_authentication_code", string(acceptEvt.MessageAuthenticationCode)). - Any("short_authentication_string", acceptEvt.ShortAuthenticationString). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Received SAS verification accept event") - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - if txn.VerificationState != VerificationStateSASStarted { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, - "received accept event for a transaction that is not in the started state") - return - } - - ephemeralKey, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - log.Err(err).Msg("Failed to generate ephemeral key") - return - } - - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationKey, &event.VerificationKeyEventContent{ - Key: ephemeralKey.PublicKey().Bytes(), - }) - if err != nil { - log.Err(err).Msg("Failed to send key event") - return - } - - txn.VerificationState = VerificationStateSASAccepted - txn.MACMethod = acceptEvt.MessageAuthenticationCode - txn.Commitment = acceptEvt.Commitment - txn.EphemeralKey = &ECDHPrivateKey{ephemeralKey} - txn.EphemeralPublicKeyShared = true - - if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { - log.Err(err).Msg("failed to save verification transaction") - } -} - -func (vh *VerificationHelper) onVerificationKey(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - log := vh.getLog(ctx).With(). - Str("verification_action", "key"). - Logger() - ctx = log.WithContext(ctx) - keyEvt := evt.Content.AsVerificationKey() - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - if txn.VerificationState != VerificationStateSASAccepted { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, - "received key event for a transaction that is not in the accepted state") - return - } - - var err error - publicKey, err := ecdh.X25519().NewPublicKey(keyEvt.Key) - if err != nil { - log.Err(err).Msg("Failed to generate other public key") - return - } - txn.OtherPublicKey = &ECDHPublicKey{publicKey} - - if txn.EphemeralPublicKeyShared { - // Verify that the commitment hash is correct - commitment, err := calculateCommitment(publicKey, txn) - if err != nil { - log.Err(err).Msg("Failed to calculate commitment") - return - } - if !bytes.Equal(commitment, txn.Commitment) { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "The key was not the one we expected") - return - } - } else { - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationKey, &event.VerificationKeyEventContent{ - Key: txn.EphemeralKey.PublicKey().Bytes(), - }) - if err != nil { - log.Err(err).Msg("Failed to send key event") - return - } - txn.EphemeralPublicKeyShared = true - } - txn.VerificationState = VerificationStateSASKeysExchanged - - sasBytes, err := vh.verificationSASHKDF(txn) - if err != nil { - log.Err(err).Msg("Failed to compute HKDF for SAS") - return - } - - var decimals []int - var emojis []rune - var emojiDescriptions []string - if slices.Contains(txn.StartEventContent.ShortAuthenticationString, event.SASMethodDecimal) { - decimals = []int{ - (int(sasBytes[0])<<5 | int(sasBytes[1])>>3) + 1000, - ((int(sasBytes[1])&0x07)<<10 | int(sasBytes[2])<<2 | int(sasBytes[3])>>6) + 1000, - ((int(sasBytes[3])&0x3f)<<7 | int(sasBytes[4])>>1) + 1000, - } - } - if slices.Contains(txn.StartEventContent.ShortAuthenticationString, event.SASMethodEmoji) { - sasNum := uint64(sasBytes[0])<<40 | uint64(sasBytes[1])<<32 | uint64(sasBytes[2])<<24 | - uint64(sasBytes[3])<<16 | uint64(sasBytes[4])<<8 | uint64(sasBytes[5]) - - for i := 0; i < 7; i++ { - // Right shift the number and then mask the lowest 6 bits. - emojiIdx := (sasNum >> uint(48-(i+1)*6)) & 0b111111 - emojis = append(emojis, allEmojis[emojiIdx]) - emojiDescriptions = append(emojiDescriptions, allEmojiDescriptions[emojiIdx]) - } - } - vh.showSAS(ctx, txn.TransactionID, emojis, emojiDescriptions, decimals) - - if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { - log.Err(err).Msg("failed to save verification transaction") - } -} - -func (vh *VerificationHelper) verificationSASHKDF(txn VerificationTransaction) ([]byte, error) { - sharedSecret, err := txn.EphemeralKey.ECDH(txn.OtherPublicKey.PublicKey) - if err != nil { - return nil, err - } - - // Perform the SAS HKDF calculation according to Section 11.12.2.2.4 of the - // Spec: - // https://spec.matrix.org/v1.9/client-server-api/#sas-hkdf-calculation - myInfo := strings.Join([]string{ - vh.client.UserID.String(), - vh.client.DeviceID.String(), - base64.RawStdEncoding.EncodeToString(txn.EphemeralKey.PublicKey().Bytes()), - }, "|") - - theirInfo := strings.Join([]string{ - txn.TheirUserID.String(), - txn.TheirDeviceID.String(), - base64.RawStdEncoding.EncodeToString(txn.OtherPublicKey.Bytes()), - }, "|") - - var infoBuf bytes.Buffer - infoBuf.WriteString("MATRIX_KEY_VERIFICATION_SAS|") - if txn.StartedByUs { - infoBuf.WriteString(myInfo + "|" + theirInfo) - } else { - infoBuf.WriteString(theirInfo + "|" + myInfo) - } - infoBuf.WriteRune('|') - infoBuf.WriteString(txn.TransactionID.String()) - - reader := hkdf.New(sha256.New, sharedSecret, nil, infoBuf.Bytes()) - output := make([]byte, 6) - _, err = reader.Read(output) - return output, err -} - -// BrokenB64Encode implements the incorrect base64 serialization in libolm for -// the hkdf-hmac-sha256 MAC method. The bug is caused by the input and output -// buffers being equal to one another during the base64 encoding. -// -// This function is narrowly scoped to this specific bug, and does not work -// generally (it only supports if the input is 32-bytes). -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/3783 and -// https://gitlab.matrix.org/matrix-org/olm/-/merge_requests/16 for details. -// -// Deprecated: never use this. It is only here for compatibility with the -// broken libolm implementation. -func BrokenB64Encode(input []byte) string { - encodeBase64 := []byte{ - 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, - 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, - 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, - 0x59, 0x5A, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, - 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, - 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, - 0x77, 0x78, 0x79, 0x7A, 0x30, 0x31, 0x32, 0x33, - 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x2B, 0x2F, - } - - output := make([]byte, 43) - copy(output, input) - - pos := 0 - outputPos := 0 - for pos != 30 { - value := int32(output[pos]) - value <<= 8 - value |= int32(output[pos+1]) - value <<= 8 - value |= int32(output[pos+2]) - pos += 3 - output[outputPos] = encodeBase64[(value>>18)&0x3F] - output[outputPos+1] = encodeBase64[(value>>12)&0x3F] - output[outputPos+2] = encodeBase64[(value>>6)&0x3F] - output[outputPos+3] = encodeBase64[value&0x3F] - outputPos += 4 - } - // This is the mangling that libolm does to the base64 encoding. - value := int32(output[pos]) - value <<= 8 - value |= int32(output[pos+1]) - value <<= 2 - output[outputPos] = encodeBase64[(value>>12)&0x3F] - output[outputPos+1] = encodeBase64[(value>>6)&0x3F] - output[outputPos+2] = encodeBase64[value&0x3F] - return string(output) -} - -func (vh *VerificationHelper) verificationMACHKDF(txn VerificationTransaction, senderUser id.UserID, senderDevice id.DeviceID, receivingUser id.UserID, receivingDevice id.DeviceID, keyID, key string) ([]byte, error) { - sharedSecret, err := txn.EphemeralKey.ECDH(txn.OtherPublicKey.PublicKey) - if err != nil { - return nil, err - } - - var infoBuf bytes.Buffer - infoBuf.WriteString("MATRIX_KEY_VERIFICATION_MAC") - infoBuf.WriteString(senderUser.String()) - infoBuf.WriteString(senderDevice.String()) - infoBuf.WriteString(receivingUser.String()) - infoBuf.WriteString(receivingDevice.String()) - infoBuf.WriteString(txn.TransactionID.String()) - infoBuf.WriteString(keyID) - - reader := hkdf.New(sha256.New, sharedSecret, nil, infoBuf.Bytes()) - macKey := make([]byte, 32) - _, err = reader.Read(macKey) - if err != nil { - return nil, err - } - - hash := hmac.New(sha256.New, macKey) - hash.Write([]byte(key)) - sum := hash.Sum(nil) - if txn.MACMethod == event.MACMethodHKDFHMACSHA256 { - sum, err = base64.RawStdEncoding.DecodeString(BrokenB64Encode(sum)) - if err != nil { - panic(err) - } - } - return sum, nil -} - -var allEmojis = []rune{ - '🐶', - '🐱', - '🦁', - '🐎', - '🦄', - '🐷', - '🐘', - '🐰', - '🐼', - '🐓', - '🐧', - '🐢', - '🐟', - '🐙', - '🦋', - '🌷', - '🌳', - '🌵', - '🍄', - '🌏', - '🌙', - '☁', - '🔥', - '🍌', - '🍎', - '🍓', - '🌽', - '🍕', - '🎂', - '❤', - '😀', - '🤖', - '🎩', - '👓', - '🔧', - '🎅', - '👍', - '☂', - '⌛', - '⏰', - '🎁', - '💡', - '📕', - '✏', - '📎', - '✂', - '🔒', - '🔑', - '🔨', - '☎', - '🏁', - '🚂', - '🚲', - '✈', - '🚀', - '🏆', - '⚽', - '🎸', - '🎺', - '🔔', - '⚓', - '🎧', - '📁', - '📌', -} - -var allEmojiDescriptions = []string{ - "Dog", - "Cat", - "Lion", - "Horse", - "Unicorn", - "Pig", - "Elephant", - "Rabbit", - "Panda", - "Rooster", - "Penguin", - "Turtle", - "Fish", - "Octopus", - "Butterfly", - "Flower", - "Tree", - "Cactus", - "Mushroom", - "Globe", - "Moon", - "Cloud", - "Fire", - "Banana", - "Apple", - "Strawberry", - "Corn", - "Pizza", - "Cake", - "Heart", - "Smiley", - "Robot", - "Hat", - "Glasses", - "Spanner", - "Santa", - "Thumbs Up", - "Umbrella", - "Hourglass", - "Clock", - "Gift", - "Light Bulb", - "Book", - "Pencil", - "Paperclip", - "Scissors", - "Lock", - "Key", - "Hammer", - "Telephone", - "Flag", - "Train", - "Bicycle", - "Aeroplane", - "Rocket", - "Trophy", - "Ball", - "Guitar", - "Trumpet", - "Bell", - "Anchor", - "Headphones", - "Folder", - "Pin", -} - -func (vh *VerificationHelper) onVerificationMAC(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - log := vh.getLog(ctx).With(). - Str("verification_action", "mac"). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Received SAS verification MAC event") - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - macEvt := evt.Content.AsVerificationMAC() - - // Verifying Keys MAC - log.Info().Msg("Verifying MAC for all sent keys") - var hasTheirDeviceKey bool - var masterKey string - var keyIDs []string - for keyID := range macEvt.MAC { - keyIDs = append(keyIDs, keyID.String()) - _, kID := keyID.Parse() - if kID == txn.TheirDeviceID.String() { - hasTheirDeviceKey = true - } else { - masterKey = kID - } - } - slices.Sort(keyIDs) - expectedKeyMAC, err := vh.verificationMACHKDF(txn, txn.TheirUserID, txn.TheirDeviceID, vh.client.UserID, vh.client.DeviceID, "KEY_IDS", strings.Join(keyIDs, ",")) - if err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "failed to calculate key list MAC: %w", err) - return - } - if !hmac.Equal(expectedKeyMAC, macEvt.Keys) { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "key list MAC mismatch") - return - } - if !hasTheirDeviceKey { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "their device key not found in list of keys") - return - } - - // Verify the MAC for each key - var theirDevice *id.Device - for keyID, mac := range macEvt.MAC { - log.Info().Stringer("key_id", keyID).Msg("Received MAC for key") - - alg, kID := keyID.Parse() - if alg != id.KeyAlgorithmEd25519 { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownMethod, "unsupported key algorithm %s", alg) - return - } - - var key string - if kID == txn.TheirDeviceID.String() { - if theirDevice != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInvalidMessage, "two keys found for their device ID") - return - } - theirDevice, err = vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) - if err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to fetch their device: %w", err) - return - } - key = theirDevice.SigningKey.String() - } else { // This is the master key - var crossSigningKeys *crypto.CrossSigningPublicKeysCache - if txn.TheirUserID == vh.client.UserID { - crossSigningKeys, err = vh.mach.GetOwnCrossSigningPublicKeys(ctx) - } else { - crossSigningKeys, err = vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) - } - if crossSigningKeys == nil { - if err != nil { - log.Err(err).Msg("Failed to get cross-signing public keys") - } - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "cross-signing keys not found") - return - } - if kID != crossSigningKeys.MasterKey.String() { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "unknown key ID %s", keyID) - return - } - key = crossSigningKeys.MasterKey.String() - } - - expectedMAC, err := vh.verificationMACHKDF(txn, txn.TheirUserID, txn.TheirDeviceID, vh.client.UserID, vh.client.DeviceID, keyID.String(), key) - if err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to calculate key MAC: %w", err) - return - } - if !hmac.Equal(expectedMAC, mac) { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeSASMismatch, "MAC mismatch for key %s", keyID) - return - } - } - log.Info().Msg("All MACs verified") - - txn.ReceivedTheirMAC = true - if txn.SentOurMAC { - txn.VerificationState = VerificationStateSASMACExchanged - - if err := vh.trustKeysAfterMACCheck(ctx, txn, masterKey); err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to trust keys: %w", err) - return - } - - err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationDone, &event.VerificationDoneEventContent{}) - if err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to send verification done event: %w", err) - return - } - txn.SentOurDone = true - } - - if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { - log.Err(err).Msg("failed to save verification transaction") - } -} - -func (vh *VerificationHelper) trustKeysAfterMACCheck(ctx context.Context, txn VerificationTransaction, masterKey string) error { - theirDevice, err := vh.mach.GetOrFetchDevice(ctx, txn.TheirUserID, txn.TheirDeviceID) - if err != nil { - return fmt.Errorf("failed to fetch their device: %w", err) - } - // Trust their device - theirDevice.Trust = id.TrustStateVerified - err = vh.mach.CryptoStore.PutDevice(ctx, txn.TheirUserID, theirDevice) - if err != nil { - return fmt.Errorf("failed to update device trust state after verifying: %w", err) - } - - if txn.TheirUserID == vh.client.UserID { - // Self-signing situation. - // - // If we have the cross-signing keys, then we need to sign their device - // using the self-signing key. Otherwise, they have the master private - // key, so we need to trust the master public key. - if vh.mach.CrossSigningKeys != nil { - err = vh.mach.SignOwnDevice(ctx, theirDevice) - if err != nil { - return fmt.Errorf("failed to sign our own new device: %w", err) - } - } else { - err = vh.mach.SignOwnMasterKey(ctx) - if err != nil { - return fmt.Errorf("failed to sign our own master key: %w", err) - } - } - } else if masterKey != "" { - // Cross-signing situation. - // - // The master key was included in the list of keys to verify, so verify - // that it matches what we expect and sign their master key using the - // user-signing key. - theirSigningKeys, err := vh.mach.GetCrossSigningPublicKeys(ctx, txn.TheirUserID) - if err != nil { - return fmt.Errorf("couldn't get %s's cross-signing keys: %w", txn.TheirUserID, err) - } else if theirSigningKeys.MasterKey.String() != masterKey { - return fmt.Errorf("master keys do not match") - } - - if err := vh.mach.SignUser(ctx, txn.TheirUserID, theirSigningKeys.MasterKey); err != nil { - return fmt.Errorf("failed to sign %s's master key: %w", txn.TheirUserID, err) - } - } - return nil -} diff --git a/mautrix-patched/crypto/verificationhelper/sas_test.go b/mautrix-patched/crypto/verificationhelper/sas_test.go deleted file mode 100644 index 78e88b80..00000000 --- a/mautrix-patched/crypto/verificationhelper/sas_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/crypto/verificationhelper" -) - -func TestBrokenB64Encode(t *testing.T) { - // See example from the PR that fixed the issue: - // https://gitlab.matrix.org/matrix-org/olm/-/merge_requests/16 - input := []byte{ - 121, 105, 187, 19, 37, 94, 119, 248, 224, 34, 94, 29, 157, 5, - 15, 230, 246, 115, 236, 217, 80, 78, 56, 200, 80, 200, 82, 158, - 168, 179, 10, 230, - } - - b64 := verificationhelper.BrokenB64Encode(input) - assert.Equal(t, "eWm7NyVeVmXgbVhnYlZobllsWm9ibGxzV205aWJHeHo", b64) -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper.go b/mautrix-patched/crypto/verificationhelper/verificationhelper.go deleted file mode 100644 index 0a781c16..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationhelper.go +++ /dev/null @@ -1,932 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper - -import ( - "bytes" - "context" - "errors" - "fmt" - "sync" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exslices" - "go.mau.fi/util/jsontime" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// RequiredCallbacks is an interface representing the callbacks required for -// the [VerificationHelper]. -type RequiredCallbacks interface { - // VerificationRequested is called when a verification request is received - // from another device. - VerificationRequested(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) - - // VerificationReady is called when a verification request has been - // accepted by both parties. - VerificationReady(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, supportsScanQRCode bool, qrCode *QRCode) - - // VerificationCancelled is called when the verification is cancelled. - VerificationCancelled(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) - - // VerificationDone is called when the verification is done. - VerificationDone(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) -} - -type ShowSASCallbacks interface { - // ShowSAS is a callback that is called when the SAS verification has - // generated a short authentication string to show. It is guaranteed that - // either the emojis and emoji descriptions lists, or the decimals list, or - // both will be present. - ShowSAS(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) -} - -type ShowQRCodeCallbacks interface { - // QRCodeScanned is called when the other user has scanned the QR code and - // sent the m.key.verification.start event. - QRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) -} - -type VerificationHelper struct { - client *mautrix.Client - mach *crypto.OlmMachine - - store VerificationStore - activeTransactionsLock sync.Mutex - - // supportedMethods are the methods that *we* support - supportedMethods []event.VerificationMethod - verificationRequested func(ctx context.Context, txnID id.VerificationTransactionID, from id.UserID, fromDevice id.DeviceID) - verificationReady func(ctx context.Context, txnID id.VerificationTransactionID, otherDeviceID id.DeviceID, supportsSAS, supportsScanQRCode bool, qrCode *QRCode) - verificationCancelledCallback func(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) - verificationDone func(ctx context.Context, txnID id.VerificationTransactionID, method event.VerificationMethod) - - // showSAS is a callback that will be called after the SAS verification - // dance is complete and we want the client to show the emojis/decimals - showSAS func(ctx context.Context, txnID id.VerificationTransactionID, emojis []rune, emojiDescriptions []string, decimals []int) - // qrCodeScanned is a callback that will be called when the other device - // scanned the QR code we are showing - qrCodeScanned func(ctx context.Context, txnID id.VerificationTransactionID) -} - -var _ mautrix.VerificationHelper = (*VerificationHelper)(nil) - -func NewVerificationHelper(client *mautrix.Client, mach *crypto.OlmMachine, store VerificationStore, callbacks any, supportsQRShow, supportsQRScan, supportsSAS bool) *VerificationHelper { - if client.Crypto == nil { - panic("client.Crypto is nil") - } - - if store == nil { - store = NewInMemoryVerificationStore() - } - - helper := VerificationHelper{ - client: client, - mach: mach, - store: store, - } - - if c, ok := callbacks.(RequiredCallbacks); !ok { - panic("callbacks must implement RequiredCallbacks") - } else { - helper.verificationRequested = c.VerificationRequested - helper.verificationReady = c.VerificationReady - helper.verificationCancelledCallback = c.VerificationCancelled - helper.verificationDone = c.VerificationDone - } - - if supportsSAS { - if c, ok := callbacks.(ShowSASCallbacks); !ok { - panic("callbacks must implement showSAS if supportsSAS is true") - } else { - helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodSAS) - helper.showSAS = c.ShowSAS - } - } - if supportsQRShow { - if c, ok := callbacks.(ShowQRCodeCallbacks); !ok { - panic("callbacks must implement ShowQRCodeCallbacks if supportsQRShow is true") - } else { - helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodQRCodeShow) - helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodReciprocate) - helper.qrCodeScanned = c.QRCodeScanned - } - } - if supportsQRScan { - helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodQRCodeScan) - helper.supportedMethods = append(helper.supportedMethods, event.VerificationMethodReciprocate) - } - helper.supportedMethods = exslices.DeduplicateUnsorted(helper.supportedMethods) - return &helper -} - -func (vh *VerificationHelper) getLog(ctx context.Context) *zerolog.Logger { - logger := zerolog.Ctx(ctx).With(). - Str("component", "verification"). - Stringer("device_id", vh.client.DeviceID). - Stringer("user_id", vh.client.UserID). - Any("supported_methods", vh.supportedMethods). - Logger() - return &logger -} - -// Init initializes the verification helper by adding the necessary event -// handlers to the syncer. -func (vh *VerificationHelper) Init(ctx context.Context) error { - if vh == nil { - return fmt.Errorf("verification helper is nil") - } - syncer, ok := vh.client.Syncer.(mautrix.ExtensibleSyncer) - if !ok { - return fmt.Errorf("the client syncer must implement ExtensibleSyncer") - } - - // Event handlers for verification requests. These are special since we do - // not need to check that the transaction ID is known. - syncer.OnEventType(event.ToDeviceVerificationRequest, vh.onVerificationRequest) - syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { - if evt.Content.AsMessage().MsgType == event.MsgVerificationRequest { - vh.onVerificationRequest(ctx, evt) - } - }) - - // Wrapper for the event handlers to check that the transaction ID is known - // and ignore the event if it isn't. - wrapHandler := func(callback func(context.Context, VerificationTransaction, *event.Event)) func(context.Context, *event.Event) { - return func(ctx context.Context, evt *event.Event) { - log := vh.getLog(ctx).With(). - Str("verification_action", "check transaction ID"). - Stringer("sender", evt.Sender). - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.ID). - Stringer("event_type", evt.Type). - Logger() - ctx = log.WithContext(ctx) - - var transactionID id.VerificationTransactionID - if evt.ID != "" { - transactionID = id.VerificationTransactionID(evt.ID) - } else { - if txnID, ok := evt.Content.Parsed.(event.VerificationTransactionable); !ok { - log.Warn().Msg("Ignoring verification event without a transaction ID") - return - } else { - transactionID = txnID.GetTransactionID() - } - } - log = log.With().Stringer("transaction_id", transactionID).Logger() - - vh.activeTransactionsLock.Lock() - txn, err := vh.store.GetVerificationTransaction(ctx, transactionID) - if err != nil && errors.Is(err, ErrUnknownVerificationTransaction) { - log.Err(err).Msg("failed to get verification transaction") - vh.activeTransactionsLock.Unlock() - return - } else if errors.Is(err, ErrUnknownVerificationTransaction) { - // If it's a cancellation event for an unknown transaction, we - // can just ignore it. - if evt.Type == event.ToDeviceVerificationCancel || evt.Type == event.InRoomVerificationCancel { - log.Info().Msg("Ignoring verification cancellation event for an unknown transaction") - vh.activeTransactionsLock.Unlock() - return - } - - log.Warn().Msg("Sending cancellation event for unknown transaction ID") - - // We have to create a fake transaction so that the call to - // cancelVerificationTxn works. - txn = VerificationTransaction{ - ExpirationTime: jsontime.UnixMilli{Time: time.Now().Add(time.Minute * 10)}, - RoomID: evt.RoomID, - TheirUserID: evt.Sender, - } - if transactionable, ok := evt.Content.Parsed.(event.VerificationTransactionable); ok { - txn.TransactionID = transactionable.GetTransactionID() - } else { - txn.TransactionID = id.VerificationTransactionID(evt.ID) - } - if fromDevice, ok := evt.Content.Raw["from_device"]; ok { - txn.TheirDeviceID = id.DeviceID(fromDevice.(string)) - } - - // Send a cancellation event. - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownTransaction, "The transaction ID was not recognized.") - vh.activeTransactionsLock.Unlock() - return - } else { - vh.activeTransactionsLock.Unlock() - } - - logCtx := log.With(). - Stringer("transaction_step", txn.VerificationState). - Stringer("sender", evt.Sender) - if evt.RoomID != "" { - logCtx = logCtx. - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.ID) - } - callback(logCtx.Logger().WithContext(ctx), txn, evt) - } - } - - // Event handlers for the to-device verification events. - syncer.OnEventType(event.ToDeviceVerificationReady, wrapHandler(vh.onVerificationReady)) - syncer.OnEventType(event.ToDeviceVerificationStart, wrapHandler(vh.onVerificationStart)) - syncer.OnEventType(event.ToDeviceVerificationDone, wrapHandler(vh.onVerificationDone)) - syncer.OnEventType(event.ToDeviceVerificationCancel, wrapHandler(vh.onVerificationCancel)) - syncer.OnEventType(event.ToDeviceVerificationAccept, wrapHandler(vh.onVerificationAccept)) // SAS - syncer.OnEventType(event.ToDeviceVerificationKey, wrapHandler(vh.onVerificationKey)) // SAS - syncer.OnEventType(event.ToDeviceVerificationMAC, wrapHandler(vh.onVerificationMAC)) // SAS - - // Event handlers for the in-room verification events. - syncer.OnEventType(event.InRoomVerificationReady, wrapHandler(vh.onVerificationReady)) - syncer.OnEventType(event.InRoomVerificationStart, wrapHandler(vh.onVerificationStart)) - syncer.OnEventType(event.InRoomVerificationDone, wrapHandler(vh.onVerificationDone)) - syncer.OnEventType(event.InRoomVerificationCancel, wrapHandler(vh.onVerificationCancel)) - syncer.OnEventType(event.InRoomVerificationAccept, wrapHandler(vh.onVerificationAccept)) // SAS - syncer.OnEventType(event.InRoomVerificationKey, wrapHandler(vh.onVerificationKey)) // SAS - syncer.OnEventType(event.InRoomVerificationMAC, wrapHandler(vh.onVerificationMAC)) // SAS - - allTransactions, err := vh.store.GetAllVerificationTransactions(ctx) - for _, txn := range allTransactions { - vh.expireTransactionAt(txn.TransactionID, txn.ExpirationTime.Time) - } - return err -} - -// StartVerification starts an interactive verification flow with the given -// user via a to-device event. -func (vh *VerificationHelper) StartVerification(ctx context.Context, to id.UserID) (id.VerificationTransactionID, error) { - if len(vh.supportedMethods) == 0 { - return "", fmt.Errorf("no supported verification methods") - } - - txnID := id.NewVerificationTransactionID() - - devices, err := vh.mach.CryptoStore.GetDevices(ctx, to) - if err != nil { - return "", fmt.Errorf("failed to get devices for user: %w", err) - } else if len(devices) == 0 { - // HACK: we are doing this because the client doesn't wait until it has - // the devices before starting verification. - if keys, err := vh.mach.FetchKeys(ctx, []id.UserID{to}, true); err != nil { - return "", err - } else { - devices = keys[to] - } - } - - log := vh.getLog(ctx).With(). - Str("verification_action", "start verification"). - Stringer("transaction_id", txnID). - Stringer("to", to). - Any("device_ids", maps.Keys(devices)). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Sending verification request") - - now := time.Now() - content := &event.Content{ - Parsed: &event.VerificationRequestEventContent{ - ToDeviceVerificationEvent: event.ToDeviceVerificationEvent{TransactionID: txnID}, - FromDevice: vh.client.DeviceID, - Methods: vh.supportedMethods, - Timestamp: jsontime.UM(now), - }, - } - vh.expireTransactionAt(txnID, now.Add(time.Minute*10)) - - req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{to: {}}} - for deviceID := range devices { - if deviceID == vh.client.DeviceID { - // Don't ever send the event to the current device. We are likely - // trying to send a verification request to our other devices. - continue - } - - req.Messages[to][deviceID] = content - } - _, err = vh.client.SendToDevice(ctx, event.ToDeviceVerificationRequest, &req) - if err != nil { - return "", fmt.Errorf("failed to send verification request: %w", err) - } - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - return txnID, vh.store.SaveVerificationTransaction(ctx, VerificationTransaction{ - ExpirationTime: jsontime.UnixMilli{Time: time.Now().Add(time.Minute * 10)}, - VerificationState: VerificationStateRequested, - TransactionID: txnID, - TheirUserID: to, - SentToDeviceIDs: maps.Keys(devices), - }) -} - -// StartInRoomVerification starts an interactive verification flow with the -// given user in the given room. -func (vh *VerificationHelper) StartInRoomVerification(ctx context.Context, roomID id.RoomID, to id.UserID) (id.VerificationTransactionID, error) { - log := vh.getLog(ctx).With(). - Str("verification_action", "start in-room verification"). - Stringer("room_id", roomID). - Stringer("to", to). - Logger() - ctx = log.WithContext(ctx) - - log.Info().Msg("Sending verification request") - content := event.MessageEventContent{ - MsgType: event.MsgVerificationRequest, - Body: fmt.Sprintf("%s is requesting to verify your device, but your client does not support verification, so you may need to use a different verification method.", vh.client.UserID), - FromDevice: vh.client.DeviceID, - Methods: vh.supportedMethods, - To: to, - } - encryptedContent, err := vh.client.Crypto.Encrypt(ctx, roomID, event.EventMessage, &content) - if err != nil { - return "", fmt.Errorf("failed to encrypt verification request: %w", err) - } - resp, err := vh.client.SendMessageEvent(ctx, roomID, event.EventMessage, encryptedContent) - if err != nil { - return "", fmt.Errorf("failed to send verification request: %w", err) - } - - txnID := id.VerificationTransactionID(resp.EventID) - log.Info().Stringer("transaction_id", txnID).Msg("Got a transaction ID for the verification request") - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - return txnID, vh.store.SaveVerificationTransaction(ctx, VerificationTransaction{ - ExpirationTime: jsontime.UnixMilli{Time: time.Now().Add(time.Minute * 10)}, - RoomID: roomID, - VerificationState: VerificationStateRequested, - TransactionID: txnID, - TheirUserID: to, - }) -} - -// AcceptVerification accepts a verification request. The transaction ID should -// be the transaction ID of a verification request that was received via the -// VerificationRequested callback in [RequiredCallbacks]. -func (vh *VerificationHelper) AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error { - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - log := vh.getLog(ctx).With(). - Str("verification_action", "accept verification"). - Stringer("transaction_id", txnID). - Logger() - ctx = log.WithContext(ctx) - - txn, err := vh.store.GetVerificationTransaction(ctx, txnID) - if err != nil { - return err - } else if txn.VerificationState != VerificationStateRequested { - return fmt.Errorf("transaction is not in the requested state") - } - - supportedMethods := map[event.VerificationMethod]struct{}{} - for _, method := range txn.TheirSupportedMethods { - switch method { - case event.VerificationMethodSAS: - if slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) { - supportedMethods[event.VerificationMethodSAS] = struct{}{} - } - case event.VerificationMethodQRCodeShow: - if slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) { - supportedMethods[event.VerificationMethodQRCodeScan] = struct{}{} - supportedMethods[event.VerificationMethodReciprocate] = struct{}{} - } - case event.VerificationMethodQRCodeScan: - if slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeShow) { - supportedMethods[event.VerificationMethodQRCodeShow] = struct{}{} - supportedMethods[event.VerificationMethodReciprocate] = struct{}{} - } - } - } - - log.Info().Any("methods", maps.Keys(supportedMethods)).Msg("Sending ready event") - readyEvt := &event.VerificationReadyEventContent{ - FromDevice: vh.client.DeviceID, - Methods: maps.Keys(supportedMethods), - } - err = vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationReady, readyEvt) - if err != nil { - return err - } - txn.VerificationState = VerificationStateReady - - supportsSAS := slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) && - slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodSAS) - supportsReciprocate := slices.Contains(vh.supportedMethods, event.VerificationMethodReciprocate) && - slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodReciprocate) - supportsScanQRCode := supportsReciprocate && - slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) && - slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodQRCodeShow) - - qrCode, err := vh.generateQRCode(ctx, &txn) - if err != nil { - return err - } - vh.verificationReady(ctx, txn.TransactionID, txn.TheirDeviceID, supportsSAS, supportsScanQRCode, qrCode) - return vh.store.SaveVerificationTransaction(ctx, txn) -} - -// DismissVerification dismisses the verification request with the given -// transaction ID. The transaction ID should be one received via the -// VerificationRequested callback in [RequiredCallbacks] or the -// [StartVerification] or [StartInRoomVerification] functions. -func (vh *VerificationHelper) DismissVerification(ctx context.Context, txnID id.VerificationTransactionID) error { - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - return vh.store.DeleteVerification(ctx, txnID) -} - -// DismissVerification cancels the verification request with the given -// transaction ID. The transaction ID should be one received via the -// VerificationRequested callback in [RequiredCallbacks] or the -// [StartVerification] or [StartInRoomVerification] functions. -func (vh *VerificationHelper) CancelVerification(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) error { - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - txn, err := vh.store.GetVerificationTransaction(ctx, txnID) - if err != nil { - return err - } - log := vh.getLog(ctx).With(). - Str("verification_action", "cancel verification"). - Stringer("transaction_id", txnID). - Str("code", string(code)). - Str("reason", reason). - Logger() - ctx = log.WithContext(ctx) - - log.Info().Msg("Sending cancellation event") - cancelEvt := &event.VerificationCancelEventContent{Code: code, Reason: reason} - if len(txn.RoomID) > 0 { - // Sending the cancellation event to the room. - err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationCancel, cancelEvt) - if err != nil { - return fmt.Errorf("failed to send cancel verification event (code: %s, reason: %s): %w", code, reason, err) - } - } else { - cancelEvt.SetTransactionID(txn.TransactionID) - req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{ - txn.TheirUserID: {}, - }} - if len(txn.TheirDeviceID) > 0 { - // Send the cancellation event to only the device that accepted the - // verification request. All of the other devices already received a - // cancellation event with code "m.acceped". - req.Messages[txn.TheirUserID][txn.TheirDeviceID] = &event.Content{Parsed: cancelEvt} - } else { - // Send the cancellation event to all of the devices that we sent the - // request to. - for _, deviceID := range txn.SentToDeviceIDs { - if deviceID != vh.client.DeviceID { - req.Messages[txn.TheirUserID][deviceID] = &event.Content{Parsed: cancelEvt} - } - } - } - _, err := vh.client.SendToDevice(ctx, event.ToDeviceVerificationCancel, &req) - if err != nil { - return fmt.Errorf("failed to send m.key.verification.cancel event to %v: %w", maps.Keys(req.Messages[txn.TheirUserID]), err) - } - } - return vh.store.DeleteVerification(ctx, txn.TransactionID) -} - -// sendVerificationEvent sends a verification event to the other user's device -// setting the m.relates_to or transaction ID as necessary. -// -// Notes: -// -// - "content" must implement [event.Relatable] and -// [event.VerificationTransactionable]. -// - evtType can be either the to-device or in-room version of the event type -// as it is always stringified. -func (vh *VerificationHelper) sendVerificationEvent(ctx context.Context, txn VerificationTransaction, evtType event.Type, content any) error { - if txn.RoomID != "" { - content.(event.Relatable).SetRelatesTo(&event.RelatesTo{Type: event.RelReference, EventID: id.EventID(txn.TransactionID)}) - _, err := vh.client.SendMessageEvent(ctx, txn.RoomID, evtType, &event.Content{ - Parsed: content, - }) - if err != nil { - return fmt.Errorf("failed to send %s event to %s: %w", evtType.String(), txn.RoomID, err) - } - } else { - content.(event.VerificationTransactionable).SetTransactionID(txn.TransactionID) - req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{ - txn.TheirUserID: { - txn.TheirDeviceID: &event.Content{Parsed: content}, - }, - }} - _, err := vh.client.SendToDevice(ctx, evtType, &req) - if err != nil { - return fmt.Errorf("failed to send %s event to %s: %w", evtType.String(), txn.TheirDeviceID, err) - } - } - return nil -} - -// cancelVerificationTxn cancels a verification transaction with the given code -// and reason. It always returns an error, which is the formatted error message -// (this is allows the caller to return the result of this function call -// directly to expose the error to its caller). -// -// Must always be called with the activeTransactionsLock held. -func (vh *VerificationHelper) cancelVerificationTxn(ctx context.Context, txn VerificationTransaction, code event.VerificationCancelCode, reasonFmtStr string, fmtArgs ...any) error { - reason := fmt.Errorf(reasonFmtStr, fmtArgs...).Error() - log := vh.getLog(ctx).With(). - Stringer("transaction_id", txn.TransactionID). - Str("code", string(code)). - Str("reason", reason). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Sending cancellation event") - cancelEvt := &event.VerificationCancelEventContent{Code: code, Reason: reason} - err := vh.sendVerificationEvent(ctx, txn, event.InRoomVerificationCancel, cancelEvt) - if err != nil { - log.Err(err).Msg("failed to send cancellation event") - return fmt.Errorf("failed to send cancel verification event (code: %s, reason: %s): %w", code, reason, err) - } - if err = vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { - log.Err(err).Msg("deleting verification failed") - } - vh.verificationCancelledCallback(ctx, txn.TransactionID, code, reason) - return fmt.Errorf("verification cancelled (code: %s): %s", code, reason) -} - -func (vh *VerificationHelper) onVerificationRequest(ctx context.Context, evt *event.Event) { - logCtx := vh.getLog(ctx).With(). - Str("verification_action", "verification request"). - Stringer("sender", evt.Sender) - if evt.RoomID != "" { - logCtx = logCtx. - Stringer("room_id", evt.RoomID). - Stringer("event_id", evt.ID) - } - log := logCtx.Logger() - - var verificationRequest *event.VerificationRequestEventContent - switch evt.Type { - case event.EventMessage: - to := evt.Content.AsMessage().To - if to != vh.client.UserID { - log.Info().Stringer("to", to).Msg("Ignoring verification request for another user") - return - } - - verificationRequest = event.VerificationRequestEventContentFromMessage(evt) - case event.ToDeviceVerificationRequest: - verificationRequest = evt.Content.AsVerificationRequest() - default: - log.Warn().Str("type", evt.Type.Type).Msg("Ignoring verification request of unknown type") - return - } - - if verificationRequest.FromDevice == vh.client.DeviceID { - log.Warn().Msg("Ignoring verification request from our own device. Why did it even get sent to us?") - return - } - - if verificationRequest.Timestamp.Add(10 * time.Minute).Before(time.Now()) { - log.Warn().Msg("Ignoring verification request that is over ten minutes old") - return - } - - if len(verificationRequest.TransactionID) == 0 { - log.Warn().Msg("Ignoring verification request without a transaction ID") - return - } - - log = log.With(). - Any("requested_methods", verificationRequest.Methods). - Stringer("transaction_id", verificationRequest.TransactionID). - Stringer("from_device", verificationRequest.FromDevice). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Received verification request") - - // Check if we support any of the methods listed - var supportsAnyMethod bool - for _, method := range verificationRequest.Methods { - switch method { - case event.VerificationMethodSAS: - supportsAnyMethod = slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) - case event.VerificationMethodQRCodeScan: - supportsAnyMethod = slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeShow) && - slices.Contains(verificationRequest.Methods, event.VerificationMethodReciprocate) - case event.VerificationMethodQRCodeShow: - supportsAnyMethod = slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) && - slices.Contains(verificationRequest.Methods, event.VerificationMethodReciprocate) - } - if supportsAnyMethod { - break - } - } - if !supportsAnyMethod { - log.Warn().Msg("Ignoring verification request that doesn't have any methods we support") - return - } - - vh.activeTransactionsLock.Lock() - newTxn := VerificationTransaction{ - ExpirationTime: jsontime.UnixMilli{Time: verificationRequest.Timestamp.Add(time.Minute * 10)}, - RoomID: evt.RoomID, - VerificationState: VerificationStateRequested, - TransactionID: verificationRequest.TransactionID, - TheirDeviceID: verificationRequest.FromDevice, - TheirUserID: evt.Sender, - TheirSupportedMethods: verificationRequest.Methods, - } - if txn, err := vh.store.FindVerificationTransactionForUserDevice(ctx, evt.Sender, verificationRequest.FromDevice); err != nil && !errors.Is(err, ErrUnknownVerificationTransaction) { - log.Err(err).Stringer("sender", evt.Sender).Stringer("device_id", verificationRequest.FromDevice).Msg("failed to find verification transaction") - vh.activeTransactionsLock.Unlock() - return - } else if !errors.Is(err, ErrUnknownVerificationTransaction) { - if txn.TransactionID == verificationRequest.TransactionID { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "received a new verification request for the same transaction ID") - } else { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "received multiple verification requests from the same device") - vh.cancelVerificationTxn(ctx, newTxn, event.VerificationCancelCodeUnexpectedMessage, "received multiple verification requests from the same device") - } - vh.activeTransactionsLock.Unlock() - return - } - if err := vh.store.SaveVerificationTransaction(ctx, newTxn); err != nil { - log.Err(err).Msg("failed to save verification transaction") - } - vh.activeTransactionsLock.Unlock() - - vh.expireTransactionAt(verificationRequest.TransactionID, verificationRequest.Timestamp.Add(time.Minute*10)) - vh.verificationRequested(ctx, verificationRequest.TransactionID, evt.Sender, verificationRequest.FromDevice) -} - -func (vh *VerificationHelper) expireTransactionAt(txnID id.VerificationTransactionID, expiresAt time.Time) { - go func() { - time.Sleep(time.Until(expiresAt)) - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - txn, err := vh.store.GetVerificationTransaction(context.Background(), txnID) - if err == ErrUnknownVerificationTransaction { - // Already deleted, nothing to expire - return - } else if err != nil { - vh.getLog(context.Background()).Err(err).Msg("failed to get verification transaction to expire") - } else { - vh.cancelVerificationTxn(context.Background(), txn, event.VerificationCancelCodeTimeout, "verification timed out") - } - }() -} - -func (vh *VerificationHelper) onVerificationReady(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - log := vh.getLog(ctx).With(). - Str("verification_action", "verification ready"). - Logger() - ctx = log.WithContext(ctx) - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - if txn.VerificationState != VerificationStateRequested { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "verification ready event received for a transaction that is not in the requested state") - return - } - - readyEvt := evt.Content.AsVerificationReady() - - // Update the transaction state. - txn.VerificationState = VerificationStateReady - txn.TheirDeviceID = readyEvt.FromDevice - txn.TheirSupportedMethods = readyEvt.Methods - - log.Info(). - Stringer("their_device_id", txn.TheirDeviceID). - Any("their_supported_methods", txn.TheirSupportedMethods). - Msg("Received verification ready event") - - // If we sent this verification request, send cancellations to all of the - // other devices. - if len(txn.SentToDeviceIDs) > 0 { - content := &event.Content{ - Parsed: &event.VerificationCancelEventContent{ - ToDeviceVerificationEvent: event.ToDeviceVerificationEvent{TransactionID: txn.TransactionID}, - Code: event.VerificationCancelCodeAccepted, - Reason: "The verification was accepted on another device.", - }, - } - req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{txn.TheirUserID: {}}} - for _, deviceID := range txn.SentToDeviceIDs { - if deviceID == txn.TheirDeviceID || deviceID == vh.client.DeviceID { - // Don't ever send a cancellation to the device that accepted - // the request or to our own device (which can happen if this - // is a self-verification). - continue - } - - req.Messages[txn.TheirUserID][deviceID] = content - } - _, err := vh.client.SendToDevice(ctx, event.ToDeviceVerificationCancel, &req) - if err != nil { - log.Warn().Err(err).Msg("Failed to send cancellation requests") - } - } - - supportsSAS := slices.Contains(vh.supportedMethods, event.VerificationMethodSAS) && - slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodSAS) - supportsReciprocate := slices.Contains(vh.supportedMethods, event.VerificationMethodReciprocate) && - slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodReciprocate) - supportsScanQRCode := supportsReciprocate && - slices.Contains(vh.supportedMethods, event.VerificationMethodQRCodeScan) && - slices.Contains(txn.TheirSupportedMethods, event.VerificationMethodQRCodeShow) - - qrCode, err := vh.generateQRCode(ctx, &txn) - if err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to generate QR code: %w", err) - return - } - - vh.verificationReady(ctx, txn.TransactionID, txn.TheirDeviceID, supportsSAS, supportsScanQRCode, qrCode) - - if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeInternalError, "failed to save verification transaction: %w", err) - } -} - -func (vh *VerificationHelper) onVerificationStart(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - startEvt := evt.Content.AsVerificationStart() - log := vh.getLog(ctx).With(). - Str("verification_action", "verification start"). - Str("method", string(startEvt.Method)). - Stringer("their_device_id", txn.TheirDeviceID). - Any("their_supported_methods", txn.TheirSupportedMethods). - Bool("started_by_us", txn.StartedByUs). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Received verification start event") - - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - if txn.VerificationState == VerificationStateSASStarted || txn.VerificationState == VerificationStateOurQRScanned || txn.VerificationState == VerificationStateTheirQRScanned { - // We might have sent the event, and they also sent an event. - if txn.StartEventContent == nil || !txn.StartedByUs { - // We didn't sent a start event yet, so we have gotten ourselves - // into a bad state. They've either sent two start events, or we - // have gone on to a new state. - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "got repeat start event from other user") - return - } - - // Otherwise, we need to implement the following algorithm from Section - // 11.12.2.1 of the Spec: - // https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework - // - // If Alice's and Bob's clients both send an m.key.verification.start - // message, and both specify the same verification method, then the - // m.key.verification.start message sent by the user whose ID is the - // lexicographically largest user ID should be ignored, and the - // situation should be treated the same as if only the user with the - // lexicographically smallest user ID had sent the - // m.key.verification.start message. In the case where the user IDs are - // the same (that is, when a user is verifying their own device), then - // the device IDs should be compared instead. If the two - // m.key.verification.start messages do not specify the same - // verification method, then the verification should be cancelled with - // a code of m.unexpected_message. - - if txn.StartEventContent.Method != startEvt.Method { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "the start events have different verification methods") - return - } - - if txn.TheirUserID < vh.client.UserID || (txn.TheirUserID == vh.client.UserID && txn.TheirDeviceID < vh.client.DeviceID) { - log.Debug().Msg("Using their start event instead of ours because they are alphabetically before us") - txn.StartedByUs = false - txn.StartEventContent = startEvt - } - } else if txn.VerificationState != VerificationStateReady { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "got start event for transaction that is not in ready state") - return - } else { - txn.StartEventContent = startEvt - } - - switch startEvt.Method { - case event.VerificationMethodSAS: - log.Info().Msg("Received SAS start event") - txn.VerificationState = VerificationStateSASStarted - if err := vh.onVerificationStartSAS(ctx, txn, evt); err != nil { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUser, "failed to handle SAS verification start: %w", err) - } - case event.VerificationMethodReciprocate: - log.Info().Msg("Received reciprocate start event") - if !bytes.Equal(txn.QRCodeSharedSecret, txn.StartEventContent.Secret) { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeKeyMismatch, "reciprocated shared secret does not match") - return - } - txn.VerificationState = VerificationStateOurQRScanned - vh.qrCodeScanned(ctx, txn.TransactionID) - if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { - log.Err(err).Msg("failed to save verification transaction") - } - default: - // Note that we should never get m.qr_code.show.v1 or m.qr_code.scan.v1 - // here, since the start command for scanning and showing QR codes - // should be of type m.reciprocate.v1. - log.Error().Str("method", string(txn.StartEventContent.Method)).Msg("Unsupported verification method in start event") - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnknownMethod, "unknown method %s", txn.StartEventContent.Method) - } -} - -func (vh *VerificationHelper) onVerificationDone(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - log := vh.getLog(ctx).With(). - Str("verification_action", "done"). - Stringer("transaction_id", txn.TransactionID). - Bool("sent_our_done", txn.SentOurDone). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Verification done") - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - if !slices.Contains([]VerificationState{ - VerificationStateTheirQRScanned, VerificationStateOurQRScanned, VerificationStateSASMACExchanged, - }, txn.VerificationState) { - vh.cancelVerificationTxn(ctx, txn, event.VerificationCancelCodeUnexpectedMessage, "got done event for transaction that is not in QR-scanned or MAC-exchanged state") - return - } - - txn.ReceivedTheirDone = true - if txn.SentOurDone { - if err := vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { - log.Err(err).Msg("Delete verification failed") - } - vh.verificationDone(ctx, txn.TransactionID, txn.StartEventContent.Method) - } else if err := vh.store.SaveVerificationTransaction(ctx, txn); err != nil { - log.Err(err).Msg("failed to save verification transaction") - } -} - -func (vh *VerificationHelper) onVerificationCancel(ctx context.Context, txn VerificationTransaction, evt *event.Event) { - cancelEvt := evt.Content.AsVerificationCancel() - log := vh.getLog(ctx).With(). - Str("verification_action", "cancel"). - Stringer("transaction_id", txn.TransactionID). - Str("cancel_code", string(cancelEvt.Code)). - Str("reason", cancelEvt.Reason). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Verification was cancelled") - vh.activeTransactionsLock.Lock() - defer vh.activeTransactionsLock.Unlock() - - // Element (and at least the old desktop client) send cancellation events - // when the user rejects the verification request. This is really dumb, - // because they should just instead ignore the request and not send a - // cancellation. - // - // The above behavior causes a problem with the other devices that we sent - // the verification request to because they don't know that the request was - // cancelled. - // - // As a workaround, if we receive a cancellation event to a transaction - // that is currently in the REQUESTED state, then we will send - // cancellations to all of the devices that we sent the request to. This - // will ensure that all of the clients know that the request was cancelled. - if txn.VerificationState == VerificationStateRequested && len(txn.SentToDeviceIDs) > 0 { - content := &event.Content{ - Parsed: &event.VerificationCancelEventContent{ - ToDeviceVerificationEvent: event.ToDeviceVerificationEvent{TransactionID: txn.TransactionID}, - Code: event.VerificationCancelCodeUser, - Reason: "The verification was rejected from another device.", - }, - } - req := mautrix.ReqSendToDevice{Messages: map[id.UserID]map[id.DeviceID]*event.Content{txn.TheirUserID: {}}} - for _, deviceID := range txn.SentToDeviceIDs { - req.Messages[txn.TheirUserID][deviceID] = content - } - _, err := vh.client.SendToDevice(ctx, event.ToDeviceVerificationCancel, &req) - if err != nil { - log.Warn().Err(err).Msg("Failed to send cancellation requests") - } - } - - if err := vh.store.DeleteVerification(ctx, txn.TransactionID); err != nil { - log.Err(err).Msg("Delete verification failed") - } - vh.verificationCancelledCallback(ctx, txn.TransactionID, cancelEvt.Code, cancelEvt.Reason) -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go deleted file mode 100644 index 5e3f146b..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_crosssign_test.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper_test - -import ( - "context" - "fmt" - "testing" - - "github.com/rs/zerolog/log" // zerolog-allow-global-log - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func TestCrossSignVerification_ScanQRAndConfirmScan(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - testCases := []struct { - sendingScansQR bool // false indicates that receiving device should emulate a scan - }{ - {false}, - {true}, - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("sendingScansQR=%t", tc.sendingScansQR), func(t *testing.T) { - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginAliceBob(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - // Generate cross-signing keys for both users - _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - _, _, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - - // Fetch each other's keys - sendingMachine.FetchKeys(ctx, []id.UserID{bobUserID}, true) - receivingMachine.FetchKeys(ctx, []id.UserID{aliceUserID}, true) - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, bobUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) - - receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, receivingShownQRCode) - sendingShownQRCode := sendingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, sendingShownQRCode) - - if tc.sendingScansQR { - // Emulate scanning the QR code shown by the receiving device - // on the sending device. - err := sendingHelper.HandleScannedQRData(ctx, receivingShownQRCode.Bytes()) - require.NoError(t, err) - - // Ensure that the receiving device received a verification - // start event and a verification done event. - receivingInbox := ts.DeviceInbox[bobUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 2) - - startEvt := receivingInbox[0].Content.AsVerificationStart() - assert.Equal(t, txnID, startEvt.TransactionID) - assert.Equal(t, sendingDeviceID, startEvt.FromDevice) - assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) - assert.EqualValues(t, receivingShownQRCode.SharedSecret, startEvt.Secret) - - doneEvt := receivingInbox[1].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - // Handle the start and done events on the receiving client and - // confirm the scan. - ts.DispatchToDevice(t, ctx, receivingClient) - - // Ensure that the receiving device detected that its QR code - // was scanned. - assert.True(t, receivingCallbacks.WasOurQRCodeScanned(txnID)) - err = receivingHelper.ConfirmQRCodeScanned(ctx, txnID) - require.NoError(t, err) - - // Ensure that the sending device received a verification done - // event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - require.Len(t, sendingInbox, 1) - doneEvt = sendingInbox[0].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - ts.DispatchToDevice(t, ctx, sendingClient) - } else { // receiving scans QR - // Emulate scanning the QR code shown by the sending device on - // the receiving device. - err := receivingHelper.HandleScannedQRData(ctx, sendingShownQRCode.Bytes()) - require.NoError(t, err) - - // Ensure that the sending device received a verification - // start event and a verification done event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 2) - - startEvt := sendingInbox[0].Content.AsVerificationStart() - assert.Equal(t, txnID, startEvt.TransactionID) - assert.Equal(t, receivingDeviceID, startEvt.FromDevice) - assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) - assert.EqualValues(t, sendingShownQRCode.SharedSecret, startEvt.Secret) - - doneEvt := sendingInbox[1].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - // Handle the start and done events on the receiving client and - // confirm the scan. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Ensure that the sending device detected that its QR code was - // scanned. - assert.True(t, sendingCallbacks.WasOurQRCodeScanned(txnID)) - err = sendingHelper.ConfirmQRCodeScanned(ctx, txnID) - require.NoError(t, err) - - // Ensure that the receiving device received a verification - // done event. - receivingInbox := ts.DeviceInbox[bobUserID][receivingDeviceID] - require.Len(t, receivingInbox, 1) - doneEvt = receivingInbox[0].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - ts.DispatchToDevice(t, ctx, receivingClient) - } - - // Ensure that both devices have marked the verification as done. - assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) - assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) - - bobTrustsAlice, err := receivingMachine.IsUserTrusted(ctx, aliceUserID) - assert.NoError(t, err) - assert.True(t, bobTrustsAlice) - aliceTrustsBob, err := sendingMachine.IsUserTrusted(ctx, bobUserID) - assert.NoError(t, err) - assert.True(t, aliceTrustsBob) - }) - } -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go deleted file mode 100644 index 256dfd0d..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationhelper_qr_self_test.go +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright (c) 2024 Sumner Evans -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package verificationhelper_test - -import ( - "context" - "fmt" - "testing" - - "github.com/rs/zerolog/log" // zerolog-allow-global-log - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/verificationhelper" - "maunium.net/go/mautrix/event" -) - -func TestSelfVerification_Accept_QRContents(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - testCases := []struct { - sendingGeneratedCrossSigningKeys bool - receivingGeneratedCrossSigningKeys bool - expectedAcceptError string - }{ - {true, false, ""}, - {false, true, ""}, - {false, false, "failed to get own cross-signing master public key"}, - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("sendingGenerated=%t receivingGenerated=%t err=%s", tc.sendingGeneratedCrossSigningKeys, tc.receivingGeneratedCrossSigningKeys, tc.expectedAcceptError), func(t *testing.T) { - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - var sendingRecoveryKey, receivingRecoveryKey string - var sendingCrossSigningKeysCache, receivingCrossSigningKeysCache *crypto.CrossSigningKeysCache - - if tc.sendingGeneratedCrossSigningKeys { - sendingRecoveryKey, sendingCrossSigningKeysCache, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - assert.NotEmpty(t, sendingRecoveryKey) - assert.NotNil(t, sendingCrossSigningKeysCache) - } - - if tc.receivingGeneratedCrossSigningKeys { - receivingRecoveryKey, receivingCrossSigningKeysCache, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - assert.NotEmpty(t, receivingRecoveryKey) - assert.NotNil(t, receivingCrossSigningKeysCache) - } - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - - err = receivingHelper.AcceptVerification(ctx, txnID) - if tc.expectedAcceptError != "" { - assert.ErrorContains(t, err, tc.expectedAcceptError) - return - } else { - require.NoError(t, err) - } - - ts.DispatchToDevice(t, ctx, sendingClient) - - receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, receivingShownQRCode) - assert.NotEmpty(t, receivingShownQRCode.SharedSecret) - assert.Equal(t, txnID, receivingShownQRCode.TransactionID) - - sendingShownQRCode := sendingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, sendingShownQRCode) - assert.NotEmpty(t, sendingShownQRCode.SharedSecret) - assert.Equal(t, txnID, sendingShownQRCode.TransactionID) - - // See the spec for the QR Code format: - // https://spec.matrix.org/v1.10/client-server-api/#qr-code-format - if tc.receivingGeneratedCrossSigningKeys { - masterKeyBytes := exerrors.Must(receivingMachine.GetOwnCrossSigningPublicKeys(ctx)).MasterKey.Bytes() - - // The receiving device should have shown a QR Code with - // trusted mode - assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyTrusted, receivingShownQRCode.Mode) - assert.EqualValues(t, masterKeyBytes, receivingShownQRCode.Key1) // master key - assert.EqualValues(t, sendingMachine.OwnIdentity().SigningKey.Bytes(), receivingShownQRCode.Key2) // other device key - - // The sending device should have shown a QR code with - // untrusted mode. - assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyUntrusted, sendingShownQRCode.Mode) - assert.EqualValues(t, sendingMachine.OwnIdentity().SigningKey.Bytes(), sendingShownQRCode.Key1) // own device key - assert.EqualValues(t, masterKeyBytes, sendingShownQRCode.Key2) // master key - } else if tc.sendingGeneratedCrossSigningKeys { - masterKeyBytes := exerrors.Must(sendingMachine.GetOwnCrossSigningPublicKeys(ctx)).MasterKey.Bytes() - - // The receiving device should have shown a QR code with - // untrusted mode - assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyUntrusted, receivingShownQRCode.Mode) - assert.EqualValues(t, receivingMachine.OwnIdentity().SigningKey.Bytes(), receivingShownQRCode.Key1) // own device key - assert.EqualValues(t, masterKeyBytes, receivingShownQRCode.Key2) // master key - - // The sending device should have shown a QR code with trusted - // mode. - assert.Equal(t, verificationhelper.QRCodeModeSelfVerifyingMasterKeyTrusted, sendingShownQRCode.Mode) - assert.EqualValues(t, masterKeyBytes, sendingShownQRCode.Key1) // master key - assert.EqualValues(t, receivingMachine.OwnIdentity().SigningKey.Bytes(), sendingShownQRCode.Key2) // other device key - } - }) - } -} - -func TestSelfVerification_ScanQRAndConfirmScan(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - testCases := []struct { - sendingGeneratedCrossSigningKeys bool - sendingScansQR bool // false indicates that receiving device should emulate a scan - }{ - {false, false}, - {false, true}, - {true, false}, - {true, true}, - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("sendingGeneratedCrossSigningKeys=%t sendingScansQR=%t", tc.sendingGeneratedCrossSigningKeys, tc.sendingScansQR), func(t *testing.T) { - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - if tc.sendingGeneratedCrossSigningKeys { - _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - } else { - _, _, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - } - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) - - receivingShownQRCode := receivingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, receivingShownQRCode) - sendingShownQRCode := sendingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, sendingShownQRCode) - - if tc.sendingScansQR { - // Emulate scanning the QR code shown by the receiving device - // on the sending device. - err := sendingHelper.HandleScannedQRData(ctx, receivingShownQRCode.Bytes()) - require.NoError(t, err) - - // Ensure that the receiving device received a verification - // start event and a verification done event. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 2) - - startEvt := receivingInbox[0].Content.AsVerificationStart() - assert.Equal(t, txnID, startEvt.TransactionID) - assert.Equal(t, sendingDeviceID, startEvt.FromDevice) - assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) - assert.EqualValues(t, receivingShownQRCode.SharedSecret, startEvt.Secret) - - doneEvt := receivingInbox[1].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - // Handle the start and done events on the receiving client and - // confirm the scan. - ts.DispatchToDevice(t, ctx, receivingClient) - - // Ensure that the receiving device detected that its QR code - // was scanned. - assert.True(t, receivingCallbacks.WasOurQRCodeScanned(txnID)) - err = receivingHelper.ConfirmQRCodeScanned(ctx, txnID) - require.NoError(t, err) - - // Ensure that the sending device received a verification done - // event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - require.Len(t, sendingInbox, 1) - doneEvt = sendingInbox[0].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - ts.DispatchToDevice(t, ctx, sendingClient) - } else { // receiving scans QR - // Emulate scanning the QR code shown by the sending device on - // the receiving device. - err := receivingHelper.HandleScannedQRData(ctx, sendingShownQRCode.Bytes()) - require.NoError(t, err) - - // Ensure that the sending device received a verification - // start event and a verification done event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 2) - - startEvt := sendingInbox[0].Content.AsVerificationStart() - assert.Equal(t, txnID, startEvt.TransactionID) - assert.Equal(t, receivingDeviceID, startEvt.FromDevice) - assert.Equal(t, event.VerificationMethodReciprocate, startEvt.Method) - assert.EqualValues(t, sendingShownQRCode.SharedSecret, startEvt.Secret) - - doneEvt := sendingInbox[1].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - // Handle the start and done events on the receiving client and - // confirm the scan. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Ensure that the sending device detected that its QR code was - // scanned. - assert.True(t, sendingCallbacks.WasOurQRCodeScanned(txnID)) - err = sendingHelper.ConfirmQRCodeScanned(ctx, txnID) - require.NoError(t, err) - - // Ensure that the receiving device received a verification - // done event. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - require.Len(t, receivingInbox, 1) - doneEvt = receivingInbox[0].Content.AsVerificationDone() - assert.Equal(t, txnID, doneEvt.TransactionID) - - ts.DispatchToDevice(t, ctx, receivingClient) - } - - // Ensure that both devices have marked the verification as done. - assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) - assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) - }) - } -} - -func TestSelfVerification_ScanQRTransactionIDCorrupted(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) - - receivingShownQRCodeBytes := receivingCallbacks.GetQRCodeShown(txnID).Bytes() - sendingShownQRCodeBytes := sendingCallbacks.GetQRCodeShown(txnID).Bytes() - - // Corrupt the QR codes (the 20th byte should be in the transaction ID) - receivingShownQRCodeBytes[20]++ - sendingShownQRCodeBytes[20]++ - - // Emulate scanning the QR code shown by the receiving device - // on the sending device. - err = sendingHelper.HandleScannedQRData(ctx, receivingShownQRCodeBytes) - assert.ErrorContains(t, err, "unknown transaction ID") - - // Emulate scanning the QR code shown by the sending device on - // the receiving device. - err = receivingHelper.HandleScannedQRData(ctx, sendingShownQRCodeBytes) - assert.ErrorContains(t, err, "unknown transaction ID") -} - -func TestSelfVerification_ScanQRKeyCorrupted(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - testCases := []struct { - sendingGeneratedCrossSigningKeys bool - sendingScansQR bool // false indicates that receiving device should emulate a scan - corruptByte int - expectedError string - }{ - // The 50th byte should be in the first key - {false, false, 50, "the other device's key is not what we expected"}, // receiver scans sender QR code, sender doesn't trust the master key => mode 0x02 => key1 == sender device key - {false, true, 50, "the master key does not match"}, // sender scans receiver QR code, receiver trusts the master key => mode 0x01 => key1 == master key - {true, false, 50, "the master key does not match"}, // receiver scans sender QR code, sender trusts the master key => mode 0x01 => key1 == master key - {true, true, 50, "the other device's key is not what we expected"}, // sender scans receiver QR Code, receiver doesn't trust the master key => mode 0x02 => key1 == receiver device key - // The 100th byte should be in the second key - {false, false, 100, "the master key does not match"}, // receiver scans sender QR code, sender doesn't trust the master key => mode 0x02 => key2 == master key - {false, true, 100, "the other device has the wrong key for this device"}, // sender scans receiver QR code, receiver trusts the master key => mode 0x01 => key2 == sender device key - {true, false, 100, "the other device has the wrong key for this device"}, // receiver scans sender QR code, sender trusts the master key => mode 0x01 => key2 == receiver device key - {true, true, 100, "the master key does not match"}, // sender scans receiver QR Code, receiver doesn't trust the master key => mode 0x02 => key2 == master key - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("sendingGeneratedCrossSigningKeys=%t sendingScansQR=%t corrupt=%d", tc.sendingGeneratedCrossSigningKeys, tc.sendingScansQR, tc.corruptByte), func(t *testing.T) { - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - if tc.sendingGeneratedCrossSigningKeys { - _, _, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - } else { - _, _, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - } - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) - - receivingShownQRCodeBytes := receivingCallbacks.GetQRCodeShown(txnID).Bytes() - sendingShownQRCodeBytes := sendingCallbacks.GetQRCodeShown(txnID).Bytes() - - // Corrupt the QR codes - receivingShownQRCodeBytes[tc.corruptByte]++ - sendingShownQRCodeBytes[tc.corruptByte]++ - - if tc.sendingScansQR { - // Emulate scanning the QR code shown by the receiving device - // on the sending device. - err := sendingHelper.HandleScannedQRData(ctx, receivingShownQRCodeBytes) - assert.ErrorContains(t, err, tc.expectedError) - - // Ensure that the receiving device received a cancellation. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - ts.DispatchToDevice(t, ctx, receivingClient) - cancellation := receivingCallbacks.GetVerificationCancellation(txnID) - require.NotNil(t, cancellation) - assert.Equal(t, event.VerificationCancelCodeKeyMismatch, cancellation.Code) - assert.Equal(t, tc.expectedError, cancellation.Reason) - } else { // receiving scans QR - // Emulate scanning the QR code shown by the sending device on - // the receiving device. - err := receivingHelper.HandleScannedQRData(ctx, sendingShownQRCodeBytes) - assert.ErrorContains(t, err, tc.expectedError) - - // Ensure that the sending device received a cancellation. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - ts.DispatchToDevice(t, ctx, sendingClient) - cancellation := sendingCallbacks.GetVerificationCancellation(txnID) - require.NotNil(t, cancellation) - assert.Equal(t, event.VerificationCancelCodeKeyMismatch, cancellation.Code) - assert.Equal(t, tc.expectedError, cancellation.Reason) - } - }) - } -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go deleted file mode 100644 index 283eca84..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationhelper_sas_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package verificationhelper_test - -import ( - "context" - "fmt" - "testing" - - "github.com/rs/zerolog/log" // zerolog-allow-global-log - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/exp/maps" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func TestVerification_SAS(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - testCases := []struct { - sendingGeneratedCrossSigningKeys bool - sendingStartsSAS bool - sendingConfirmsFirst bool - }{ - {true, true, true}, - {true, true, false}, - {true, false, true}, - {true, false, false}, - {false, true, true}, - {false, true, false}, - {false, false, true}, - {false, false, false}, - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("sendingGenerated=%t sendingStartsSAS=%t sendingConfirmsFirst=%t", tc.sendingGeneratedCrossSigningKeys, tc.sendingStartsSAS, tc.sendingConfirmsFirst), func(t *testing.T) { - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - var sendingRecoveryKey, receivingRecoveryKey string - var sendingCrossSigningKeysCache, receivingCrossSigningKeysCache *crypto.CrossSigningKeysCache - - if tc.sendingGeneratedCrossSigningKeys { - sendingRecoveryKey, sendingCrossSigningKeysCache, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - assert.NotEmpty(t, sendingRecoveryKey) - assert.NotNil(t, sendingCrossSigningKeysCache) - } else { - receivingRecoveryKey, receivingCrossSigningKeysCache, err = receivingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - assert.NotEmpty(t, receivingRecoveryKey) - assert.NotNil(t, receivingCrossSigningKeysCache) - } - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) - - // Test that the start event is correct - var startEvt *event.VerificationStartEventContent - if tc.sendingStartsSAS { - err = sendingHelper.StartSAS(ctx, txnID) - require.NoError(t, err) - - // Ensure that the receiving device received a verification - // start event. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - startEvt = receivingInbox[0].Content.AsVerificationStart() - assert.Equal(t, sendingDeviceID, startEvt.FromDevice) - } else { - err = receivingHelper.StartSAS(ctx, txnID) - require.NoError(t, err) - - // Ensure that the receiving device received a verification - // start event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - startEvt = sendingInbox[0].Content.AsVerificationStart() - assert.Equal(t, receivingDeviceID, startEvt.FromDevice) - } - assert.Equal(t, txnID, startEvt.TransactionID) - assert.Equal(t, event.VerificationMethodSAS, startEvt.Method) - assert.Contains(t, startEvt.Hashes, event.VerificationHashMethodSHA256) - assert.Contains(t, startEvt.KeyAgreementProtocols, event.KeyAgreementProtocolCurve25519HKDFSHA256) - assert.Contains(t, startEvt.MessageAuthenticationCodes, event.MACMethodHKDFHMACSHA256) - assert.Contains(t, startEvt.MessageAuthenticationCodes, event.MACMethodHKDFHMACSHA256V2) - assert.Contains(t, startEvt.ShortAuthenticationString, event.SASMethodDecimal) - assert.Contains(t, startEvt.ShortAuthenticationString, event.SASMethodEmoji) - - // Test that the accept event is correct - var acceptEvt *event.VerificationAcceptEventContent - if tc.sendingStartsSAS { - // Process the verification start event on the receiving - // device. - ts.DispatchToDevice(t, ctx, receivingClient) - - // Receiving device sent the accept event to the sending device - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - acceptEvt = sendingInbox[0].Content.AsVerificationAccept() - } else { - // Process the verification start event on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Sending device sent the accept event to the receiving device - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - acceptEvt = receivingInbox[0].Content.AsVerificationAccept() - } - assert.Equal(t, txnID, acceptEvt.TransactionID) - assert.Equal(t, acceptEvt.Hash, event.VerificationHashMethodSHA256) - assert.Equal(t, acceptEvt.KeyAgreementProtocol, event.KeyAgreementProtocolCurve25519HKDFSHA256) - assert.Equal(t, acceptEvt.MessageAuthenticationCode, event.MACMethodHKDFHMACSHA256V2) - assert.Contains(t, acceptEvt.ShortAuthenticationString, event.SASMethodDecimal) - assert.Contains(t, acceptEvt.ShortAuthenticationString, event.SASMethodEmoji) - assert.NotEmpty(t, acceptEvt.Commitment) - - // Test that the first key event is correct - var firstKeyEvt *event.VerificationKeyEventContent - if tc.sendingStartsSAS { - // Process the verification accept event on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Sending device sends first key event to the receiving - // device. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - firstKeyEvt = receivingInbox[0].Content.AsVerificationKey() - } else { - // Process the verification accept event on the receiving - // device. - ts.DispatchToDevice(t, ctx, receivingClient) - - // Receiving device sends first key event to the sending - // device. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - firstKeyEvt = sendingInbox[0].Content.AsVerificationKey() - } - assert.Equal(t, txnID, firstKeyEvt.TransactionID) - assert.NotEmpty(t, firstKeyEvt.Key) - assert.Len(t, firstKeyEvt.Key, 32) - - // Test that the second key event is correct - var secondKeyEvt *event.VerificationKeyEventContent - if tc.sendingStartsSAS { - // Process the first key event on the receiving device. - ts.DispatchToDevice(t, ctx, receivingClient) - - // Receiving device sends second key event to the sending - // device. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - secondKeyEvt = sendingInbox[0].Content.AsVerificationKey() - - // Ensure that the receiving device showed emojis and SAS numbers. - assert.Len(t, receivingCallbacks.GetDecimalsShown(txnID), 3) - emojis, descriptions := receivingCallbacks.GetEmojisAndDescriptionsShown(txnID) - assert.Len(t, emojis, 7) - assert.Len(t, descriptions, 7) - } else { - // Process the first key event on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Sending device sends second key event to the receiving - // device. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - secondKeyEvt = receivingInbox[0].Content.AsVerificationKey() - - // Ensure that the sending device showed emojis and SAS numbers. - assert.Len(t, sendingCallbacks.GetDecimalsShown(txnID), 3) - emojis, descriptions := sendingCallbacks.GetEmojisAndDescriptionsShown(txnID) - assert.Len(t, emojis, 7) - assert.Len(t, descriptions, 7) - } - assert.Equal(t, txnID, secondKeyEvt.TransactionID) - assert.NotEmpty(t, secondKeyEvt.Key) - assert.Len(t, secondKeyEvt.Key, 32) - - // Ensure that the SAS codes are the same. - if tc.sendingStartsSAS { - // Process the second key event on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - } else { - // Process the second key event on the receiving device. - ts.DispatchToDevice(t, ctx, receivingClient) - } - assert.Equal(t, sendingCallbacks.GetDecimalsShown(txnID), receivingCallbacks.GetDecimalsShown(txnID)) - sendingEmojis, sendingDescriptions := sendingCallbacks.GetEmojisAndDescriptionsShown(txnID) - receivingEmojis, receivingDescriptions := receivingCallbacks.GetEmojisAndDescriptionsShown(txnID) - assert.Equal(t, sendingEmojis, receivingEmojis) - assert.Equal(t, sendingDescriptions, receivingDescriptions) - - // Test that the first MAC event is correct - var firstMACEvt *event.VerificationMACEventContent - if tc.sendingConfirmsFirst { - err = sendingHelper.ConfirmSAS(ctx, txnID) - require.NoError(t, err) - - // The receiving device should have received the MAC event. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - firstMACEvt = receivingInbox[0].Content.AsVerificationMAC() - - // The MAC event should have a MAC for the sending device ID. - assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingDeviceID.String())) - } else { - err = receivingHelper.ConfirmSAS(ctx, txnID) - require.NoError(t, err) - - // The sending device should have received the MAC event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - firstMACEvt = sendingInbox[0].Content.AsVerificationMAC() - - // The MAC event should have a MAC for the receiving device ID. - assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingDeviceID.String())) - } - assert.Equal(t, txnID, firstMACEvt.TransactionID) - - // The master key and the sending device ID should be in the - // MAC event's mac keys. - if tc.sendingGeneratedCrossSigningKeys { - assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingCrossSigningKeysCache.MasterKey.PublicKey().String())) - } else { - assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingCrossSigningKeysCache.MasterKey.PublicKey().String())) - } - - // Test that the second MAC event is correct - var secondMACEvt *event.VerificationMACEventContent - if tc.sendingConfirmsFirst { - err = receivingHelper.ConfirmSAS(ctx, txnID) - require.NoError(t, err) - - // The sending device should have received the MAC event. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - secondMACEvt = sendingInbox[0].Content.AsVerificationMAC() - - // The MAC event should have a MAC for the receiving device ID. - assert.Contains(t, maps.Keys(secondMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingDeviceID.String())) - } else { - err = sendingHelper.ConfirmSAS(ctx, txnID) - require.NoError(t, err) - - // The receiving device should have received the MAC event. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - secondMACEvt = receivingInbox[0].Content.AsVerificationMAC() - - // The MAC event should have a MAC for the sending device ID. - assert.Contains(t, maps.Keys(secondMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingDeviceID.String())) - } - assert.Equal(t, txnID, secondMACEvt.TransactionID) - - // The master key and the sending device ID should be in the - // MAC event's mac keys. - if tc.sendingGeneratedCrossSigningKeys { - assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, sendingCrossSigningKeysCache.MasterKey.PublicKey().String())) - } else { - assert.Contains(t, maps.Keys(firstMACEvt.MAC), id.NewKeyID(id.KeyAlgorithmEd25519, receivingCrossSigningKeysCache.MasterKey.PublicKey().String())) - } - - // Test the transaction is done on both sides. We have to dispatch - // twice to process and drain all of the events. - ts.DispatchToDevice(t, ctx, sendingClient) - ts.DispatchToDevice(t, ctx, receivingClient) - ts.DispatchToDevice(t, ctx, sendingClient) - ts.DispatchToDevice(t, ctx, receivingClient) - assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) - assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) - }) - } -} - -func TestVerification_SAS_BothCallStart(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - var err error - - var sendingRecoveryKey string - var sendingCrossSigningKeysCache *crypto.CrossSigningKeysCache - - sendingRecoveryKey, sendingCrossSigningKeysCache, err = sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - assert.NotEmpty(t, sendingRecoveryKey) - assert.NotNil(t, sendingCrossSigningKeysCache) - - // Send the verification request from the sender device and accept - // it on the receiving device and receive the verification ready - // event on the sending device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) - - err = sendingHelper.StartSAS(ctx, txnID) - require.NoError(t, err) - - err = receivingHelper.StartSAS(ctx, txnID) - require.NoError(t, err) - - // Ensure that both devices have received the verification start event. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationStart().TransactionID) - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - assert.Equal(t, txnID, sendingInbox[0].Content.AsVerificationStart().TransactionID) - - // Process the start event from the receiving client to the sending client. - ts.DispatchToDevice(t, ctx, sendingClient) - receivingInbox = ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 2) - assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationStart().TransactionID) - assert.Equal(t, txnID, receivingInbox[1].Content.AsVerificationAccept().TransactionID) - - // Process the rest of the events until we need to confirm the SAS. - for len(ts.DeviceInbox[aliceUserID][sendingDeviceID]) > 0 || len(ts.DeviceInbox[aliceUserID][receivingDeviceID]) > 0 { - ts.DispatchToDevice(t, ctx, receivingClient) - ts.DispatchToDevice(t, ctx, sendingClient) - } - - // Confirm the SAS only the receiving device. - receivingHelper.ConfirmSAS(ctx, txnID) - ts.DispatchToDevice(t, ctx, sendingClient) - - // Verification is not done until both devices confirm the SAS. - assert.False(t, sendingCallbacks.IsVerificationDone(txnID)) - assert.False(t, receivingCallbacks.IsVerificationDone(txnID)) - - // Now, confirm it on the sending device. - sendingHelper.ConfirmSAS(ctx, txnID) - - // Dispatching the events to the receiving device should get us to the done - // state on the receiving device. - ts.DispatchToDevice(t, ctx, receivingClient) - assert.False(t, sendingCallbacks.IsVerificationDone(txnID)) - assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) - - // Dispatching the events to the sending client should get us to the done - // state on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - assert.True(t, sendingCallbacks.IsVerificationDone(txnID)) - assert.True(t, receivingCallbacks.IsVerificationDone(txnID)) -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationhelper_test.go b/mautrix-patched/crypto/verificationhelper/verificationhelper_test.go deleted file mode 100644 index ce5ec5b4..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationhelper_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package verificationhelper_test - -import ( - "context" - "database/sql" - "fmt" - "os" - "testing" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" // zerolog-allow-global-log - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/cryptohelper" - "maunium.net/go/mautrix/crypto/verificationhelper" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/mockserver" -) - -var aliceUserID = id.UserID("@alice:example.org") -var bobUserID = id.UserID("@bob:example.org") -var sendingDeviceID = id.DeviceID("sending") -var receivingDeviceID = id.DeviceID("receiving") - -func init() { - log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.TraceLevel) - zerolog.DefaultContextLogger = &log.Logger -} - -func addDeviceID(ctx context.Context, cryptoStore crypto.Store, userID id.UserID, deviceID id.DeviceID) { - err := cryptoStore.PutDevice(ctx, userID, &id.Device{ - UserID: userID, - DeviceID: deviceID, - }) - if err != nil { - panic(err) - } -} - -func initServerAndLoginTwoAlice(t *testing.T, ctx context.Context) (ts *mockserver.MockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { - t.Helper() - ts = mockserver.Create(t) - - sendingClient, sendingCryptoStore = ts.Login(t, ctx, aliceUserID, sendingDeviceID) - sendingMachine = sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() - receivingClient, receivingCryptoStore = ts.Login(t, ctx, aliceUserID, receivingDeviceID) - receivingMachine = receivingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() - - require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) - require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, receivingMachine.OwnIdentity())) - require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) - require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, receivingMachine.OwnIdentity())) - return -} - -func initServerAndLoginAliceBob(t *testing.T, ctx context.Context) (ts *mockserver.MockServer, sendingClient, receivingClient *mautrix.Client, sendingCryptoStore, receivingCryptoStore crypto.Store, sendingMachine, receivingMachine *crypto.OlmMachine) { - t.Helper() - ts = mockserver.Create(t) - - sendingClient, sendingCryptoStore = ts.Login(t, ctx, aliceUserID, sendingDeviceID) - sendingMachine = sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() - receivingClient, receivingCryptoStore = ts.Login(t, ctx, bobUserID, receivingDeviceID) - receivingMachine = receivingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() - - require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) - require.NoError(t, sendingCryptoStore.PutDevice(ctx, bobUserID, receivingMachine.OwnIdentity())) - require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, sendingMachine.OwnIdentity())) - require.NoError(t, receivingCryptoStore.PutDevice(ctx, bobUserID, receivingMachine.OwnIdentity())) - return -} - -func initDefaultCallbacks(t *testing.T, ctx context.Context, sendingClient, receivingClient *mautrix.Client, sendingMachine, receivingMachine *crypto.OlmMachine) (sendingCallbacks, receivingCallbacks *allVerificationCallbacks, sendingHelper, receivingHelper *verificationhelper.VerificationHelper) { - t.Helper() - sendingCallbacks = newAllVerificationCallbacks() - senderVerificationDB, err := sql.Open("sqlite3", ":memory:") - require.NoError(t, err) - senderVerificationStore, err := NewSQLiteVerificationStore(ctx, senderVerificationDB) - require.NoError(t, err) - - sendingHelper = verificationhelper.NewVerificationHelper(sendingClient, sendingMachine, senderVerificationStore, sendingCallbacks, true, true, true) - require.NoError(t, sendingHelper.Init(ctx)) - - receivingCallbacks = newAllVerificationCallbacks() - receiverVerificationDB, err := sql.Open("sqlite3", ":memory:") - require.NoError(t, err) - receiverVerificationStore, err := NewSQLiteVerificationStore(ctx, receiverVerificationDB) - require.NoError(t, err) - receivingHelper = verificationhelper.NewVerificationHelper(receivingClient, receivingMachine, receiverVerificationStore, receivingCallbacks, true, true, true) - require.NoError(t, receivingHelper.Init(ctx)) - return -} - -func TestVerification_Start(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - receivingDeviceID2 := id.DeviceID("receiving2") - - testCases := []struct { - supportsShow bool - supportsScan bool - supportsSAS bool - callbacks MockVerificationCallbacks - startVerificationErrMsg string - expectedVerificationMethods []event.VerificationMethod - }{ - {false, false, false, newBaseVerificationCallbacks(), "no supported verification methods", nil}, - {false, true, false, newBaseVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, - - {false, false, false, newShowQRCodeVerificationCallbacks(), "no supported verification methods", nil}, - {true, false, false, newShowQRCodeVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeShow, event.VerificationMethodReciprocate}}, - {false, true, false, newShowQRCodeVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, - {true, true, false, newShowQRCodeVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodQRCodeShow, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, - - {false, false, true, newSASVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS}}, - {false, true, true, newSASVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, - - {false, false, false, newAllVerificationCallbacks(), "no supported verification methods", nil}, - {false, false, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS}}, - {false, true, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, - {true, false, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeShow, event.VerificationMethodReciprocate}}, - {true, true, true, newAllVerificationCallbacks(), "", []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodQRCodeShow, event.VerificationMethodQRCodeScan, event.VerificationMethodReciprocate}}, - } - - for i, tc := range testCases { - t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { - ts := mockserver.Create(t) - - client, cryptoStore := ts.Login(t, ctx, aliceUserID, sendingDeviceID) - addDeviceID(ctx, cryptoStore, aliceUserID, sendingDeviceID) - addDeviceID(ctx, cryptoStore, aliceUserID, receivingDeviceID) - addDeviceID(ctx, cryptoStore, aliceUserID, receivingDeviceID2) - - senderHelper := verificationhelper.NewVerificationHelper(client, client.Crypto.(*cryptohelper.CryptoHelper).Machine(), nil, tc.callbacks, tc.supportsShow, tc.supportsScan, tc.supportsSAS) - err := senderHelper.Init(ctx) - require.NoError(t, err) - - txnID, err := senderHelper.StartVerification(ctx, aliceUserID) - if tc.startVerificationErrMsg != "" { - assert.ErrorContains(t, err, tc.startVerificationErrMsg) - return - } - - require.NoError(t, err) - assert.NotEmpty(t, txnID) - - toDeviceInbox := ts.DeviceInbox[aliceUserID] - - // Ensure that we didn't send a verification request to the - // sending device. - assert.Empty(t, toDeviceInbox[sendingDeviceID]) - - // Ensure that the verification request was sent to both of - // the other devices. - assert.NotEmpty(t, toDeviceInbox[receivingDeviceID]) - assert.NotEmpty(t, toDeviceInbox[receivingDeviceID2]) - assert.Equal(t, toDeviceInbox[receivingDeviceID], toDeviceInbox[receivingDeviceID2]) - require.Len(t, toDeviceInbox[receivingDeviceID], 1) - - // Ensure that the verification request is correct. - verificationRequest := toDeviceInbox[receivingDeviceID][0].Content.AsVerificationRequest() - assert.Equal(t, sendingDeviceID, verificationRequest.FromDevice) - assert.Equal(t, txnID, verificationRequest.TransactionID) - assert.ElementsMatch(t, tc.expectedVerificationMethods, verificationRequest.Methods) - }) - } -} - -func TestVerification_StartThenCancel(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - bystanderDeviceID := id.DeviceID("bystander") - - for _, sendingCancels := range []bool{true, false} { - t.Run(fmt.Sprintf("sendingCancels=%t", sendingCancels), func(t *testing.T) { - ts, sendingClient, receivingClient, sendingCryptoStore, receivingCryptoStore, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - - bystanderClient, _ := ts.Login(t, ctx, aliceUserID, bystanderDeviceID) - bystanderMachine := bystanderClient.Crypto.(*cryptohelper.CryptoHelper).Machine() - bystanderHelper := verificationhelper.NewVerificationHelper(bystanderClient, bystanderMachine, nil, newAllVerificationCallbacks(), true, true, true) - require.NoError(t, bystanderHelper.Init(ctx)) - - require.NoError(t, sendingCryptoStore.PutDevice(ctx, aliceUserID, bystanderMachine.OwnIdentity())) - require.NoError(t, receivingCryptoStore.PutDevice(ctx, aliceUserID, bystanderMachine.OwnIdentity())) - - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - - assert.Empty(t, ts.DeviceInbox[aliceUserID][sendingDeviceID]) - - // Process the request event on the receiving device. - receivingInbox := ts.DeviceInbox[aliceUserID][receivingDeviceID] - assert.Len(t, receivingInbox, 1) - assert.Equal(t, txnID, receivingInbox[0].Content.AsVerificationRequest().TransactionID) - ts.DispatchToDevice(t, ctx, receivingClient) - - // Process the request event on the bystander device. - bystanderInbox := ts.DeviceInbox[aliceUserID][bystanderDeviceID] - assert.Len(t, bystanderInbox, 1) - assert.Equal(t, txnID, bystanderInbox[0].Content.AsVerificationRequest().TransactionID) - ts.DispatchToDevice(t, ctx, bystanderClient) - - // Cancel the verification request. - var cancelEvt *event.VerificationCancelEventContent - if sendingCancels { - err = sendingHelper.CancelVerification(ctx, txnID, event.VerificationCancelCodeUser, "Recovery code preferred") - assert.NoError(t, err) - - // The sending device should not have a cancellation event. - assert.Empty(t, ts.DeviceInbox[aliceUserID][sendingDeviceID]) - - // Ensure that the cancellation event was sent to the receiving device. - assert.Len(t, ts.DeviceInbox[aliceUserID][receivingDeviceID], 1) - cancelEvt = ts.DeviceInbox[aliceUserID][receivingDeviceID][0].Content.AsVerificationCancel() - - // Ensure that the cancellation event was sent to the bystander device. - assert.Len(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID], 1) - bystanderCancelEvt := ts.DeviceInbox[aliceUserID][bystanderDeviceID][0].Content.AsVerificationCancel() - assert.Equal(t, cancelEvt, bystanderCancelEvt) - } else { - err = receivingHelper.CancelVerification(ctx, txnID, event.VerificationCancelCodeUser, "Recovery code preferred") - assert.NoError(t, err) - - // The receiving device should not have a cancellation event. - assert.Empty(t, ts.DeviceInbox[aliceUserID][receivingDeviceID]) - - // Ensure that the cancellation event was sent to the sending device. - assert.Len(t, ts.DeviceInbox[aliceUserID][sendingDeviceID], 1) - cancelEvt = ts.DeviceInbox[aliceUserID][sendingDeviceID][0].Content.AsVerificationCancel() - - // The bystander device should not have a cancellation event. - assert.Empty(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID]) - } - assert.Equal(t, txnID, cancelEvt.TransactionID) - assert.Equal(t, event.VerificationCancelCodeUser, cancelEvt.Code) - assert.Equal(t, "Recovery code preferred", cancelEvt.Reason) - - if !sendingCancels { - // Process the cancellation event on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Ensure that the cancellation event was sent to the bystander device. - assert.Len(t, ts.DeviceInbox[aliceUserID][bystanderDeviceID], 1) - bystanderCancelEvt := ts.DeviceInbox[aliceUserID][bystanderDeviceID][0].Content.AsVerificationCancel() - assert.Equal(t, txnID, bystanderCancelEvt.TransactionID) - assert.Equal(t, event.VerificationCancelCodeUser, bystanderCancelEvt.Code) - assert.Equal(t, "The verification was rejected from another device.", bystanderCancelEvt.Reason) - } - }) - } -} - -func TestVerification_Accept_NoSupportedMethods(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - ts := mockserver.Create(t) - - sendingClient, sendingCryptoStore := ts.Login(t, ctx, aliceUserID, sendingDeviceID) - receivingClient, _ := ts.Login(t, ctx, aliceUserID, receivingDeviceID) - addDeviceID(ctx, sendingCryptoStore, aliceUserID, sendingDeviceID) - addDeviceID(ctx, sendingCryptoStore, aliceUserID, receivingDeviceID) - - sendingMachine := sendingClient.Crypto.(*cryptohelper.CryptoHelper).Machine() - recoveryKey, cache, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - assert.NoError(t, err) - assert.NotEmpty(t, recoveryKey) - assert.NotNil(t, cache) - - sendingHelper := verificationhelper.NewVerificationHelper(sendingClient, sendingMachine, nil, newAllVerificationCallbacks(), true, true, true) - err = sendingHelper.Init(ctx) - require.NoError(t, err) - - receivingCallbacks := newBaseVerificationCallbacks() - receivingHelper := verificationhelper.NewVerificationHelper(receivingClient, receivingClient.Crypto.(*cryptohelper.CryptoHelper).Machine(), nil, receivingCallbacks, false, false, false) - err = receivingHelper.Init(ctx) - require.NoError(t, err) - - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - require.NotEmpty(t, txnID) - - ts.DispatchToDevice(t, ctx, receivingClient) - - // Ensure that the receiver ignored the request because it - // doesn't support any of the verification methods in the - // request. - assert.Empty(t, receivingCallbacks.GetRequestedVerifications()) -} - -func TestVerification_Accept_CorrectMethodsPresented(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - - testCases := []struct { - sendingSupportsScan bool - sendingSupportsShow bool - receivingSupportsScan bool - receivingSupportsShow bool - sendingSupportsSAS bool - receivingSupportsSAS bool - sendingCallbacks MockVerificationCallbacks - receivingCallbacks MockVerificationCallbacks - expectedVerificationMethods []event.VerificationMethod - }{ - // TODO - {false, false, false, false, true, true, newSASVerificationCallbacks(), newSASVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodSAS}}, - {true, false, true, false, true, true, newSASVerificationCallbacks(), newSASVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodSAS}}, - - {true, false, false, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeShow}}, - {false, true, true, false, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan}}, - {true, false, true, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeShow}}, - {false, true, true, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan}}, - {true, true, true, false, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan}}, - {true, true, false, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeShow}}, - {true, true, true, true, false, false, newShowQRCodeVerificationCallbacks(), newShowQRCodeVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan, event.VerificationMethodQRCodeShow}}, - - {true, true, true, true, true, true, newAllVerificationCallbacks(), newAllVerificationCallbacks(), []event.VerificationMethod{event.VerificationMethodSAS, event.VerificationMethodReciprocate, event.VerificationMethodQRCodeScan, event.VerificationMethodQRCodeShow}}, - } - - for i, tc := range testCases { - t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - - recoveryKey, sendingCrossSigningKeysCache, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - assert.NoError(t, err) - assert.NotEmpty(t, recoveryKey) - assert.NotNil(t, sendingCrossSigningKeysCache) - - sendingHelper := verificationhelper.NewVerificationHelper(sendingClient, sendingMachine, nil, tc.sendingCallbacks, tc.sendingSupportsShow, tc.sendingSupportsScan, tc.sendingSupportsSAS) - err = sendingHelper.Init(ctx) - require.NoError(t, err) - - receivingHelper := verificationhelper.NewVerificationHelper(receivingClient, receivingMachine, nil, tc.receivingCallbacks, tc.receivingSupportsShow, tc.receivingSupportsScan, tc.receivingSupportsSAS) - err = receivingHelper.Init(ctx) - require.NoError(t, err) - - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - - // Process the verification request on the receiving device. - ts.DispatchToDevice(t, ctx, receivingClient) - - // Ensure that the receiving device received a verification - // request with the correct transaction ID. - assert.ElementsMatch(t, []id.VerificationTransactionID{txnID}, tc.receivingCallbacks.GetRequestedVerifications()[aliceUserID]) - - // Have the receiving device accept the verification request. - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - - // Ensure that the receiving device get a notification about the - // transaction being ready. - assert.Contains(t, tc.receivingCallbacks.GetVerificationsReadyTransactions(), txnID) - - // Ensure that if the receiving device should show a QR code that - // it has the correct content. - if tc.sendingSupportsScan && tc.receivingSupportsShow { - receivingShownQRCode := tc.receivingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, receivingShownQRCode) - assert.Equal(t, txnID, receivingShownQRCode.TransactionID) - assert.NotEmpty(t, receivingShownQRCode.SharedSecret) - } - - // Check for whether the receiving device should be scanning a QR - // code. - if tc.receivingSupportsScan && tc.sendingSupportsShow { - assert.Contains(t, tc.receivingCallbacks.GetScanQRCodeTransactions(), txnID) - } - - // Check that the m.key.verification.ready event has the correct - // content. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - assert.Len(t, sendingInbox, 1) - readyEvt := sendingInbox[0].Content.AsVerificationReady() - assert.Equal(t, txnID, readyEvt.TransactionID) - assert.Equal(t, receivingDeviceID, readyEvt.FromDevice) - assert.ElementsMatch(t, tc.expectedVerificationMethods, readyEvt.Methods) - - // Receive the m.key.verification.ready event on the sending - // device. - ts.DispatchToDevice(t, ctx, sendingClient) - - // Ensure that the sending device got a notification about the - // transaction being ready. - assert.Contains(t, tc.sendingCallbacks.GetVerificationsReadyTransactions(), txnID) - - // Ensure that if the sending device should show a QR code that it - // has the correct content. - if tc.receivingSupportsScan && tc.sendingSupportsShow { - sendingShownQRCode := tc.sendingCallbacks.GetQRCodeShown(txnID) - require.NotNil(t, sendingShownQRCode) - assert.Equal(t, txnID, sendingShownQRCode.TransactionID) - assert.NotEmpty(t, sendingShownQRCode.SharedSecret) - } - - // Check for whether the sending device should be scanning a QR - // code. - if tc.sendingSupportsScan && tc.receivingSupportsShow { - assert.Contains(t, tc.sendingCallbacks.GetScanQRCodeTransactions(), txnID) - } - }) - } -} - -// TestAcceptSelfVerificationCancelOnNonParticipatingDevices ensures that we do -// not regress https://github.com/mautrix/go/pull/230. -func TestVerification_Accept_CancelOnNonParticipatingDevices(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - ts, sendingClient, receivingClient, sendingCryptoStore, receivingCryptoStore, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - - nonParticipatingDeviceID1 := id.DeviceID("non-participating1") - nonParticipatingDeviceID2 := id.DeviceID("non-participating2") - addDeviceID(ctx, sendingCryptoStore, aliceUserID, nonParticipatingDeviceID1) - addDeviceID(ctx, sendingCryptoStore, aliceUserID, nonParticipatingDeviceID2) - addDeviceID(ctx, receivingCryptoStore, aliceUserID, nonParticipatingDeviceID1) - addDeviceID(ctx, receivingCryptoStore, aliceUserID, nonParticipatingDeviceID2) - - _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - assert.NoError(t, err) - - // Send the verification request from the sender device and accept it on - // the receiving device. - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - - // Receive the m.key.verification.ready event on the sending device. - ts.DispatchToDevice(t, ctx, sendingClient) - - // The sending and receiving devices should not have any cancellation - // events in their inboxes. - assert.Empty(t, ts.DeviceInbox[aliceUserID][sendingDeviceID]) - assert.Empty(t, ts.DeviceInbox[aliceUserID][receivingDeviceID]) - - // There should now be cancellation events in the non-participating devices - // inboxes (in addition to the request event). - assert.Len(t, ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID1], 2) - assert.Len(t, ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID2], 2) - assert.Equal(t, ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID1][1], ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID2][1]) - cancellationEvent := ts.DeviceInbox[aliceUserID][nonParticipatingDeviceID1][1].Content.AsVerificationCancel() - assert.Equal(t, txnID, cancellationEvent.TransactionID) - assert.Equal(t, event.VerificationCancelCodeAccepted, cancellationEvent.Code) -} - -func TestVerification_ErrorOnDoubleAccept(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - _, _, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - - _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - - txnID, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID) - require.NoError(t, err) - err = receivingHelper.AcceptVerification(ctx, txnID) - assert.ErrorContains(t, err, "transaction is not in the requested state") -} - -// TestVerification_CancelOnDoubleStart ensures that the receiving device -// cancels both transactions if the sending device starts two verifications. -// -// This test ensures that the following bullet point from [Section 10.12.2.2.1 -// of the Spec] is followed: -// -// - When the same device attempts to initiate multiple verification attempts, -// the recipient should cancel all attempts with that device. -// -// [Section 10.12.2.2.1 of the Spec]: https://spec.matrix.org/v1.10/client-server-api/#error-and-exception-handling -func TestVerification_CancelOnDoubleStart(t *testing.T) { - ctx := log.Logger.WithContext(context.TODO()) - ts, sendingClient, receivingClient, _, _, sendingMachine, receivingMachine := initServerAndLoginTwoAlice(t, ctx) - sendingCallbacks, receivingCallbacks, sendingHelper, receivingHelper := initDefaultCallbacks(t, ctx, sendingClient, receivingClient, sendingMachine, receivingMachine) - - _, _, err := sendingMachine.GenerateAndUploadCrossSigningKeys(ctx, nil, "") - require.NoError(t, err) - - // Send and accept the first verification request. - txnID1, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - err = receivingHelper.AcceptVerification(ctx, txnID1) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.ready event - - // Send a second verification request - txnID2, err := sendingHelper.StartVerification(ctx, aliceUserID) - require.NoError(t, err) - ts.DispatchToDevice(t, ctx, receivingClient) - - // Ensure that the sending device received a cancellation event for both of - // the ongoing transactions. - sendingInbox := ts.DeviceInbox[aliceUserID][sendingDeviceID] - require.Len(t, sendingInbox, 2) - cancelEvt1 := sendingInbox[0].Content.AsVerificationCancel() - cancelEvt2 := sendingInbox[1].Content.AsVerificationCancel() - cancelledTxnIDs := []id.VerificationTransactionID{cancelEvt1.TransactionID, cancelEvt2.TransactionID} - assert.Contains(t, cancelledTxnIDs, txnID1) - assert.Contains(t, cancelledTxnIDs, txnID2) - assert.Equal(t, event.VerificationCancelCodeUnexpectedMessage, cancelEvt1.Code) - assert.Equal(t, event.VerificationCancelCodeUnexpectedMessage, cancelEvt2.Code) - assert.Equal(t, "received multiple verification requests from the same device", cancelEvt1.Reason) - assert.Equal(t, "received multiple verification requests from the same device", cancelEvt2.Reason) - - assert.NotNil(t, receivingCallbacks.GetVerificationCancellation(txnID1)) - assert.NotNil(t, receivingCallbacks.GetVerificationCancellation(txnID2)) - ts.DispatchToDevice(t, ctx, sendingClient) // Process the m.key.verification.cancel events - assert.NotNil(t, sendingCallbacks.GetVerificationCancellation(txnID1)) - assert.NotNil(t, sendingCallbacks.GetVerificationCancellation(txnID2)) -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationstore.go b/mautrix-patched/crypto/verificationhelper/verificationstore.go deleted file mode 100644 index 1eb8f752..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationstore.go +++ /dev/null @@ -1,159 +0,0 @@ -package verificationhelper - -import ( - "context" - "errors" - "fmt" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var ErrUnknownVerificationTransaction = errors.New("unknown transaction ID") - -type VerificationState int - -const ( - VerificationStateRequested VerificationState = iota - VerificationStateReady - - VerificationStateTheirQRScanned // We scanned their QR code - VerificationStateOurQRScanned // They scanned our QR code - - VerificationStateSASStarted // An SAS verification has been started - VerificationStateSASAccepted // An SAS verification has been accepted - VerificationStateSASKeysExchanged // An SAS verification has exchanged keys - VerificationStateSASMACExchanged // An SAS verification has exchanged MACs -) - -func (step VerificationState) String() string { - switch step { - case VerificationStateRequested: - return "requested" - case VerificationStateReady: - return "ready" - case VerificationStateTheirQRScanned: - return "their_qr_scanned" - case VerificationStateOurQRScanned: - return "our_qr_scanned" - case VerificationStateSASStarted: - return "sas_started" - case VerificationStateSASAccepted: - return "sas_accepted" - case VerificationStateSASKeysExchanged: - return "sas_keys_exchanged" - case VerificationStateSASMACExchanged: - return "sas_mac" - default: - return fmt.Sprintf("VerificationState(%d)", step) - } -} - -type VerificationTransaction struct { - ExpirationTime jsontime.UnixMilli `json:"expiration_time,omitempty"` - - // RoomID is the room ID if the verification is happening in a room or - // empty if it is a to-device verification. - RoomID id.RoomID `json:"room_id,omitempty"` - - // VerificationState is the current step of the verification flow. - VerificationState VerificationState `json:"verification_state"` - // TransactionID is the ID of the verification transaction. - TransactionID id.VerificationTransactionID `json:"transaction_id"` - - // TheirDeviceID is the device ID of the device that either made the - // initial request or accepted our request. - TheirDeviceID id.DeviceID `json:"their_device_id,omitempty"` - // TheirUserID is the user ID of the other user. - TheirUserID id.UserID `json:"their_user_id,omitempty"` - // TheirSupportedMethods is a list of verification methods that the other - // device supports. - TheirSupportedMethods []event.VerificationMethod `json:"their_supported_methods,omitempty"` - - // SentToDeviceIDs is a list of devices which the initial request was sent - // to. This is only used for to-device verification requests, and is meant - // to be used to send cancellation requests to all other devices when a - // verification request is accepted via a m.key.verification.ready event. - SentToDeviceIDs []id.DeviceID `json:"sent_to_device_ids,omitempty"` - - // QRCodeSharedSecret is the shared secret that was encoded in the QR code - // that we showed. - QRCodeSharedSecret []byte `json:"qr_code_shared_secret,omitempty"` - - StartedByUs bool `json:"started_by_us,omitempty"` // Whether the verification was started by us - StartEventContent *event.VerificationStartEventContent `json:"start_event_content,omitempty"` // The m.key.verification.start event content - Commitment []byte `json:"committment,omitempty"` // The commitment from the m.key.verification.accept event - MACMethod event.MACMethod `json:"mac_method,omitempty"` // The method used to calculate the MAC - EphemeralKey *ECDHPrivateKey `json:"ephemeral_key,omitempty"` // The ephemeral key - EphemeralPublicKeyShared bool `json:"ephemeral_public_key_shared,omitempty"` // Whether this device's ephemeral public key has been shared - OtherPublicKey *ECDHPublicKey `json:"other_public_key,omitempty"` // The other device's ephemeral public key - ReceivedTheirMAC bool `json:"received_their_mac,omitempty"` // Whether we have received their MAC - SentOurMAC bool `json:"sent_our_mac,omitempty"` // Whether we have sent our MAC - ReceivedTheirDone bool `json:"received_their_done,omitempty"` // Whether we have received their done event - SentOurDone bool `json:"sent_our_done,omitempty"` // Whether we have sent our done event -} - -type VerificationStore interface { - // DeleteVerification deletes a verification transaction by ID - DeleteVerification(ctx context.Context, txnID id.VerificationTransactionID) error - // GetVerificationTransaction gets a verification transaction by ID - GetVerificationTransaction(ctx context.Context, txnID id.VerificationTransactionID) (VerificationTransaction, error) - // SaveVerificationTransaction saves a verification transaction by ID - SaveVerificationTransaction(ctx context.Context, txn VerificationTransaction) error - // FindVerificationTransactionForUserDevice finds a verification - // transaction by user and device ID - FindVerificationTransactionForUserDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (VerificationTransaction, error) - // GetAllVerificationTransactions returns all of the verification - // transactions. This is used to reset the cancellation timeouts. - GetAllVerificationTransactions(ctx context.Context) ([]VerificationTransaction, error) -} - -type InMemoryVerificationStore struct { - txns map[id.VerificationTransactionID]VerificationTransaction -} - -var _ VerificationStore = (*InMemoryVerificationStore)(nil) - -func NewInMemoryVerificationStore() *InMemoryVerificationStore { - return &InMemoryVerificationStore{ - txns: map[id.VerificationTransactionID]VerificationTransaction{}, - } -} - -func (i *InMemoryVerificationStore) DeleteVerification(ctx context.Context, txnID id.VerificationTransactionID) error { - if _, ok := i.txns[txnID]; !ok { - return ErrUnknownVerificationTransaction - } - delete(i.txns, txnID) - return nil -} - -func (i *InMemoryVerificationStore) GetVerificationTransaction(ctx context.Context, txnID id.VerificationTransactionID) (VerificationTransaction, error) { - if _, ok := i.txns[txnID]; !ok { - return VerificationTransaction{}, ErrUnknownVerificationTransaction - } - return i.txns[txnID], nil -} - -func (i *InMemoryVerificationStore) SaveVerificationTransaction(ctx context.Context, txn VerificationTransaction) error { - i.txns[txn.TransactionID] = txn - return nil -} - -func (i *InMemoryVerificationStore) FindVerificationTransactionForUserDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (VerificationTransaction, error) { - for _, existingTxn := range i.txns { - if existingTxn.TheirUserID == userID && existingTxn.TheirDeviceID == deviceID { - return existingTxn, nil - } - } - return VerificationTransaction{}, ErrUnknownVerificationTransaction -} - -func (i *InMemoryVerificationStore) GetAllVerificationTransactions(ctx context.Context) (txns []VerificationTransaction, err error) { - for _, txn := range i.txns { - txns = append(txns, txn) - } - return -} diff --git a/mautrix-patched/crypto/verificationhelper/verificationstore_test.go b/mautrix-patched/crypto/verificationhelper/verificationstore_test.go deleted file mode 100644 index e64153b1..00000000 --- a/mautrix-patched/crypto/verificationhelper/verificationstore_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package verificationhelper_test - -import ( - "context" - "database/sql" - "errors" - - _ "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/crypto/verificationhelper" - "maunium.net/go/mautrix/id" -) - -type SQLiteVerificationStore struct { - db *sql.DB -} - -const ( - selectVerifications = `SELECT transaction_data FROM verifications` - getVerificationByTransactionID = selectVerifications + ` WHERE transaction_id = ?1` - getVerificationByUserDeviceID = selectVerifications + ` - WHERE transaction_data->>'their_user_id' = ?1 - AND transaction_data->>'their_device_id' = ?2 - ` - deleteVerificationsQuery = `DELETE FROM verifications WHERE transaction_id = ?1` -) - -var _ verificationhelper.VerificationStore = (*SQLiteVerificationStore)(nil) - -func NewSQLiteVerificationStore(ctx context.Context, db *sql.DB) (*SQLiteVerificationStore, error) { - _, err := db.ExecContext(ctx, ` - CREATE TABLE verifications ( - transaction_id TEXT PRIMARY KEY NOT NULL, - transaction_data JSONB NOT NULL - ); - CREATE INDEX verifications_user_device_id ON - verifications(transaction_data->>'their_user_id', transaction_data->>'their_device_id'); - `) - return &SQLiteVerificationStore{db}, err -} - -func (s *SQLiteVerificationStore) GetAllVerificationTransactions(ctx context.Context) ([]verificationhelper.VerificationTransaction, error) { - rows, err := s.db.QueryContext(ctx, selectVerifications) - return dbutil.NewRowIterWithError(rows, func(dbutil.Scannable) (txn verificationhelper.VerificationTransaction, err error) { - err = rows.Scan(&dbutil.JSON{Data: &txn}) - return - }, err).AsList() -} - -func (vq *SQLiteVerificationStore) GetVerificationTransaction(ctx context.Context, txnID id.VerificationTransactionID) (txn verificationhelper.VerificationTransaction, err error) { - zerolog.Ctx(ctx).Warn().Stringer("transaction_id", txnID).Msg("Getting verification transaction") - row := vq.db.QueryRowContext(ctx, getVerificationByTransactionID, txnID) - err = row.Scan(&dbutil.JSON{Data: &txn}) - if errors.Is(err, sql.ErrNoRows) { - err = verificationhelper.ErrUnknownVerificationTransaction - } - return -} - -func (vq *SQLiteVerificationStore) FindVerificationTransactionForUserDevice(ctx context.Context, userID id.UserID, deviceID id.DeviceID) (txn verificationhelper.VerificationTransaction, err error) { - row := vq.db.QueryRowContext(ctx, getVerificationByUserDeviceID, userID, deviceID) - err = row.Scan(&dbutil.JSON{Data: &txn}) - if errors.Is(err, sql.ErrNoRows) { - err = verificationhelper.ErrUnknownVerificationTransaction - } - return -} - -func (vq *SQLiteVerificationStore) SaveVerificationTransaction(ctx context.Context, txn verificationhelper.VerificationTransaction) (err error) { - zerolog.Ctx(ctx).Debug().Any("transaction", &txn).Msg("Saving verification transaction") - _, err = vq.db.ExecContext(ctx, ` - INSERT INTO verifications (transaction_id, transaction_data) - VALUES (?1, ?2) - ON CONFLICT (transaction_id) DO UPDATE - SET transaction_data=excluded.transaction_data - `, txn.TransactionID, &dbutil.JSON{Data: &txn}) - return -} - -func (vq *SQLiteVerificationStore) DeleteVerification(ctx context.Context, txnID id.VerificationTransactionID) (err error) { - _, err = vq.db.ExecContext(ctx, deleteVerificationsQuery, txnID) - return -} diff --git a/mautrix-patched/error.go b/mautrix-patched/error.go deleted file mode 100644 index 987dfb5a..00000000 --- a/mautrix-patched/error.go +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - - "go.mau.fi/util/exhttp" - "go.mau.fi/util/exmaps" - "golang.org/x/exp/maps" -) - -// Common error codes from https://matrix.org/docs/spec/client_server/latest#api-standards -// -// Can be used with errors.Is() to check the response code without casting the error: -// -// err := client.Sync() -// if errors.Is(err, MUnknownToken) { -// // logout -// } -var ( - // Generic error for when the server encounters an error and it does not have a more specific error code. - // Note that `errors.Is` will check the error message rather than code for M_UNKNOWNs. - MUnknown = RespError{ErrCode: "M_UNKNOWN", StatusCode: http.StatusInternalServerError} - // Forbidden access, e.g. joining a room without permission, failed login. - MForbidden = RespError{ErrCode: "M_FORBIDDEN", StatusCode: http.StatusForbidden} - // Unrecognized request, e.g. the endpoint does not exist or is not implemented. - MUnrecognized = RespError{ErrCode: "M_UNRECOGNIZED", StatusCode: http.StatusNotFound} - // The access token specified was not recognised. - MUnknownToken = RespError{ErrCode: "M_UNKNOWN_TOKEN", StatusCode: http.StatusUnauthorized} - // No access token was specified for the request. - MMissingToken = RespError{ErrCode: "M_MISSING_TOKEN", StatusCode: http.StatusUnauthorized} - // Request contained valid JSON, but it was malformed in some way, e.g. missing required keys, invalid values for keys. - MBadJSON = RespError{ErrCode: "M_BAD_JSON", StatusCode: http.StatusBadRequest} - // Request did not contain valid JSON. - MNotJSON = RespError{ErrCode: "M_NOT_JSON", StatusCode: http.StatusBadRequest} - // No resource was found for this request. - MNotFound = RespError{ErrCode: "M_NOT_FOUND", StatusCode: http.StatusNotFound} - // Too many requests have been sent in a short period of time. Wait a while then try again. - MLimitExceeded = RespError{ErrCode: "M_LIMIT_EXCEEDED", StatusCode: http.StatusTooManyRequests} - // The user ID associated with the request has been deactivated. - // Typically for endpoints that prove authentication, such as /login. - MUserDeactivated = RespError{ErrCode: "M_USER_DEACTIVATED"} - // Encountered when trying to register a user ID which has been taken. - MUserInUse = RespError{ErrCode: "M_USER_IN_USE", StatusCode: http.StatusBadRequest} - // Encountered when trying to register a user ID which is not valid. - MInvalidUsername = RespError{ErrCode: "M_INVALID_USERNAME", StatusCode: http.StatusBadRequest} - // Sent when the room alias given to the createRoom API is already in use. - MRoomInUse = RespError{ErrCode: "M_ROOM_IN_USE", StatusCode: http.StatusBadRequest} - // The state change requested cannot be performed, such as attempting to unban a user who is not banned. - MBadState = RespError{ErrCode: "M_BAD_STATE"} - // The request or entity was too large. - MTooLarge = RespError{ErrCode: "M_TOO_LARGE", StatusCode: http.StatusRequestEntityTooLarge} - // The resource being requested is reserved by an application service, or the application service making the request has not created the resource. - MExclusive = RespError{ErrCode: "M_EXCLUSIVE", StatusCode: http.StatusBadRequest} - // The client's request to create a room used a room version that the server does not support. - MUnsupportedRoomVersion = RespError{ErrCode: "M_UNSUPPORTED_ROOM_VERSION"} - // The client attempted to join a room that has a version the server does not support. - // Inspect the room_version property of the error response for the room's version. - MIncompatibleRoomVersion = RespError{ErrCode: "M_INCOMPATIBLE_ROOM_VERSION"} - // The client specified a parameter that has the wrong value. - MInvalidParam = RespError{ErrCode: "M_INVALID_PARAM", StatusCode: http.StatusBadRequest} - // The client specified a room key backup version that is not the current room key backup version for the user. - MWrongRoomKeysVersion = RespError{ErrCode: "M_WRONG_ROOM_KEYS_VERSION", StatusCode: http.StatusForbidden} - - MURLNotSet = RespError{ErrCode: "M_URL_NOT_SET"} - MBadStatus = RespError{ErrCode: "M_BAD_STATUS"} - MConnectionTimeout = RespError{ErrCode: "M_CONNECTION_TIMEOUT"} - MConnectionFailed = RespError{ErrCode: "M_CONNECTION_FAILED"} - - MUnredactedContentDeleted = RespError{ErrCode: "FI.MAU.MSC2815_UNREDACTED_CONTENT_DELETED"} - MUnredactedContentNotReceived = RespError{ErrCode: "FI.MAU.MSC2815_UNREDACTED_CONTENT_NOT_RECEIVED"} -) - -var ( - ErrClientIsNil = errors.New("client is nil") - ErrClientHasNoHomeserver = errors.New("client has no homeserver set") - - ErrResponseTooLong = errors.New("response content length too long") - ErrBodyReadReachedLimit = errors.New("reached response size limit while reading body") - - // Special error that indicates we should retry canceled contexts. Note that on it's own this - // is useless, the context itself must also be replaced. - ErrContextCancelRetry = errors.New("retry canceled context") -) - -// HTTPError An HTTP Error response, which may wrap an underlying native Go Error. -type HTTPError struct { - Request *http.Request - Response *http.Response - ResponseBody string - - WrappedError error - RespError *RespError - Message string -} - -func (e HTTPError) Is(err error) bool { - return (e.RespError != nil && errors.Is(e.RespError, err)) || (e.WrappedError != nil && errors.Is(e.WrappedError, err)) -} - -func (e HTTPError) IsStatus(code int) bool { - return e.Response != nil && e.Response.StatusCode == code -} - -func (e HTTPError) Error() string { - if e.WrappedError != nil { - return fmt.Sprintf("%s: %v", e.Message, e.WrappedError) - } else if e.RespError != nil { - msg := e.RespError.Err - if e.RespError.InternalError != "" { - msg = e.RespError.InternalError - } - return fmt.Sprintf("%s (HTTP %d): %s", e.RespError.ErrCode, e.Response.StatusCode, msg) - } else { - msg := fmt.Sprintf("HTTP %d", e.Response.StatusCode) - if len(e.ResponseBody) > 0 { - msg = fmt.Sprintf("%s: %s", msg, e.ResponseBody) - } - return msg - } -} - -func (e HTTPError) Unwrap() error { - if e.WrappedError != nil { - return e.WrappedError - } else if e.RespError != nil { - return *e.RespError - } - return nil -} - -// RespError is the standard JSON error response from Homeservers. It also implements the Golang "error" interface. -// See https://spec.matrix.org/v1.2/client-server-api/#api-standards -type RespError struct { - ErrCode string - Err string - ExtraData map[string]any - - StatusCode int - ExtraHeader map[string]string - - CanRetry bool - InternalError string -} - -func (e *RespError) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, &e.ExtraData) - if err != nil { - return err - } - e.ErrCode, _ = e.ExtraData["errcode"].(string) - e.Err, _ = e.ExtraData["error"].(string) - e.CanRetry, _ = e.ExtraData["com.beeper.can_retry"].(bool) - e.InternalError, _ = e.ExtraData["fi.mau.internal_error"].(string) - return nil -} - -func (e *RespError) MarshalJSON() ([]byte, error) { - data := exmaps.NonNilClone(e.ExtraData) - data["errcode"] = e.ErrCode - data["error"] = e.Err - if e.CanRetry { - data["com.beeper.can_retry"] = e.CanRetry - } else { - delete(data, "com.beeper.can_retry") - } - if e.InternalError != "" { - data["fi.mau.internal_error"] = e.InternalError - } else { - delete(data, "fi.mau.internal_error") - } - return json.Marshal(data) -} - -func (e RespError) Write(w http.ResponseWriter) { - if w == nil { - return - } - statusCode := e.StatusCode - if statusCode == 0 { - statusCode = http.StatusInternalServerError - } - for key, value := range e.ExtraHeader { - w.Header().Set(key, value) - } - exhttp.WriteJSONResponse(w, statusCode, &e) -} - -func (e RespError) WithMessage(msg string, args ...any) RespError { - if len(args) > 0 { - msg = fmt.Sprintf(msg, args...) - } - e.Err = msg - return e -} - -func (e RespError) WithStatus(status int) RespError { - e.StatusCode = status - return e -} - -func (e RespError) WithCanRetry(canRetry bool) RespError { - e.CanRetry = canRetry - return e -} - -func (e RespError) WithInternalError(err error) RespError { - e.InternalError = err.Error() - return e -} - -func (e RespError) WithExtraData(extraData map[string]any) RespError { - e.ExtraData = exmaps.NonNilClone(e.ExtraData) - maps.Copy(e.ExtraData, extraData) - return e -} - -func (e RespError) WithExtraField(key string, value any) RespError { - e.ExtraData = exmaps.NonNilClone(e.ExtraData) - e.ExtraData[key] = value - return e -} - -func (e RespError) WithExtraHeader(key, value string) RespError { - e.ExtraHeader = exmaps.NonNilClone(e.ExtraHeader) - e.ExtraHeader[key] = value - return e -} - -func (e RespError) WithExtraHeaders(headers map[string]string) RespError { - e.ExtraHeader = exmaps.NonNilClone(e.ExtraHeader) - maps.Copy(e.ExtraHeader, headers) - return e -} - -// Error returns the errcode and error message. -func (e RespError) Error() string { - return e.ErrCode + ": " + e.Err -} - -func (e RespError) Is(err error) bool { - e2, ok := err.(RespError) - if !ok { - return false - } - if e.ErrCode == "M_UNKNOWN" && e2.ErrCode == "M_UNKNOWN" { - return e.Err == e2.Err - } - return e2.ErrCode == e.ErrCode -} diff --git a/mautrix-patched/event/accountdata.go b/mautrix-patched/event/accountdata.go deleted file mode 100644 index 223919a1..00000000 --- a/mautrix-patched/event/accountdata.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "strings" - "time" - - "maunium.net/go/mautrix/id" -) - -// TagEventContent represents the content of a m.tag room account data event. -// https://spec.matrix.org/v1.2/client-server-api/#mtag -type TagEventContent struct { - Tags Tags `json:"tags"` -} - -type Tags map[RoomTag]TagMetadata - -type RoomTag string - -const ( - RoomTagFavourite RoomTag = "m.favourite" - RoomTagLowPriority RoomTag = "m.lowpriority" - RoomTagServerNotice RoomTag = "m.server_notice" -) - -func (rt RoomTag) IsUserDefined() bool { - return strings.HasPrefix(string(rt), "u.") -} - -func (rt RoomTag) String() string { - return string(rt) -} - -func (rt RoomTag) Name() string { - if rt.IsUserDefined() { - return string(rt[2:]) - } - switch rt { - case RoomTagFavourite: - return "Favourite" - case RoomTagLowPriority: - return "Low priority" - case RoomTagServerNotice: - return "Server notice" - default: - return "" - } -} - -// Deprecated: type alias -type Tag = TagMetadata - -type TagMetadata struct { - Order json.Number `json:"order,omitempty"` - - MauDoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` -} - -// DirectChatsEventContent represents the content of a m.direct account data event. -// https://spec.matrix.org/v1.2/client-server-api/#mdirect -type DirectChatsEventContent map[id.UserID][]id.RoomID - -// FullyReadEventContent represents the content of a m.fully_read account data event. -// https://spec.matrix.org/v1.2/client-server-api/#mfully_read -type FullyReadEventContent struct { - EventID id.EventID `json:"event_id"` -} - -// IgnoredUserListEventContent represents the content of a m.ignored_user_list account data event. -// https://spec.matrix.org/v1.2/client-server-api/#mignored_user_list -type IgnoredUserListEventContent struct { - IgnoredUsers map[id.UserID]IgnoredUser `json:"ignored_users"` -} - -type IgnoredUser struct { - // This is an empty object -} - -type MarkedUnreadEventContent struct { - Unread bool `json:"unread"` -} - -type BeeperMuteEventContent struct { - MutedUntil int64 `json:"muted_until,omitempty"` -} - -func (bmec *BeeperMuteEventContent) IsMuted() bool { - return bmec.MutedUntil < 0 || (bmec.MutedUntil > 0 && bmec.GetMutedUntilTime().After(time.Now())) -} - -var MutedForever = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC) - -func (bmec *BeeperMuteEventContent) GetMutedUntilTime() time.Time { - if bmec.MutedUntil < 0 { - return MutedForever - } else if bmec.MutedUntil > 0 { - return time.UnixMilli(bmec.MutedUntil) - } - return time.Time{} -} - -func (bmec *BeeperMuteEventContent) GetMuteDuration() time.Duration { - ts := bmec.GetMutedUntilTime() - now := time.Now() - if ts.Before(now) { - return 0 - } else if ts == MutedForever { - return -1 - } else { - return ts.Sub(now) - } -} diff --git a/mautrix-patched/event/audio.go b/mautrix-patched/event/audio.go deleted file mode 100644 index 9eeb8edb..00000000 --- a/mautrix-patched/event/audio.go +++ /dev/null @@ -1,21 +0,0 @@ -package event - -import ( - "encoding/json" -) - -type MSC1767Audio struct { - Duration int `json:"duration"` - Waveform []int `json:"waveform"` -} - -type serializableMSC1767Audio MSC1767Audio - -func (ma *MSC1767Audio) MarshalJSON() ([]byte, error) { - if ma.Waveform == nil { - ma.Waveform = []int{} - } - return json.Marshal((*serializableMSC1767Audio)(ma)) -} - -type MSC3245Voice struct{} diff --git a/mautrix-patched/event/beeper.go b/mautrix-patched/event/beeper.go deleted file mode 100644 index fc784c5d..00000000 --- a/mautrix-patched/event/beeper.go +++ /dev/null @@ -1,383 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/base32" - "encoding/binary" - "encoding/json" - "fmt" - "html" - "regexp" - "strconv" - "strings" - - "go.mau.fi/util/jsonbytes" - - "maunium.net/go/mautrix/id" -) - -type MessageStatusReason string - -const ( - MessageStatusGenericError MessageStatusReason = "m.event_not_handled" - MessageStatusUnsupported MessageStatusReason = "com.beeper.unsupported_event" - MessageStatusUndecryptable MessageStatusReason = "com.beeper.undecryptable_event" - MessageStatusTooOld MessageStatusReason = "m.event_too_old" - MessageStatusNetworkError MessageStatusReason = "m.foreign_network_error" - MessageStatusNoPermission MessageStatusReason = "m.no_permission" - MessageStatusBridgeUnavailable MessageStatusReason = "m.bridge_unavailable" -) - -type MessageStatus string - -const ( - MessageStatusSuccess MessageStatus = "SUCCESS" - MessageStatusPending MessageStatus = "PENDING" - MessageStatusRetriable MessageStatus = "FAIL_RETRIABLE" - MessageStatusFail MessageStatus = "FAIL_PERMANENT" -) - -type BeeperMessageStatusEventContent struct { - Network string `json:"network,omitempty"` - RelatesTo RelatesTo `json:"m.relates_to"` - Status MessageStatus `json:"status"` - Reason MessageStatusReason `json:"reason,omitempty"` - // Deprecated: clients were showing this to users even though they aren't supposed to. - // Use InternalError for error messages that should be included in bug reports, but not shown in the UI. - Error string `json:"error,omitempty"` - InternalError string `json:"internal_error,omitempty"` - Message string `json:"message,omitempty"` - - LastRetry id.EventID `json:"last_retry,omitempty"` - - TargetTxnID string `json:"relates_to_txn_id,omitempty"` - - MutateEventKey string `json:"mutate_event_key,omitempty"` - - // Indicates the set of users to whom the event was delivered. If nil, then - // the client should not expect delivered status at any later point. If not - // nil (even if empty), this field indicates which users the event was - // delivered to. - DeliveredToUsers *[]id.UserID `json:"delivered_to_users,omitempty"` -} - -type BeeperRelatesTo struct { - EventID id.EventID `json:"event_id,omitempty"` - RoomID id.RoomID `json:"room_id,omitempty"` - Type RelationType `json:"rel_type,omitempty"` -} - -type BeeperTranscriptionEventContent struct { - Text []ExtensibleText `json:"m.text,omitempty"` - Model string `json:"com.beeper.transcription.model,omitempty"` - RelatesTo BeeperRelatesTo `json:"com.beeper.relates_to,omitempty"` -} - -type BeeperRetryMetadata struct { - OriginalEventID id.EventID `json:"original_event_id"` - RetryCount int `json:"retry_count"` - // last_retry is also present, but not used by bridges -} - -type BeeperRoomKeyAckEventContent struct { - RoomID id.RoomID `json:"room_id"` - SessionID id.SessionID `json:"session_id"` - FirstMessageIndex int `json:"first_message_index"` -} - -type BeeperChatDeleteEventContent struct { - DeleteForEveryone bool `json:"delete_for_everyone,omitempty"` - FromMessageRequest bool `json:"from_message_request,omitempty"` -} - -type BeeperAcceptMessageRequestEventContent struct { - // Whether this was triggered by a message rather than an explicit event - IsImplicit bool `json:"-"` -} - -type BeeperSendStateEventContent struct { - Type string `json:"type"` - StateKey string `json:"state_key"` - Content Content `json:"content"` -} - -type IntOrString int - -func (ios *IntOrString) UnmarshalJSON(data []byte) error { - if len(data) > 0 && data[0] == '"' { - var str string - err := json.Unmarshal(data, &str) - if err != nil { - return err - } - intVal, err := strconv.Atoi(str) - if err != nil { - return err - } - *ios = IntOrString(intVal) - return nil - } - return json.Unmarshal(data, (*int)(ios)) -} - -type LinkPreview struct { - CanonicalURL string `json:"og:url,omitempty"` - Title string `json:"og:title,omitempty"` - Type string `json:"og:type,omitempty"` - Description string `json:"og:description,omitempty"` - SiteName string `json:"og:site_name,omitempty"` - - ImageURL id.ContentURIString `json:"og:image,omitempty"` - - ImageSize IntOrString `json:"matrix:image:size,omitempty"` - ImageWidth IntOrString `json:"og:image:width,omitempty"` - ImageHeight IntOrString `json:"og:image:height,omitempty"` - ImageType string `json:"og:image:type,omitempty"` -} - -// BeeperLinkPreview contains the data for a bundled URL preview as specified in MSC4095 -// -// https://github.com/matrix-org/matrix-spec-proposals/pull/4095 -type BeeperLinkPreview struct { - LinkPreview - - MatchedURL string `json:"matched_url,omitempty"` - ImageEncryption *EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` - ImageBlurhash string `json:"matrix:image:blurhash,omitempty"` -} - -type BeeperProfileExtra struct { - RemoteID string `json:"com.beeper.bridge.remote_id,omitempty"` - Identifiers []string `json:"com.beeper.bridge.identifiers,omitempty"` - Service string `json:"com.beeper.bridge.service,omitempty"` - Network string `json:"com.beeper.bridge.network,omitempty"` - IsBridgeBot bool `json:"com.beeper.bridge.is_bridge_bot,omitempty"` - IsNetworkBot bool `json:"com.beeper.bridge.is_network_bot,omitempty"` -} - -type BeeperPerMessageProfile struct { - ID string `json:"id"` - Displayname string `json:"displayname,omitempty"` - AvatarURL *id.ContentURIString `json:"avatar_url,omitempty"` - AvatarFile *EncryptedFileInfo `json:"avatar_file,omitempty"` - HasFallback bool `json:"has_fallback,omitempty"` -} - -type BeeperActionMessageType string - -const ( - BeeperActionMessageCall BeeperActionMessageType = "call" -) - -type BeeperActionMessageCallType string - -const ( - BeeperActionMessageCallTypeVoice BeeperActionMessageCallType = "voice" - BeeperActionMessageCallTypeVideo BeeperActionMessageCallType = "video" -) - -type BeeperActionMessage struct { - Type BeeperActionMessageType `json:"type"` - CallType BeeperActionMessageCallType `json:"call_type,omitempty"` -} - -func (content *MessageEventContent) AddPerMessageProfileFallback() { - if content.BeeperPerMessageProfile == nil || content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { - return - } - content.BeeperPerMessageProfile.HasFallback = true - content.EnsureHasHTML() - content.Body = fmt.Sprintf("%s: %s", content.BeeperPerMessageProfile.Displayname, content.Body) - content.FormattedBody = fmt.Sprintf( - "%s: %s", - html.EscapeString(content.BeeperPerMessageProfile.Displayname), - content.FormattedBody, - ) -} - -var HTMLProfileFallbackRegex = regexp.MustCompile(`([^<]+): `) - -func (content *MessageEventContent) RemovePerMessageProfileFallback() { - if content.NewContent != nil && content.NewContent != content { - content.NewContent.RemovePerMessageProfileFallback() - } - if content == nil || content.BeeperPerMessageProfile == nil || !content.BeeperPerMessageProfile.HasFallback || content.BeeperPerMessageProfile.Displayname == "" { - return - } - content.BeeperPerMessageProfile.HasFallback = false - content.Body = strings.TrimPrefix(content.Body, content.BeeperPerMessageProfile.Displayname+": ") - if content.Format == FormatHTML { - content.FormattedBody = HTMLProfileFallbackRegex.ReplaceAllLiteralString(content.FormattedBody, "") - } -} - -type BeeperStreamInfo struct { - UserID id.UserID `json:"user_id"` - DeviceID id.DeviceID `json:"device_id,omitempty"` - Type string `json:"type"` - ExpiryMS int64 `json:"expiry_ms,omitempty"` - MaxBufferedUpdates int `json:"max_buffered_updates,omitempty"` - Encryption *BeeperStreamEncryptionInfo `json:"encryption,omitempty"` -} - -func (info *BeeperStreamInfo) Clone() *BeeperStreamInfo { - if info == nil { - return nil - } - cloned := *info - if info.Encryption != nil { - enc := *info.Encryption - enc.Key = append(jsonbytes.UnpaddedBytes(nil), info.Encryption.Key...) - cloned.Encryption = &enc - } - return &cloned -} - -func (info *BeeperStreamInfo) Validate() error { - if info == nil { - return fmt.Errorf("missing beeper stream descriptor") - } else if info.UserID == "" || info.Type == "" { - return fmt.Errorf("missing beeper stream descriptor fields") - } else if info.MaxBufferedUpdates < 0 { - return fmt.Errorf("invalid beeper stream max buffered updates %d", info.MaxBufferedUpdates) - } - if info.Encryption == nil { - return nil - } - if info.Encryption.Algorithm != id.AlgorithmBeeperStreamV1 { - return fmt.Errorf("unsupported beeper stream encryption algorithm %q", info.Encryption.Algorithm) - } else if len(info.Encryption.Key) == 0 { - return fmt.Errorf("missing beeper stream encryption key") - } - return nil -} - -type BeeperStreamEncryptionInfo struct { - Algorithm id.Algorithm `json:"algorithm"` - Key jsonbytes.UnpaddedBytes `json:"key"` -} - -type BeeperStreamSubscribeEventContent struct { - RoomID id.RoomID `json:"room_id"` - EventID id.EventID `json:"event_id"` - DeviceID id.DeviceID `json:"device_id"` - ExpiryMS int64 `json:"expiry_ms"` -} - -type BeeperStreamUpdateEventContent struct { - RoomID id.RoomID `json:"room_id"` - EventID id.EventID `json:"event_id"` - Updates []map[string]any `json:"updates,omitempty"` -} - -type BeeperEncodedOrder struct { - order int64 - suborder int16 -} - -func NewBeeperEncodedOrder(order int64, suborder int16) *BeeperEncodedOrder { - return &BeeperEncodedOrder{order: order, suborder: suborder} -} - -func BeeperEncodedOrderFromString(str string) (*BeeperEncodedOrder, error) { - order, suborder, err := decodeIntPair(str) - if err != nil { - return nil, err - } - return &BeeperEncodedOrder{order: order, suborder: suborder}, nil -} - -func (b *BeeperEncodedOrder) String() string { - if b == nil { - return "" - } - return encodeIntPair(b.order, b.suborder) -} - -func (b *BeeperEncodedOrder) OrderPair() (int64, int16) { - if b == nil { - return 0, 0 - } - return b.order, b.suborder -} - -func (b *BeeperEncodedOrder) IsZero() bool { - return b == nil || (b.order == 0 && b.suborder == 0) -} - -func (b *BeeperEncodedOrder) MarshalJSON() ([]byte, error) { - return []byte(`"` + b.String() + `"`), nil -} - -func (b *BeeperEncodedOrder) UnmarshalJSON(data []byte) error { - if b == nil { - return fmt.Errorf("BeeperEncodedOrder: receiver is nil") - } - str := string(data) - if len(str) < 2 { - return fmt.Errorf("invalid encoded order string: %s", str) - } - decoded, err := BeeperEncodedOrderFromString(str[1 : len(str)-1]) - if err != nil { - return err - } - b.order, b.suborder = decoded.order, decoded.suborder - return nil -} - -// encodeIntPair encodes an int64 and an int16 into a lexicographically sortable string -func encodeIntPair(a int64, b int16) string { - // Create a buffer to hold the binary representation of the integers. - // Will need 8 bytes for the int64 and 2 bytes for the int16. - var buf [10]byte - - // Flip the sign bit of each integer to map the entire int range to uint - // in a way that preserves the order of the original integers. - // - // Explanation: - // - By XORing with (1 << 63), we flip the most significant bit (sign bit) of the int64 value. - // - Negative numbers (which have a sign bit of 1) become smaller uint64 values. - // - Non-negative numbers (with a sign bit of 0) become larger uint64 values. - // - This mapping preserves the original ordering when the uint64 values are compared. - binary.BigEndian.PutUint64(buf[0:8], uint64(a)^(1<<63)) - binary.BigEndian.PutUint16(buf[8:10], uint16(b)^(1<<15)) - - // Encode the buffer into a Base32 string without padding using the Hex encoding. - // - // Explanation: - // - Base32 encoding converts binary data into a text representation using 32 ASCII characters. - // - Using Base32HexEncoding ensures that the characters are in lexicographical order. - // - Disabling padding results in a consistent string length, which is important for sorting. - encoded := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]) - - return encoded -} - -// decodeIntPair decodes a string produced by encodeIntPair back into the original int64 and int16 values -func decodeIntPair(encoded string) (int64, int16, error) { - // Decode the Base32 string back into the original byte buffer. - buf, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) - if err != nil { - return 0, 0, fmt.Errorf("failed to decode string: %w", err) - } - - // Check that the decoded buffer has the expected length. - if len(buf) != 10 { - return 0, 0, fmt.Errorf("invalid encoded string length: expected 10 bytes, got %d", len(buf)) - } - - // Read the uint values from the buffer using big-endian byte order. - aPos := binary.BigEndian.Uint64(buf[0:8]) - bPos := binary.BigEndian.Uint16(buf[8:10]) - - // Reverse the sign bit flip to retrieve the original values. - a := int64(aPos ^ (1 << 63)) - b := int16(bPos ^ (1 << 15)) - - return a, b, nil -} diff --git a/mautrix-patched/event/capabilities.d.ts b/mautrix-patched/event/capabilities.d.ts deleted file mode 100644 index a608647e..00000000 --- a/mautrix-patched/event/capabilities.d.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * The content of the `com.beeper.room_features` state event. - */ -export interface RoomFeatures { - /** - * Supported formatting features. If omitted, no formatting is supported. - * - * Capability level 0 means the corresponding HTML tags/attributes are ignored - * and will be treated as if they don't exist, which means that children will - * be rendered, but attributes will be dropped. - */ - formatting?: Record - /** - * Supported file message types and their features. - * - * If a message type isn't listed here, it should be treated as support level -2 (will be rejected). - */ - file?: Record - /** - * Supported state event types and their parameters. Currently, there are no parameters, - * but it is likely there will be some in the future (like max name/topic length, avatar mime types, etc.). - * - * Events that are not listed or have a support level of zero or below should be treated as unsupported. - * - * Clients should at least check `m.room.name`, `m.room.topic`, and `m.room.avatar` here. - * `m.room.member` will not be listed here, as it's controlled by the member_actions field. - * `com.beeper.disappearing_timer` should be listed here, but the parameters are in the disappearing_timer field for now. - */ - state?: Record - /** - * Supported member actions and their support levels. - * - * Actions that are not listed or have a support level of zero or below should be treated as unsupported. - */ - member_actions?: Record - - /** Maximum length of normal text messages. */ - max_text_length?: integer - - /** Whether location messages (`m.location`) are supported. */ - location_message?: CapabilitySupportLevel - /** Whether polls are supported. */ - poll?: CapabilitySupportLevel - /** Whether replying in a thread is supported. */ - thread?: CapabilitySupportLevel - /** Whether replying to a specific message is supported. */ - reply?: CapabilitySupportLevel - - /** Whether edits are supported. */ - edit?: CapabilitySupportLevel - /** How many times can an individual message be edited. */ - edit_max_count?: integer - /** How old messages can be edited, in seconds. */ - edit_max_age?: seconds - /** Whether deleting messages for everyone is supported */ - delete?: CapabilitySupportLevel - /** How old messages can be deleted for everyone, in seconds. */ - delete_max_age?: seconds - /** Whether deleting messages just for yourself is supported. No message age limit. */ - delete_for_me?: boolean - /** - * Whether outgoing redactions should set the `com.beeper.dont_render_redacted_placeholder` field to indicate - * clients shouldn't render them in the timeline. Incoming redactions can just read the field and don't need to - * care about this capability flag. - */ - delete_hide_placeholder?: boolean - /** Allowed configuration options for disappearing timers. */ - disappearing_timer?: DisappearingTimerCapability - - /** Whether reactions are supported. */ - reaction?: CapabilitySupportLevel - /** How many reactions can be added to a single message. */ - reaction_count?: integer - /** - * The Unicode emojis allowed for reactions. If omitted, all emojis are allowed. - * Emojis in this list must include variation selector 16 if allowed in the Unicode spec. - */ - allowed_reactions?: string[] - /** Whether custom emoji reactions are allowed. */ - custom_emoji_reactions?: boolean - - /** Whether deleting the chat for yourself is supported. */ - delete_chat?: boolean - /** Whether deleting the chat for all participants is supported. */ - delete_chat_for_everyone?: boolean - /** What can be done with message requests? */ - message_request?: { - accept_with_message?: CapabilitySupportLevel - accept_with_button?: CapabilitySupportLevel - } -} - -declare type integer = number -declare type seconds = integer -declare type milliseconds = integer -declare type MIMEClass = "image" | "audio" | "video" | "text" | "font" | "model" | "application" -declare type MIMETypeOrPattern = - "*/*" - | `${MIMEClass}/*` - | `${MIMEClass}/${string}` - | `${MIMEClass}/${string}; ${string}` - -export enum MemberAction { - Ban = "ban", - Kick = "kick", - Leave = "leave", - RevokeInvite = "revoke_invite", - Invite = "invite", -} - -declare type EventType = string - -// This is an object for future extensibility (e.g. max name/topic length) -export interface StateFeatures { - level: CapabilitySupportLevel -} - -export enum CapabilityMsgType { - // Real message types used in the `msgtype` field - Image = "m.image", - File = "m.file", - Audio = "m.audio", - Video = "m.video", - - // Pseudo types only used in capabilities - /** An `m.audio` message that has `"org.matrix.msc3245.voice": {}` */ - Voice = "org.matrix.msc3245.voice", - /** An `m.video` message that has `"info": {"fi.mau.gif": true}`, or an `m.image` message of type `image/gif` */ - GIF = "fi.mau.gif", - /** An `m.sticker` event, no `msgtype` field */ - Sticker = "m.sticker", -} - -export interface FileFeatures { - /** - * The supported MIME types or type patterns and their support levels. - * - * If a mime type doesn't match any pattern provided, - * it should be treated as support level -2 (will be rejected). - */ - mime_types: Record - - /** The support level for captions within this file message type */ - caption?: CapabilitySupportLevel - /** The maximum length for captions (only applicable if captions are supported). */ - max_caption_length?: integer - /** The maximum file size as bytes. */ - max_size?: integer - /** For images and videos, the maximum width as pixels. */ - max_width?: integer - /** For images and videos, the maximum height as pixels. */ - max_height?: integer - /** For videos and audio files, the maximum duration as seconds. */ - max_duration?: seconds - - /** Can this type of file be sent as view-once media? */ - view_once?: boolean -} - -export enum DisappearingType { - None = "", - AfterRead = "after_read", - AfterSend = "after_send", -} - -export interface DisappearingTimerCapability { - types: DisappearingType[] - /** Allowed timer values. If omitted, any timer is allowed. */ - timers?: milliseconds[] - /** - * Whether clients should omit the empty disappearing_timer object in messages that they don't want to disappear - * - * Generally, bridged rooms will want the object to be always present, while native Matrix rooms don't, - * so the hardcoded features for Matrix rooms should set this to true, while bridges will not. - */ - omit_empty_timer?: true -} - -/** - * The support level for a feature. These are integers rather than booleans - * to accurately represent what the bridge is doing and hopefully make the - * state event more generally useful. Our clients should check for > 0 to - * determine if the feature should be allowed. - */ -export enum CapabilitySupportLevel { - /** The feature is unsupported and messages using it will be rejected. */ - Rejected = -2, - /** The feature is unsupported and has no fallback. The message will go through, but data may be lost. */ - Dropped = -1, - /** The feature is unsupported, but may have a fallback. The nature of the fallback depends on the context. */ - Unsupported = 0, - /** The feature is partially supported (e.g. it may be converted to a different format). */ - PartialSupport = 1, - /** The feature is fully supported and can be safely used. */ - FullySupported = 2, -} - -/** - * A formatting feature that consists of specific HTML tags and/or attributes. - */ -export enum FormattingFeature { - Bold = "bold", // strong, b - Italic = "italic", // em, i - Underline = "underline", // u - Strikethrough = "strikethrough", // del, s - InlineCode = "inline_code", // code - CodeBlock = "code_block", // pre + code - SyntaxHighlighting = "code_block.syntax_highlighting", //
      
      -	Blockquote = "blockquote", // blockquote
      -	InlineLink = "inline_link", // a
      -	UserLink = "user_link", // 
      -	RoomLink = "room_link", // 
      -	EventLink = "event_link", // 
      -	AtRoomMention = "at_room_mention", // @room (no html tag)
      -	UnorderedList = "unordered_list", // ul + li
      -	OrderedList = "ordered_list", // ol + li
      -	ListStart = "ordered_list.start", // 
        - ListJumpValue = "ordered_list.jump_value", //
      1. - CustomEmoji = "custom_emoji", // - Spoiler = "spoiler", // - SpoilerReason = "spoiler.reason", // - TextForegroundColor = "color.foreground", // - TextBackgroundColor = "color.background", // - HorizontalLine = "horizontal_line", // hr - Headers = "headers", // h1, h2, h3, h4, h5, h6 - Superscript = "superscript", // sup - Subscript = "subscript", // sub - Math = "math", // - DetailsSummary = "details_summary", //
        ......
        - Table = "table", // table, thead, tbody, tr, th, td -} diff --git a/mautrix-patched/event/capabilities.go b/mautrix-patched/event/capabilities.go deleted file mode 100644 index b5c226ac..00000000 --- a/mautrix-patched/event/capabilities.go +++ /dev/null @@ -1,416 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "fmt" - "io" - "mime" - "slices" - "strings" - - "go.mau.fi/util/exerrors" - "go.mau.fi/util/jsontime" - "go.mau.fi/util/ptr" - "golang.org/x/exp/constraints" - "golang.org/x/exp/maps" -) - -type RoomFeatures struct { - ID string `json:"id,omitempty"` - - // N.B. New fields need to be added to the Hash function to be included in the deduplication hash. - - Formatting FormattingFeatureMap `json:"formatting,omitempty"` - File FileFeatureMap `json:"file,omitempty"` - State StateFeatureMap `json:"state,omitempty"` - MemberActions MemberFeatureMap `json:"member_actions,omitempty"` - - MaxTextLength int `json:"max_text_length,omitempty"` - - LocationMessage CapabilitySupportLevel `json:"location_message,omitempty"` - Poll CapabilitySupportLevel `json:"poll,omitempty"` - Thread CapabilitySupportLevel `json:"thread,omitempty"` - Reply CapabilitySupportLevel `json:"reply,omitempty"` - - Edit CapabilitySupportLevel `json:"edit,omitempty"` - EditMaxCount int `json:"edit_max_count,omitempty"` - EditMaxAge *jsontime.Seconds `json:"edit_max_age,omitempty"` - Delete CapabilitySupportLevel `json:"delete,omitempty"` - DeleteForMe bool `json:"delete_for_me,omitempty"` - DeleteMaxAge *jsontime.Seconds `json:"delete_max_age,omitempty"` - DeleteHide bool `json:"delete_hide_placeholder,omitempty"` - - DisappearingTimer *DisappearingTimerCapability `json:"disappearing_timer,omitempty"` - - Reaction CapabilitySupportLevel `json:"reaction,omitempty"` - ReactionCount int `json:"reaction_count,omitempty"` - AllowedReactions []string `json:"allowed_reactions,omitempty"` - CustomEmojiReactions bool `json:"custom_emoji_reactions,omitempty"` - - ReadReceipts bool `json:"read_receipts,omitempty"` - TypingNotifications bool `json:"typing_notifications,omitempty"` - Archive bool `json:"archive,omitempty"` - MarkAsUnread bool `json:"mark_as_unread,omitempty"` - DeleteChat bool `json:"delete_chat,omitempty"` - DeleteChatForEveryone bool `json:"delete_chat_for_everyone,omitempty"` - - MessageRequest *MessageRequestFeatures `json:"message_request,omitempty"` - - PerMessageProfileRelay bool `json:"-"` -} - -func (rf *RoomFeatures) GetID() string { - if rf.ID != "" { - return rf.ID - } - return base64.RawURLEncoding.EncodeToString(rf.Hash()) -} - -func (rf *RoomFeatures) Clone() *RoomFeatures { - if rf == nil { - return nil - } - clone := *rf - clone.File = clone.File.Clone() - clone.Formatting = maps.Clone(clone.Formatting) - clone.State = clone.State.Clone() - clone.MemberActions = clone.MemberActions.Clone() - clone.EditMaxAge = ptr.Clone(clone.EditMaxAge) - clone.DeleteMaxAge = ptr.Clone(clone.DeleteMaxAge) - clone.DisappearingTimer = clone.DisappearingTimer.Clone() - clone.AllowedReactions = slices.Clone(clone.AllowedReactions) - clone.MessageRequest = clone.MessageRequest.Clone() - return &clone -} - -type MemberFeatureMap map[MemberAction]CapabilitySupportLevel - -func (mfm MemberFeatureMap) Clone() MemberFeatureMap { - return maps.Clone(mfm) -} - -type MemberAction string - -const ( - MemberActionBan MemberAction = "ban" - MemberActionKick MemberAction = "kick" - MemberActionLeave MemberAction = "leave" - MemberActionRevokeInvite MemberAction = "revoke_invite" - MemberActionInvite MemberAction = "invite" -) - -type StateFeatureMap map[string]*StateFeatures - -func (sfm StateFeatureMap) Clone() StateFeatureMap { - dup := maps.Clone(sfm) - for key, value := range dup { - dup[key] = value.Clone() - } - return dup -} - -type StateFeatures struct { - Level CapabilitySupportLevel `json:"level"` -} - -func (sf *StateFeatures) Clone() *StateFeatures { - if sf == nil { - return nil - } - clone := *sf - return &clone -} - -func (sf *StateFeatures) Hash() []byte { - return sf.Level.Hash() -} - -type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel - -type FileFeatureMap map[CapabilityMsgType]*FileFeatures - -func (ffm FileFeatureMap) Clone() FileFeatureMap { - dup := maps.Clone(ffm) - for key, value := range dup { - dup[key] = value.Clone() - } - return dup -} - -type DisappearingTimerCapability struct { - Types []DisappearingType `json:"types"` - Timers []jsontime.Milliseconds `json:"timers,omitempty"` - - OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` -} - -func (dtc *DisappearingTimerCapability) Clone() *DisappearingTimerCapability { - if dtc == nil { - return nil - } - clone := *dtc - clone.Types = slices.Clone(clone.Types) - clone.Timers = slices.Clone(clone.Timers) - return &clone -} - -func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { - if dtc == nil || content == nil || content.Type == DisappearingTypeNone { - return true - } - return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer)) -} - -type MessageRequestFeatures struct { - AcceptWithMessage CapabilitySupportLevel `json:"accept_with_message,omitempty"` - AcceptWithButton CapabilitySupportLevel `json:"accept_with_button,omitempty"` -} - -func (mrf *MessageRequestFeatures) Clone() *MessageRequestFeatures { - return ptr.Clone(mrf) -} - -func (mrf *MessageRequestFeatures) Hash() []byte { - if mrf == nil { - return nil - } - hasher := sha256.New() - hashValue(hasher, "accept_with_message", mrf.AcceptWithMessage) - hashValue(hasher, "accept_with_button", mrf.AcceptWithButton) - return hasher.Sum(nil) -} - -type CapabilityMsgType = MessageType - -// Message types which are used for event capability signaling, but aren't real values for the msgtype field. -const ( - CapMsgVoice CapabilityMsgType = "org.matrix.msc3245.voice" - CapMsgGIF CapabilityMsgType = "fi.mau.gif" - CapMsgSticker CapabilityMsgType = "m.sticker" -) - -type CapabilitySupportLevel int - -func (csl CapabilitySupportLevel) Partial() bool { - return csl >= CapLevelPartialSupport -} - -func (csl CapabilitySupportLevel) Full() bool { - return csl >= CapLevelFullySupported -} - -func (csl CapabilitySupportLevel) Reject() bool { - return csl <= CapLevelRejected -} - -const ( - CapLevelRejected CapabilitySupportLevel = -2 // The feature is unsupported and messages using it will be rejected. - CapLevelDropped CapabilitySupportLevel = -1 // The feature is unsupported and has no fallback. The message will go through, but data may be lost. - CapLevelUnsupported CapabilitySupportLevel = 0 // The feature is unsupported, but may have a fallback. - CapLevelPartialSupport CapabilitySupportLevel = 1 // The feature is partially supported (e.g. it may be converted to a different format). - CapLevelFullySupported CapabilitySupportLevel = 2 // The feature is fully supported and can be safely used. -) - -type FormattingFeature string - -const ( - FmtBold FormattingFeature = "bold" // strong, b - FmtItalic FormattingFeature = "italic" // em, i - FmtUnderline FormattingFeature = "underline" // u - FmtStrikethrough FormattingFeature = "strikethrough" // del, s - FmtInlineCode FormattingFeature = "inline_code" // code - FmtCodeBlock FormattingFeature = "code_block" // pre + code - FmtSyntaxHighlighting FormattingFeature = "code_block.syntax_highlighting" //
        
        -	FmtBlockquote          FormattingFeature = "blockquote"                     // blockquote
        -	FmtInlineLink          FormattingFeature = "inline_link"                    // a
        -	FmtUserLink            FormattingFeature = "user_link"                      // 
        -	FmtRoomLink            FormattingFeature = "room_link"                      // 
        -	FmtEventLink           FormattingFeature = "event_link"                     // 
        -	FmtAtRoomMention       FormattingFeature = "at_room_mention"                // @room (no html tag)
        -	FmtUnorderedList       FormattingFeature = "unordered_list"                 // ul + li
        -	FmtOrderedList         FormattingFeature = "ordered_list"                   // ol + li
        -	FmtListStart           FormattingFeature = "ordered_list.start"             // 
          - FmtListJumpValue FormattingFeature = "ordered_list.jump_value" //
        1. - FmtCustomEmoji FormattingFeature = "custom_emoji" // - FmtSpoiler FormattingFeature = "spoiler" // - FmtSpoilerReason FormattingFeature = "spoiler.reason" // - FmtTextForegroundColor FormattingFeature = "color.foreground" // - FmtTextBackgroundColor FormattingFeature = "color.background" // - FmtHorizontalLine FormattingFeature = "horizontal_line" // hr - FmtHeaders FormattingFeature = "headers" // h1, h2, h3, h4, h5, h6 - FmtSuperscript FormattingFeature = "superscript" // sup - FmtSubscript FormattingFeature = "subscript" // sub - FmtMath FormattingFeature = "math" // - FmtDetailsSummary FormattingFeature = "details_summary" //
          ......
          - FmtTable FormattingFeature = "table" // table, thead, tbody, tr, th, td -) - -type FileFeatures struct { - // N.B. New fields need to be added to the Hash function to be included in the deduplication hash. - - MimeTypes map[string]CapabilitySupportLevel `json:"mime_types"` - - Caption CapabilitySupportLevel `json:"caption,omitempty"` - MaxCaptionLength int `json:"max_caption_length,omitempty"` - - MaxSize int64 `json:"max_size,omitempty"` - MaxWidth int `json:"max_width,omitempty"` - MaxHeight int `json:"max_height,omitempty"` - MaxDuration *jsontime.Seconds `json:"max_duration,omitempty"` - - ViewOnce bool `json:"view_once,omitempty"` -} - -func (ff *FileFeatures) GetMimeSupport(inputType string) CapabilitySupportLevel { - match, ok := ff.MimeTypes[inputType] - if ok { - return match - } - if strings.IndexByte(inputType, ';') != -1 { - plainMime, _, _ := mime.ParseMediaType(inputType) - if plainMime != "" { - if match, ok = ff.MimeTypes[plainMime]; ok { - return match - } - } - } - if slash := strings.IndexByte(inputType, '/'); slash > 0 { - generalType := fmt.Sprintf("%s/*", inputType[:slash]) - if match, ok = ff.MimeTypes[generalType]; ok { - return match - } - } - match, ok = ff.MimeTypes["*/*"] - if ok { - return match - } - return CapLevelRejected -} - -type hashable interface { - Hash() []byte -} - -func hashMap[Key ~string, Value hashable](w io.Writer, name string, data map[Key]Value) { - keys := maps.Keys(data) - slices.Sort(keys) - exerrors.Must(w.Write([]byte(name))) - for _, key := range keys { - exerrors.Must(w.Write([]byte(key))) - exerrors.Must(w.Write(data[key].Hash())) - exerrors.Must(w.Write([]byte{0})) - } -} - -func hashValue(w io.Writer, name string, data hashable) { - exerrors.Must(w.Write([]byte(name))) - exerrors.Must(w.Write(data.Hash())) -} - -func hashInt[T constraints.Integer](w io.Writer, name string, data T) { - exerrors.Must(w.Write(binary.BigEndian.AppendUint64([]byte(name), uint64(data)))) -} - -func hashBool[T ~bool](w io.Writer, name string, data T) { - exerrors.Must(w.Write([]byte(name))) - if data { - exerrors.Must(w.Write([]byte{1})) - } else { - exerrors.Must(w.Write([]byte{0})) - } -} - -func (csl CapabilitySupportLevel) Hash() []byte { - return []byte{byte(csl + 128)} -} - -func (rf *RoomFeatures) Hash() []byte { - hasher := sha256.New() - - hashMap(hasher, "formatting", rf.Formatting) - hashMap(hasher, "file", rf.File) - hashMap(hasher, "state", rf.State) - hashMap(hasher, "member_actions", rf.MemberActions) - - hashInt(hasher, "max_text_length", rf.MaxTextLength) - - hashValue(hasher, "location_message", rf.LocationMessage) - hashValue(hasher, "poll", rf.Poll) - hashValue(hasher, "thread", rf.Thread) - hashValue(hasher, "reply", rf.Reply) - - hashValue(hasher, "edit", rf.Edit) - hashInt(hasher, "edit_max_count", rf.EditMaxCount) - hashInt(hasher, "edit_max_age", rf.EditMaxAge.Get()) - - hashValue(hasher, "delete", rf.Delete) - hashBool(hasher, "delete_for_me", rf.DeleteForMe) - hashInt(hasher, "delete_max_age", rf.DeleteMaxAge.Get()) - hashBool(hasher, "delete_hide_placeholder", rf.DeleteHide) - hashValue(hasher, "disappearing_timer", rf.DisappearingTimer) - - hashValue(hasher, "reaction", rf.Reaction) - hashInt(hasher, "reaction_count", rf.ReactionCount) - hasher.Write([]byte("allowed_reactions")) - for _, reaction := range rf.AllowedReactions { - hasher.Write([]byte(reaction)) - } - hashBool(hasher, "custom_emoji_reactions", rf.CustomEmojiReactions) - - hashBool(hasher, "read_receipts", rf.ReadReceipts) - hashBool(hasher, "typing_notifications", rf.TypingNotifications) - hashBool(hasher, "archive", rf.Archive) - hashBool(hasher, "mark_as_unread", rf.MarkAsUnread) - hashBool(hasher, "delete_chat", rf.DeleteChat) - hashBool(hasher, "delete_chat_for_everyone", rf.DeleteChatForEveryone) - hashValue(hasher, "message_request", rf.MessageRequest) - - return hasher.Sum(nil) -} - -func (dtc *DisappearingTimerCapability) Hash() []byte { - if dtc == nil { - return nil - } - hasher := sha256.New() - hasher.Write([]byte("types")) - for _, t := range dtc.Types { - hasher.Write([]byte(t)) - } - hasher.Write([]byte("timers")) - for _, timer := range dtc.Timers { - hashInt(hasher, "", timer.Milliseconds()) - } - return hasher.Sum(nil) -} - -func (ff *FileFeatures) Hash() []byte { - hasher := sha256.New() - hashMap(hasher, "mime_types", ff.MimeTypes) - hashValue(hasher, "caption", ff.Caption) - hashInt(hasher, "max_caption_length", ff.MaxCaptionLength) - hashInt(hasher, "max_size", ff.MaxSize) - hashInt(hasher, "max_width", ff.MaxWidth) - hashInt(hasher, "max_height", ff.MaxHeight) - hashInt(hasher, "max_duration", ff.MaxDuration.Get()) - hashBool(hasher, "view_once", ff.ViewOnce) - return hasher.Sum(nil) -} - -func (ff *FileFeatures) Clone() *FileFeatures { - if ff == nil { - return nil - } - clone := *ff - clone.MimeTypes = maps.Clone(clone.MimeTypes) - clone.MaxDuration = ptr.Clone(clone.MaxDuration) - return &clone -} diff --git a/mautrix-patched/event/cmdschema/content.go b/mautrix-patched/event/cmdschema/content.go deleted file mode 100644 index ce07c4c0..00000000 --- a/mautrix-patched/event/cmdschema/content.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cmdschema - -import ( - "crypto/sha256" - "encoding/base64" - "fmt" - "reflect" - "slices" - - "go.mau.fi/util/exsync" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type EventContent struct { - Command string `json:"command"` - Aliases []string `json:"aliases,omitempty"` - Parameters []*Parameter `json:"parameters,omitempty"` - Description *event.ExtensibleTextContainer `json:"description,omitempty"` - TailParam string `json:"fi.mau.tail_parameter,omitempty"` -} - -func (ec *EventContent) Validate() error { - if ec == nil { - return fmt.Errorf("event content is nil") - } else if ec.Command == "" { - return fmt.Errorf("command is empty") - } - var tailFound bool - dupMap := exsync.NewSet[string]() - for i, p := range ec.Parameters { - if err := p.Validate(); err != nil { - return fmt.Errorf("parameter %q (#%d) is invalid: %w", ptr.Val(p).Key, i+1, err) - } else if !dupMap.Add(p.Key) { - return fmt.Errorf("duplicate parameter key %q at #%d", p.Key, i+1) - } else if p.Key == ec.TailParam { - tailFound = true - } else if tailFound && !p.Optional { - return fmt.Errorf("required parameter %q (#%d) is after tail parameter %q", p.Key, i+1, ec.TailParam) - } - } - if ec.TailParam != "" && !tailFound { - return fmt.Errorf("tail parameter %q not found in parameters", ec.TailParam) - } - return nil -} - -func (ec *EventContent) IsValid() bool { - return ec.Validate() == nil -} - -func (ec *EventContent) StateKey(owner id.UserID) string { - hash := sha256.Sum256([]byte(ec.Command + owner.String())) - return base64.StdEncoding.EncodeToString(hash[:]) -} - -func (ec *EventContent) Equals(other *EventContent) bool { - if ec == nil || other == nil { - return ec == other - } - return ec.Command == other.Command && - slices.Equal(ec.Aliases, other.Aliases) && - slices.EqualFunc(ec.Parameters, other.Parameters, (*Parameter).Equals) && - ec.Description.Equals(other.Description) && - ec.TailParam == other.TailParam -} - -func init() { - event.TypeMap[event.StateMSC4391BotCommand] = reflect.TypeOf(EventContent{}) -} diff --git a/mautrix-patched/event/cmdschema/parameter.go b/mautrix-patched/event/cmdschema/parameter.go deleted file mode 100644 index 4193b297..00000000 --- a/mautrix-patched/event/cmdschema/parameter.go +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cmdschema - -import ( - "encoding/json" - "fmt" - "slices" - - "go.mau.fi/util/exslices" - - "maunium.net/go/mautrix/event" -) - -type Parameter struct { - Key string `json:"key"` - Schema *ParameterSchema `json:"schema"` - Optional bool `json:"optional,omitempty"` - Description *event.ExtensibleTextContainer `json:"description,omitempty"` - DefaultValue any `json:"fi.mau.default_value,omitempty"` -} - -func (p *Parameter) Equals(other *Parameter) bool { - if p == nil || other == nil { - return p == other - } - return p.Key == other.Key && - p.Schema.Equals(other.Schema) && - p.Optional == other.Optional && - p.Description.Equals(other.Description) && - p.DefaultValue == other.DefaultValue // TODO this won't work for room/event ID values -} - -func (p *Parameter) Validate() error { - if p == nil { - return fmt.Errorf("parameter is nil") - } else if p.Key == "" { - return fmt.Errorf("key is empty") - } - return p.Schema.Validate() -} - -func (p *Parameter) IsValid() bool { - return p.Validate() == nil -} - -func (p *Parameter) GetDefaultValue() any { - if p != nil && p.DefaultValue != nil { - return p.DefaultValue - } else if p == nil || p.Optional { - return nil - } - return p.Schema.GetDefaultValue() -} - -type PrimitiveType string - -const ( - PrimitiveTypeString PrimitiveType = "string" - PrimitiveTypeInteger PrimitiveType = "integer" - PrimitiveTypeBoolean PrimitiveType = "boolean" - PrimitiveTypeServerName PrimitiveType = "server_name" - PrimitiveTypeUserID PrimitiveType = "user_id" - PrimitiveTypeRoomID PrimitiveType = "room_id" - PrimitiveTypeRoomAlias PrimitiveType = "room_alias" - PrimitiveTypeEventID PrimitiveType = "event_id" -) - -func (pt PrimitiveType) Schema() *ParameterSchema { - return &ParameterSchema{ - SchemaType: SchemaTypePrimitive, - Type: pt, - } -} - -func (pt PrimitiveType) IsValid() bool { - switch pt { - case PrimitiveTypeString, - PrimitiveTypeInteger, - PrimitiveTypeBoolean, - PrimitiveTypeServerName, - PrimitiveTypeUserID, - PrimitiveTypeRoomID, - PrimitiveTypeRoomAlias, - PrimitiveTypeEventID: - return true - default: - return false - } -} - -type SchemaType string - -const ( - SchemaTypePrimitive SchemaType = "primitive" - SchemaTypeArray SchemaType = "array" - SchemaTypeUnion SchemaType = "union" - SchemaTypeLiteral SchemaType = "literal" -) - -type ParameterSchema struct { - SchemaType SchemaType `json:"schema_type"` - Type PrimitiveType `json:"type,omitempty"` // Only for primitive - Items *ParameterSchema `json:"items,omitempty"` // Only for array - Variants []*ParameterSchema `json:"variants,omitempty"` // Only for union - Value any `json:"value,omitempty"` // Only for literal -} - -func Literal(value any) *ParameterSchema { - return &ParameterSchema{ - SchemaType: SchemaTypeLiteral, - Value: value, - } -} - -func Enum(values ...any) *ParameterSchema { - return Union(exslices.CastFunc(values, Literal)...) -} - -func flattenUnion(variants []*ParameterSchema) []*ParameterSchema { - var flattened []*ParameterSchema - for _, variant := range variants { - switch variant.SchemaType { - case SchemaTypeArray: - panic(fmt.Errorf("illegal array schema in union")) - case SchemaTypeUnion: - flattened = append(flattened, flattenUnion(variant.Variants)...) - default: - flattened = append(flattened, variant) - } - } - return flattened -} - -func Union(variants ...*ParameterSchema) *ParameterSchema { - needsFlattening := false - for _, variant := range variants { - if variant.SchemaType == SchemaTypeArray { - panic(fmt.Errorf("illegal array schema in union")) - } else if variant.SchemaType == SchemaTypeUnion { - needsFlattening = true - } - } - if needsFlattening { - variants = flattenUnion(variants) - } - return &ParameterSchema{ - SchemaType: SchemaTypeUnion, - Variants: variants, - } -} - -func Array(items *ParameterSchema) *ParameterSchema { - if items.SchemaType == SchemaTypeArray { - panic(fmt.Errorf("illegal array schema in array")) - } - return &ParameterSchema{ - SchemaType: SchemaTypeArray, - Items: items, - } -} - -func (ps *ParameterSchema) GetDefaultValue() any { - if ps == nil { - return nil - } - switch ps.SchemaType { - case SchemaTypePrimitive: - switch ps.Type { - case PrimitiveTypeInteger: - return 0 - case PrimitiveTypeBoolean: - return false - default: - return "" - } - case SchemaTypeArray: - return []any{} - case SchemaTypeUnion: - if len(ps.Variants) > 0 { - return ps.Variants[0].GetDefaultValue() - } - return nil - case SchemaTypeLiteral: - return ps.Value - default: - return nil - } -} - -func (ps *ParameterSchema) IsValid() bool { - return ps.validate("") == nil -} - -func (ps *ParameterSchema) Validate() error { - return ps.validate("") -} - -func (ps *ParameterSchema) validate(parent SchemaType) error { - if ps == nil { - return fmt.Errorf("schema is nil") - } - switch ps.SchemaType { - case SchemaTypePrimitive: - if !ps.Type.IsValid() { - return fmt.Errorf("invalid primitive type %s", ps.Type) - } else if ps.Items != nil || ps.Variants != nil || ps.Value != nil { - return fmt.Errorf("primitive schema has extra fields") - } - return nil - case SchemaTypeArray: - if parent != "" { - return fmt.Errorf("arrays can't be nested in other types") - } else if err := ps.Items.validate(ps.SchemaType); err != nil { - return fmt.Errorf("item schema is invalid: %w", err) - } else if ps.Type != "" || ps.Variants != nil || ps.Value != nil { - return fmt.Errorf("array schema has extra fields") - } - return nil - case SchemaTypeUnion: - if len(ps.Variants) == 0 { - return fmt.Errorf("no variants specified for union") - } else if parent != "" && parent != SchemaTypeArray { - return fmt.Errorf("unions can't be nested in anything other than arrays") - } - for i, v := range ps.Variants { - if err := v.validate(ps.SchemaType); err != nil { - return fmt.Errorf("variant #%d is invalid: %w", i+1, err) - } - } - if ps.Type != "" || ps.Items != nil || ps.Value != nil { - return fmt.Errorf("union schema has extra fields") - } - return nil - case SchemaTypeLiteral: - switch typedVal := ps.Value.(type) { - case string, float64, int, int64, json.Number, bool, RoomIDValue, *RoomIDValue: - // ok - case map[string]any: - if typedVal["type"] != "event_id" && typedVal["type"] != "room_id" { - return fmt.Errorf("literal value has invalid map data") - } - default: - return fmt.Errorf("literal value has unsupported type %T", ps.Value) - } - if ps.Type != "" || ps.Items != nil || ps.Variants != nil { - return fmt.Errorf("literal schema has extra fields") - } - return nil - default: - return fmt.Errorf("invalid schema type %s", ps.SchemaType) - } -} - -func (ps *ParameterSchema) Equals(other *ParameterSchema) bool { - if ps == nil || other == nil { - return ps == other - } - return ps.SchemaType == other.SchemaType && - ps.Type == other.Type && - ps.Items.Equals(other.Items) && - slices.EqualFunc(ps.Variants, other.Variants, (*ParameterSchema).Equals) && - ps.Value == other.Value // TODO this won't work for room/event ID values -} - -func (ps *ParameterSchema) AllowsPrimitive(prim PrimitiveType) bool { - switch ps.SchemaType { - case SchemaTypePrimitive: - return ps.Type == prim - case SchemaTypeUnion: - for _, variant := range ps.Variants { - if variant.AllowsPrimitive(prim) { - return true - } - } - return false - case SchemaTypeArray: - return ps.Items.AllowsPrimitive(prim) - default: - return false - } -} diff --git a/mautrix-patched/event/cmdschema/parse.go b/mautrix-patched/event/cmdschema/parse.go deleted file mode 100644 index 92e69b60..00000000 --- a/mautrix-patched/event/cmdschema/parse.go +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cmdschema - -import ( - "encoding/json" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -const botArrayOpener = "<" -const botArrayCloser = ">" - -func parseQuoted(val string) (parsed, remaining string, quoted bool) { - if len(val) == 0 { - return - } - if !strings.HasPrefix(val, `"`) { - spaceIdx := strings.IndexByte(val, ' ') - if spaceIdx == -1 { - parsed = val - } else { - parsed = val[:spaceIdx] - remaining = strings.TrimLeft(val[spaceIdx+1:], " ") - } - return - } - val = val[1:] - var buf strings.Builder - for { - quoteIdx := strings.IndexByte(val, '"') - var valUntilQuote string - if quoteIdx == -1 { - valUntilQuote = val - } else { - valUntilQuote = val[:quoteIdx] - } - escapeIdx := strings.IndexByte(valUntilQuote, '\\') - if escapeIdx >= 0 { - buf.WriteString(val[:escapeIdx]) - if len(val) > escapeIdx+1 { - buf.WriteByte(val[escapeIdx+1]) - } - val = val[min(escapeIdx+2, len(val)):] - } else if quoteIdx >= 0 { - buf.WriteString(val[:quoteIdx]) - val = val[quoteIdx+1:] - break - } else if buf.Len() == 0 { - // Unterminated quote, no escape characters, val is the whole input - return val, "", true - } else { - // Unterminated quote, but there were escape characters previously - buf.WriteString(val) - val = "" - break - } - } - return buf.String(), strings.TrimLeft(val, " "), true -} - -// ParseInput tries to parse the given text into a bot command event matching this command definition. -// -// If the prefix doesn't match, this will return a nil content and nil error. -// If the prefix does match, some content is always returned, but there may still be an error if parsing failed. -func (ec *EventContent) ParseInput(owner id.UserID, sigils []string, input string) (content *event.MessageEventContent, err error) { - prefix := ec.parsePrefix(input, sigils, owner.String()) - if prefix == "" { - return nil, nil - } - content = &event.MessageEventContent{ - MsgType: event.MsgText, - Body: input, - Mentions: &event.Mentions{UserIDs: []id.UserID{owner}}, - MSC4391BotCommand: &event.MSC4391BotCommandInput{ - Command: ec.Command, - }, - } - content.MSC4391BotCommand.Arguments, err = ec.ParseArguments(input[len(prefix):]) - return content, err -} - -func (ec *EventContent) ParseArguments(input string) (json.RawMessage, error) { - args := make(map[string]any) - var retErr error - setError := func(err error) { - if err != nil && retErr == nil { - retErr = err - } - } - processParameter := func(param *Parameter, isLast, isTail, isNamed bool) { - origInput := input - var nextVal string - var wasQuoted bool - if param.Schema.SchemaType == SchemaTypeArray { - hasOpener := strings.HasPrefix(input, botArrayOpener) - arrayClosed := false - if hasOpener { - input = input[len(botArrayOpener):] - if strings.HasPrefix(input, botArrayCloser) { - input = strings.TrimLeft(input[len(botArrayCloser):], " ") - arrayClosed = true - } - } - var collector []any - for len(input) > 0 && !arrayClosed { - //origInput = input - nextVal, input, wasQuoted = parseQuoted(input) - if !wasQuoted && hasOpener && strings.HasSuffix(nextVal, botArrayCloser) { - // The value wasn't quoted and has the array delimiter at the end, close the array - nextVal = strings.TrimRight(nextVal, botArrayCloser) - arrayClosed = true - } else if hasOpener && strings.HasPrefix(input, botArrayCloser) { - // The value was quoted or there was a space, and the next character is the - // array delimiter, close the array - input = strings.TrimLeft(input[len(botArrayCloser):], " ") - arrayClosed = true - } else if !hasOpener && !isLast { - // For array arguments in the middle without the <> delimiters, stop after the first item - arrayClosed = true - } - parsedVal, err := param.Schema.Items.ParseString(nextVal) - if err == nil { - collector = append(collector, parsedVal) - } else if hasOpener || isLast { - setError(fmt.Errorf("failed to parse item #%d of array %s: %w", len(collector)+1, param.Key, err)) - } else { - //input = origInput - } - } - args[param.Key] = collector - } else { - nextVal, input, wasQuoted = parseQuoted(input) - if (isLast || isTail) && !wasQuoted && len(input) > 0 { - // If the last argument is not quoted, just treat the rest of the string - // as the argument without escapes (arguments with escapes should be quoted). - nextVal += " " + input - input = "" - } - // Special case for named boolean parameters: if no value is given, treat it as true - if nextVal == "" && !wasQuoted && isNamed && param.Schema.AllowsPrimitive(PrimitiveTypeBoolean) { - args[param.Key] = true - return - } - if nextVal == "" && !wasQuoted && !isNamed && !param.Optional { - setError(fmt.Errorf("missing value for required parameter %s", param.Key)) - } - parsedVal, err := param.Schema.ParseString(nextVal) - if err != nil { - args[param.Key] = param.GetDefaultValue() - // For optional parameters that fail to parse, restore the input and try passing it as the next parameter - if param.Optional && !isLast && !isNamed { - input = strings.TrimLeft(origInput, " ") - } else if !param.Optional || isNamed { - setError(fmt.Errorf("failed to parse %s: %w", param.Key, err)) - } - } else { - args[param.Key] = parsedVal - } - } - } - skipParams := make([]bool, len(ec.Parameters)) - for i, param := range ec.Parameters { - for strings.HasPrefix(input, "--") { - nameEndIdx := strings.IndexAny(input, " =") - if nameEndIdx == -1 { - nameEndIdx = len(input) - } - overrideParam, paramIdx := ec.parameterByName(input[2:nameEndIdx]) - if overrideParam != nil { - // Trim the equals sign, but leave spaces alone to let parseQuoted treat it as empty input - input = strings.TrimPrefix(input[nameEndIdx:], "=") - skipParams[paramIdx] = true - processParameter(overrideParam, false, false, true) - } else { - break - } - } - isTail := param.Key == ec.TailParam - if skipParams[i] || (param.Optional && !isTail) { - continue - } - processParameter(param, i == len(ec.Parameters)-1, isTail, false) - } - jsonArgs, marshalErr := json.Marshal(args) - if marshalErr != nil { - return nil, fmt.Errorf("failed to marshal arguments: %w", marshalErr) - } - return jsonArgs, retErr -} - -func (ec *EventContent) parameterByName(name string) (*Parameter, int) { - for i, param := range ec.Parameters { - if strings.EqualFold(param.Key, name) { - return param, i - } - } - return nil, -1 -} - -func (ec *EventContent) parsePrefix(origInput string, sigils []string, owner string) (prefix string) { - input := origInput - var chosenSigil string - for _, sigil := range sigils { - if strings.HasPrefix(input, sigil) { - chosenSigil = sigil - break - } - } - if chosenSigil == "" { - return "" - } - input = input[len(chosenSigil):] - var chosenAlias string - if !strings.HasPrefix(input, ec.Command) { - for _, alias := range ec.Aliases { - if strings.HasPrefix(input, alias) { - chosenAlias = alias - break - } - } - if chosenAlias == "" { - return "" - } - } else { - chosenAlias = ec.Command - } - input = strings.TrimPrefix(input[len(chosenAlias):], owner) - if input == "" || input[0] == ' ' { - input = strings.TrimLeft(input, " ") - return origInput[:len(origInput)-len(input)] - } - return "" -} - -func (pt PrimitiveType) ValidateValue(value any) bool { - _, err := pt.NormalizeValue(value) - return err == nil -} - -func normalizeNumber(value any) (int, error) { - switch typedValue := value.(type) { - case int: - return typedValue, nil - case int64: - return int(typedValue), nil - case float64: - return int(typedValue), nil - case json.Number: - if i, err := typedValue.Int64(); err != nil { - return 0, fmt.Errorf("failed to parse json.Number: %w", err) - } else { - return int(i), nil - } - default: - return 0, fmt.Errorf("unsupported type %T for integer", value) - } -} - -func (pt PrimitiveType) NormalizeValue(value any) (any, error) { - switch pt { - case PrimitiveTypeInteger: - return normalizeNumber(value) - case PrimitiveTypeBoolean: - bv, ok := value.(bool) - if !ok { - return nil, fmt.Errorf("unsupported type %T for boolean", value) - } - return bv, nil - case PrimitiveTypeString, PrimitiveTypeServerName: - str, ok := value.(string) - if !ok { - return nil, fmt.Errorf("unsupported type %T for string", value) - } - return str, pt.validateStringValue(str) - case PrimitiveTypeUserID, PrimitiveTypeRoomAlias: - str, ok := value.(string) - if !ok { - return nil, fmt.Errorf("unsupported type %T for user ID or room alias", value) - } else if plainErr := pt.validateStringValue(str); plainErr == nil { - return str, nil - } else if parsed, err := id.ParseMatrixURIOrMatrixToURL(str); err != nil { - return nil, fmt.Errorf("couldn't parse %q as plain ID nor matrix URI: %w / %w", value, plainErr, err) - } else if parsed.Sigil1 == '@' && pt == PrimitiveTypeUserID { - return parsed.UserID(), nil - } else if parsed.Sigil1 == '#' && pt == PrimitiveTypeRoomAlias { - return parsed.RoomAlias(), nil - } else { - return nil, fmt.Errorf("unexpected sigil %c for user ID or room alias", parsed.Sigil1) - } - case PrimitiveTypeRoomID, PrimitiveTypeEventID: - riv, err := NormalizeRoomIDValue(value) - if err != nil { - return nil, err - } - return riv, riv.Validate() - default: - return nil, fmt.Errorf("cannot normalize value for argument type %s", pt) - } -} - -func (pt PrimitiveType) validateStringValue(value string) error { - switch pt { - case PrimitiveTypeString: - return nil - case PrimitiveTypeServerName: - if !id.ValidateServerName(value) { - return fmt.Errorf("invalid server name: %q", value) - } - return nil - case PrimitiveTypeUserID: - _, _, err := id.UserID(value).ParseAndValidateRelaxed() - return err - case PrimitiveTypeRoomAlias: - sigil, localpart, serverName := id.ParseCommonIdentifier(value) - if sigil != '#' || localpart == "" || serverName == "" { - return fmt.Errorf("invalid room alias: %q", value) - } else if !id.ValidateServerName(serverName) { - return fmt.Errorf("invalid server name in room alias: %q", serverName) - } - return nil - default: - panic(fmt.Errorf("validateStringValue called with invalid type %s", pt)) - } -} - -func parseBoolean(val string) (bool, error) { - if len(val) == 0 { - return false, fmt.Errorf("cannot parse empty string as boolean") - } - switch strings.ToLower(val) { - case "t", "true", "y", "yes", "1": - return true, nil - case "f", "false", "n", "no", "0": - return false, nil - default: - return false, fmt.Errorf("invalid boolean string: %q", val) - } -} - -var markdownLinkRegex = regexp.MustCompile(`^\[.+]\(([^)]+)\)$`) - -func parseRoomOrEventID(value string) (*RoomIDValue, error) { - if strings.HasPrefix(value, "[") && strings.Contains(value, "](") && strings.HasSuffix(value, ")") { - matches := markdownLinkRegex.FindStringSubmatch(value) - if len(matches) == 2 { - value = matches[1] - } - } - parsed, err := id.ParseMatrixURIOrMatrixToURL(value) - if err != nil && strings.HasPrefix(value, "!") { - return &RoomIDValue{ - Type: PrimitiveTypeRoomID, - RoomID: id.RoomID(value), - }, nil - } - if err != nil { - return nil, err - } else if parsed.Sigil1 != '!' { - return nil, fmt.Errorf("unexpected sigil %c for room ID", parsed.Sigil1) - } else if parsed.MXID2 != "" && parsed.Sigil2 != '$' { - return nil, fmt.Errorf("unexpected sigil %c for event ID", parsed.Sigil2) - } - valType := PrimitiveTypeRoomID - if parsed.MXID2 != "" { - valType = PrimitiveTypeEventID - } - return &RoomIDValue{ - Type: valType, - RoomID: parsed.RoomID(), - Via: parsed.Via, - EventID: parsed.EventID(), - }, nil -} - -func (pt PrimitiveType) ParseString(value string) (any, error) { - switch pt { - case PrimitiveTypeInteger: - return strconv.Atoi(value) - case PrimitiveTypeBoolean: - return parseBoolean(value) - case PrimitiveTypeString, PrimitiveTypeServerName, PrimitiveTypeUserID: - return value, pt.validateStringValue(value) - case PrimitiveTypeRoomAlias: - plainErr := pt.validateStringValue(value) - if plainErr == nil { - return value, nil - } - parsed, err := id.ParseMatrixURIOrMatrixToURL(value) - if err != nil { - return nil, fmt.Errorf("couldn't parse %q as plain room alias nor matrix URI: %w / %w", value, plainErr, err) - } else if parsed.Sigil1 != '#' { - return nil, fmt.Errorf("unexpected sigil %c for room alias", parsed.Sigil1) - } - return parsed.RoomAlias(), nil - case PrimitiveTypeRoomID, PrimitiveTypeEventID: - parsed, err := parseRoomOrEventID(value) - if err != nil { - return nil, err - } else if pt != parsed.Type { - return nil, fmt.Errorf("mismatching argument type: expected %s but got %s", pt, parsed.Type) - } - return parsed, nil - default: - return nil, fmt.Errorf("cannot parse string for argument type %s", pt) - } -} - -func (ps *ParameterSchema) ParseString(value string) (any, error) { - if ps == nil { - return nil, fmt.Errorf("parameter schema is nil") - } - switch ps.SchemaType { - case SchemaTypePrimitive: - return ps.Type.ParseString(value) - case SchemaTypeLiteral: - switch typedValue := ps.Value.(type) { - case string: - if value == typedValue { - return typedValue, nil - } else { - return nil, fmt.Errorf("literal value %q does not match %q", typedValue, value) - } - case int, int64, float64, json.Number: - expectedVal, _ := normalizeNumber(typedValue) - intVal, err := strconv.Atoi(value) - if err != nil { - return nil, fmt.Errorf("failed to parse integer literal: %w", err) - } else if intVal != expectedVal { - return nil, fmt.Errorf("literal value %d does not match %d", expectedVal, intVal) - } - return intVal, nil - case bool: - boolVal, err := parseBoolean(value) - if err != nil { - return nil, fmt.Errorf("failed to parse boolean literal: %w", err) - } else if boolVal != typedValue { - return nil, fmt.Errorf("literal value %t does not match %t", typedValue, boolVal) - } - return boolVal, nil - case RoomIDValue, *RoomIDValue, map[string]any, json.RawMessage: - expectedVal, _ := NormalizeRoomIDValue(typedValue) - parsed, err := parseRoomOrEventID(value) - if err != nil { - return nil, fmt.Errorf("failed to parse room or event ID literal: %w", err) - } else if !parsed.Equals(expectedVal) { - return nil, fmt.Errorf("literal value %s does not match %s", expectedVal, parsed) - } - return parsed, nil - default: - return nil, fmt.Errorf("unsupported literal type %T", ps.Value) - } - case SchemaTypeUnion: - var errs []error - for _, variant := range ps.Variants { - if parsed, err := variant.ParseString(value); err == nil { - return parsed, nil - } else { - errs = append(errs, err) - } - } - return nil, fmt.Errorf("no union variant matched: %w", errors.Join(errs...)) - case SchemaTypeArray: - return nil, fmt.Errorf("cannot parse string for array schema type") - default: - return nil, fmt.Errorf("unknown schema type %s", ps.SchemaType) - } -} diff --git a/mautrix-patched/event/cmdschema/parse_test.go b/mautrix-patched/event/cmdschema/parse_test.go deleted file mode 100644 index 1e0d1817..00000000 --- a/mautrix-patched/event/cmdschema/parse_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cmdschema - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "go.mau.fi/util/exbytes" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/event/cmdschema/testdata" -) - -type QuoteParseOutput struct { - Parsed string - Remaining string - Quoted bool -} - -func (qpo *QuoteParseOutput) UnmarshalJSON(data []byte) error { - var arr []any - if err := json.Unmarshal(data, &arr); err != nil { - return err - } - qpo.Parsed = arr[0].(string) - qpo.Remaining = arr[1].(string) - qpo.Quoted = arr[2].(bool) - return nil -} - -type QuoteParseTestData struct { - Name string `json:"name"` - Input string `json:"input"` - Output QuoteParseOutput `json:"output"` -} - -func loadFile[T any](name string) (into T) { - quoteData := exerrors.Must(testdata.FS.ReadFile(name)) - exerrors.PanicIfNotNil(json.Unmarshal(quoteData, &into)) - return -} - -func TestParseQuoted(t *testing.T) { - qptd := loadFile[[]QuoteParseTestData]("parse_quote.json") - for _, test := range qptd { - t.Run(test.Name, func(t *testing.T) { - parsed, remaining, quoted := parseQuoted(test.Input) - assert.Equalf(t, test.Output, QuoteParseOutput{ - Parsed: parsed, - Remaining: remaining, - Quoted: quoted, - }, "Failed with input `%s`", test.Input) - // Note: can't just test that requoted == input, because some inputs - // have unnecessary escapes which won't survive roundtripping - t.Run("roundtrip", func(t *testing.T) { - requoted := quoteString(parsed) + " " + remaining - reparsed, newRemaining, _ := parseQuoted(requoted) - assert.Equal(t, parsed, reparsed) - assert.Equal(t, remaining, newRemaining) - }) - }) - } -} - -type CommandTestData struct { - Spec *EventContent - Tests []*CommandTestUnit -} - -type CommandTestUnit struct { - Name string `json:"name"` - Input string `json:"input"` - Broken string `json:"broken,omitempty"` - Error bool `json:"error"` - Output json.RawMessage `json:"output"` -} - -func compactJSON(input json.RawMessage) json.RawMessage { - var buf bytes.Buffer - exerrors.PanicIfNotNil(json.Compact(&buf, input)) - return buf.Bytes() -} - -func TestMSC4391BotCommandEventContent_ParseInput(t *testing.T) { - for _, cmd := range exerrors.Must(testdata.FS.ReadDir("commands")) { - t.Run(strings.TrimSuffix(cmd.Name(), ".json"), func(t *testing.T) { - ctd := loadFile[CommandTestData]("commands/" + cmd.Name()) - for _, test := range ctd.Tests { - outputStr := exbytes.UnsafeString(compactJSON(test.Output)) - t.Run(test.Name, func(t *testing.T) { - if test.Broken != "" { - t.Skip(test.Broken) - } - output, err := ctd.Spec.ParseInput("@testbot", []string{"/"}, test.Input) - if test.Error { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - if outputStr == "null" { - assert.Nil(t, output) - } else { - assert.Equal(t, ctd.Spec.Command, output.MSC4391BotCommand.Command) - assert.Equalf(t, outputStr, exbytes.UnsafeString(output.MSC4391BotCommand.Arguments), "Input: %s", test.Input) - } - }) - } - }) - } -} diff --git a/mautrix-patched/event/cmdschema/roomid.go b/mautrix-patched/event/cmdschema/roomid.go deleted file mode 100644 index 98c421fc..00000000 --- a/mautrix-patched/event/cmdschema/roomid.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cmdschema - -import ( - "encoding/json" - "fmt" - "slices" - "strings" - - "maunium.net/go/mautrix/id" -) - -var ParameterSchemaJoinableRoom = Union( - PrimitiveTypeRoomID.Schema(), - PrimitiveTypeRoomAlias.Schema(), -) - -type RoomIDValue struct { - Type PrimitiveType `json:"type"` - RoomID id.RoomID `json:"id"` - Via []string `json:"via,omitempty"` - EventID id.EventID `json:"event_id,omitempty"` -} - -func NormalizeRoomIDValue(input any) (riv *RoomIDValue, err error) { - switch typedValue := input.(type) { - case map[string]any, json.RawMessage: - var raw json.RawMessage - if raw, err = json.Marshal(input); err != nil { - err = fmt.Errorf("failed to roundtrip room ID value: %w", err) - } else if err = json.Unmarshal(raw, &riv); err != nil { - err = fmt.Errorf("failed to roundtrip room ID value: %w", err) - } - case *RoomIDValue: - riv = typedValue - case RoomIDValue: - riv = &typedValue - default: - err = fmt.Errorf("unsupported type %T for room or event ID", input) - } - return -} - -func (riv *RoomIDValue) String() string { - return riv.URI().String() -} - -func (riv *RoomIDValue) URI() *id.MatrixURI { - if riv == nil { - return nil - } - switch riv.Type { - case PrimitiveTypeRoomID: - return riv.RoomID.URI(riv.Via...) - case PrimitiveTypeEventID: - return riv.RoomID.EventURI(riv.EventID, riv.Via...) - default: - return nil - } -} - -func (riv *RoomIDValue) Equals(other *RoomIDValue) bool { - if riv == nil || other == nil { - return riv == other - } - return riv.Type == other.Type && - riv.RoomID == other.RoomID && - riv.EventID == other.EventID && - slices.Equal(riv.Via, other.Via) -} - -func (riv *RoomIDValue) Validate() error { - if riv == nil { - return fmt.Errorf("value is nil") - } - switch riv.Type { - case PrimitiveTypeRoomID: - if riv.EventID != "" { - return fmt.Errorf("event ID must be empty for room ID type") - } - case PrimitiveTypeEventID: - if !strings.HasPrefix(riv.EventID.String(), "$") { - return fmt.Errorf("event ID not valid: %q", riv.EventID) - } - default: - return fmt.Errorf("unexpected type %s for room/event ID value", riv.Type) - } - for _, via := range riv.Via { - if !id.ValidateServerName(via) { - return fmt.Errorf("invalid server name %q in vias", via) - } - } - sigil, localpart, serverName := id.ParseCommonIdentifier(riv.RoomID) - if sigil != '!' { - return fmt.Errorf("room ID does not start with !: %q", riv.RoomID) - } else if localpart == "" && serverName == "" { - return fmt.Errorf("room ID has empty localpart and server name: %q", riv.RoomID) - } else if serverName != "" && !id.ValidateServerName(serverName) { - return fmt.Errorf("invalid server name %q in room ID", serverName) - } - return nil -} - -func (riv *RoomIDValue) IsValid() bool { - return riv.Validate() == nil -} - -type RoomIDOrString string - -func (ros *RoomIDOrString) UnmarshalJSON(data []byte) error { - if len(data) == 0 { - return fmt.Errorf("empty data for room ID or string") - } - if data[0] == '"' { - var str string - if err := json.Unmarshal(data, &str); err != nil { - return err - } - *ros = RoomIDOrString(str) - return nil - } - var riv RoomIDValue - if err := json.Unmarshal(data, &riv); err != nil { - return err - } else if err = riv.Validate(); err != nil { - return err - } - *ros = RoomIDOrString(riv.String()) - return nil -} diff --git a/mautrix-patched/event/cmdschema/stringify.go b/mautrix-patched/event/cmdschema/stringify.go deleted file mode 100644 index c5c57c53..00000000 --- a/mautrix-patched/event/cmdschema/stringify.go +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package cmdschema - -import ( - "encoding/json" - "strconv" - "strings" -) - -var quoteEscaper = strings.NewReplacer( - `"`, `\"`, - `\`, `\\`, -) - -const charsToQuote = ` \` + botArrayOpener + botArrayCloser - -func quoteString(val string) string { - if val == "" { - return `""` - } - val = quoteEscaper.Replace(val) - if strings.ContainsAny(val, charsToQuote) { - return `"` + val + `"` - } - return val -} - -func (ec *EventContent) StringifyArgs(args any) string { - var argMap map[string]any - switch typedArgs := args.(type) { - case json.RawMessage: - err := json.Unmarshal(typedArgs, &argMap) - if err != nil { - return "" - } - case map[string]any: - argMap = typedArgs - default: - if b, err := json.Marshal(args); err != nil { - return "" - } else if err = json.Unmarshal(b, &argMap); err != nil { - return "" - } - } - parts := make([]string, 0, len(ec.Parameters)) - for i, param := range ec.Parameters { - isLast := i == len(ec.Parameters)-1 - val := argMap[param.Key] - if val == nil { - val = param.DefaultValue - if val == nil && !param.Optional { - val = param.Schema.GetDefaultValue() - } - } - if val == nil { - continue - } - var stringified string - if param.Schema.SchemaType == SchemaTypeArray { - stringified = arrayArgumentToString(val, isLast) - } else { - stringified = singleArgumentToString(val) - } - if stringified != "" { - parts = append(parts, stringified) - } - } - return strings.Join(parts, " ") -} - -func arrayArgumentToString(val any, isLast bool) string { - valArr, ok := val.([]any) - if !ok { - return "" - } - parts := make([]string, 0, len(valArr)) - for _, elem := range valArr { - stringified := singleArgumentToString(elem) - if stringified != "" { - parts = append(parts, stringified) - } - } - joinedParts := strings.Join(parts, " ") - if isLast && len(parts) > 0 { - return joinedParts - } - return botArrayOpener + joinedParts + botArrayCloser -} - -func singleArgumentToString(val any) string { - switch typedVal := val.(type) { - case string: - return quoteString(typedVal) - case json.Number: - return typedVal.String() - case bool: - return strconv.FormatBool(typedVal) - case int: - return strconv.Itoa(typedVal) - case int64: - return strconv.FormatInt(typedVal, 10) - case float64: - return strconv.FormatInt(int64(typedVal), 10) - case map[string]any, json.RawMessage, RoomIDValue, *RoomIDValue: - normalized, err := NormalizeRoomIDValue(typedVal) - if err != nil { - return "" - } - uri := normalized.URI() - if uri == nil { - return "" - } - return quoteString(uri.String()) - default: - return "" - } -} diff --git a/mautrix-patched/event/cmdschema/testdata/commands.schema.json b/mautrix-patched/event/cmdschema/testdata/commands.schema.json deleted file mode 100644 index e53382db..00000000 --- a/mautrix-patched/event/cmdschema/testdata/commands.schema.json +++ /dev/null @@ -1,281 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema#", - "$id": "commands.schema.json", - "title": "ParseInput test cases", - "description": "JSON schema for test case files containing command specifications and test cases", - "type": "object", - "required": [ - "spec", - "tests" - ], - "additionalProperties": false, - "properties": { - "spec": { - "title": "MSC4391 Command Description", - "description": "JSON schema defining the structure of a bot command event content", - "type": "object", - "required": [ - "command" - ], - "additionalProperties": false, - "properties": { - "command": { - "type": "string", - "description": "The command name that triggers this bot command" - }, - "aliases": { - "type": "array", - "description": "Alternative names/aliases for this command", - "items": { - "type": "string" - } - }, - "parameters": { - "type": "array", - "description": "List of parameters accepted by this command", - "items": { - "$ref": "#/$defs/Parameter" - } - }, - "description": { - "$ref": "#/$defs/ExtensibleTextContainer", - "description": "Human-readable description of the command" - }, - "fi.mau.tail_parameter": { - "type": "string", - "description": "The key of the parameter that accepts remaining arguments as tail text" - }, - "source": { - "type": "string", - "description": "The user ID of the bot that responds to this command" - } - } - }, - "tests": { - "type": "array", - "description": "Array of test cases for the command", - "items": { - "type": "object", - "description": "A single test case for command parsing", - "required": [ - "name", - "input" - ], - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "description": "The name of the test case" - }, - "input": { - "type": "string", - "description": "The command input string to parse" - }, - "output": { - "description": "The expected parsed parameter values, or null if the parsing is expected to fail", - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "null" - } - ] - }, - "error": { - "type": "boolean", - "description": "Whether parsing should result in an error. May still produce output.", - "default": false - } - } - } - } - }, - "$defs": { - "ExtensibleTextContainer": { - "type": "object", - "description": "Container for text that can have multiple representations", - "required": [ - "m.text" - ], - "properties": { - "m.text": { - "type": "array", - "description": "Array of text representations in different formats", - "items": { - "$ref": "#/$defs/ExtensibleText" - } - } - } - }, - "ExtensibleText": { - "type": "object", - "description": "A text representation with a specific MIME type", - "required": [ - "body" - ], - "properties": { - "body": { - "type": "string", - "description": "The text content" - }, - "mimetype": { - "type": "string", - "description": "The MIME type of the text (e.g., text/plain, text/html)", - "default": "text/plain", - "examples": [ - "text/plain", - "text/html" - ] - } - } - }, - "Parameter": { - "type": "object", - "description": "A parameter definition for a command", - "required": [ - "key", - "schema" - ], - "additionalProperties": false, - "properties": { - "key": { - "type": "string", - "description": "The identifier for this parameter" - }, - "schema": { - "$ref": "#/$defs/ParameterSchema", - "description": "The schema defining the type and structure of this parameter" - }, - "optional": { - "type": "boolean", - "description": "Whether this parameter is optional", - "default": false - }, - "description": { - "$ref": "#/$defs/ExtensibleTextContainer", - "description": "Human-readable description of this parameter" - }, - "fi.mau.default_value": { - "description": "Default value for this parameter if not provided" - } - } - }, - "ParameterSchema": { - "type": "object", - "description": "Schema definition for a parameter value", - "required": [ - "schema_type" - ], - "additionalProperties": false, - "properties": { - "schema_type": { - "type": "string", - "enum": [ - "primitive", - "array", - "union", - "literal" - ], - "description": "The type of schema" - } - }, - "allOf": [ - { - "if": { - "properties": { - "schema_type": { - "const": "primitive" - } - } - }, - "then": { - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "string", - "integer", - "boolean", - "server_name", - "user_id", - "room_id", - "room_alias", - "event_id" - ], - "description": "The primitive type (only for schema_type: primitive)" - } - } - } - }, - { - "if": { - "properties": { - "schema_type": { - "const": "array" - } - } - }, - "then": { - "required": [ - "items" - ], - "properties": { - "items": { - "$ref": "#/$defs/ParameterSchema", - "description": "The schema for array items (only for schema_type: array)" - } - } - } - }, - { - "if": { - "properties": { - "schema_type": { - "const": "union" - } - } - }, - "then": { - "required": [ - "variants" - ], - "properties": { - "variants": { - "type": "array", - "description": "The possible variants (only for schema_type: union)", - "items": { - "$ref": "#/$defs/ParameterSchema" - }, - "minItems": 1 - } - } - } - }, - { - "if": { - "properties": { - "schema_type": { - "const": "literal" - } - } - }, - "then": { - "required": [ - "value" - ], - "properties": { - "value": { - "description": "The literal value (only for schema_type: literal)" - } - } - } - } - ] - } - } -} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/flags.json b/mautrix-patched/event/cmdschema/testdata/commands/flags.json deleted file mode 100644 index 6ce1f4da..00000000 --- a/mautrix-patched/event/cmdschema/testdata/commands/flags.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "$schema": "../commands.schema.json#", - "spec": { - "command": "flag", - "source": "@testbot", - "parameters": [ - { - "key": "meow", - "schema": { - "schema_type": "primitive", - "type": "string" - } - }, - { - "key": "user", - "schema": { - "schema_type": "primitive", - "type": "user_id" - }, - "optional": true - }, - { - "key": "woof", - "schema": { - "schema_type": "primitive", - "type": "boolean" - }, - "optional": true, - "fi.mau.default_value": false - } - ], - "fi.mau.tail_parameter": "user" - }, - "tests": [ - { - "name": "no flags", - "input": "/flag mrrp", - "output": { - "meow": "mrrp", - "user": null - } - }, - { - "name": "no flags, has tail", - "input": "/flag mrrp @user:example.com", - "output": { - "meow": "mrrp", - "user": "@user:example.com" - } - }, - { - "name": "named flag at start", - "input": "/flag --woof=yes mrrp @user:example.com", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": true - } - }, - { - "name": "boolean flag without value", - "input": "/flag --woof mrrp @user:example.com", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": true - } - }, - { - "name": "user id flag without value", - "input": "/flag --user --woof mrrp", - "error": true, - "output": { - "meow": "mrrp", - "user": null, - "woof": true - } - }, - { - "name": "named flag in the middle", - "input": "/flag mrrp --woof=yes @user:example.com", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": true - } - }, - { - "name": "named flag in the middle with different value", - "input": "/flag mrrp --woof=no @user:example.com", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": false - } - }, - { - "name": "all variables named", - "input": "/flag --woof=no --meow=mrrp --user=@user:example.com", - "output": { - "meow": "mrrp", - "user": "@user:example.com", - "woof": false - } - }, - { - "name": "all variables named with quotes", - "input": "/flag --woof --meow=\"meow meow mrrp\" --user=\"@user:example.com\"", - "output": { - "meow": "meow meow mrrp", - "user": "@user:example.com", - "woof": true - } - }, - { - "name": "invalid value for named parameter", - "input": "/flag --user=meowings mrrp --woof", - "error": true, - "output": { - "meow": "mrrp", - "user": null, - "woof": true - } - } - ] -} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json b/mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json deleted file mode 100644 index 1351c292..00000000 --- a/mautrix-patched/event/cmdschema/testdata/commands/room_id_or_alias.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "../commands.schema.json#", - "spec": { - "command": "test room reference", - "source": "@testbot", - "parameters": [ - { - "key": "room", - "schema": { - "schema_type": "union", - "variants": [ - { - "schema_type": "primitive", - "type": "room_id" - }, - { - "schema_type": "primitive", - "type": "room_alias" - } - ] - } - } - ] - }, - "tests": [ - { - "name": "room alias", - "input": "/test room reference #test:matrix.org", - "output": { - "room": "#test:matrix.org" - } - }, - { - "name": "room id", - "input": "/test room reference !aiwVrNhPwbGBNjqlNu:matrix.org", - "output": { - "room": { - "type": "room_id", - "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" - } - } - }, - { - "name": "room id matrix.to link", - "input": "/test room reference https://matrix.to/#/!aiwVrNhPwbGBNjqlNu:matrix.org?via=example.com", - "output": { - "room": { - "type": "room_id", - "id": "!aiwVrNhPwbGBNjqlNu:matrix.org", - "via": [ - "example.com" - ] - } - } - }, - { - "name": "room id matrix.to link with url encoding", - "input": "/test room reference https://matrix.to/#/!%23test%2Froom%0Aversion%20%3Cu%3E11%3C%2Fu%3E%2C%20with%20%40%F0%9F%90%88%EF%B8%8F%3Amaunium.net?via=maunium.net", - "broken": "Go's url.URL does url decoding on the fragment, which breaks splitting the path segments properly", - "output": { - "room": { - "type": "room_id", - "id": "!#test/room\nversion 11, with @🐈️:maunium.net", - "via": [ - "maunium.net" - ] - } - } - }, - { - "name": "room id matrix: URI", - "input": "/test room reference matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", - "output": { - "room": { - "type": "room_id", - "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", - "via": [ - "maunium.net", - "matrix.org" - ] - } - } - } - ] -} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json b/mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json deleted file mode 100644 index aa266054..00000000 --- a/mautrix-patched/event/cmdschema/testdata/commands/room_reference_list.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "$schema": "../commands.schema.json#", - "spec": { - "command": "test room reference", - "source": "@testbot", - "parameters": [ - { - "key": "rooms", - "schema": { - "schema_type": "array", - "items": { - "schema_type": "union", - "variants": [ - { - "schema_type": "primitive", - "type": "room_id" - }, - { - "schema_type": "primitive", - "type": "room_alias" - } - ] - } - } - } - ] - }, - "tests": [ - { - "name": "room alias", - "input": "/test room reference #test:matrix.org", - "output": { - "rooms": [ - "#test:matrix.org" - ] - } - }, - { - "name": "room id", - "input": "/test room reference !aiwVrNhPwbGBNjqlNu:matrix.org", - "output": { - "rooms": [ - { - "type": "room_id", - "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" - } - ] - } - }, - { - "name": "two room ids", - "input": "/test room reference !mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ !aiwVrNhPwbGBNjqlNu:matrix.org", - "output": { - "rooms": [ - { - "type": "room_id", - "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ" - }, - { - "type": "room_id", - "id": "!aiwVrNhPwbGBNjqlNu:matrix.org" - } - ] - } - }, - { - "name": "room id matrix: URI", - "input": "/test room reference matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", - "output": { - "rooms": [ - { - "type": "room_id", - "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", - "via": [ - "maunium.net", - "matrix.org" - ] - } - ] - } - }, - { - "name": "room id matrix: URI and matrix.to URL", - "input": "/test room reference https://matrix.to/#/!aiwVrNhPwbGBNjqlNu:matrix.org?via=example.com matrix:roomid/mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ?via=maunium.net&via=matrix.org", - "output": { - "rooms": [ - { - "type": "room_id", - "id": "!aiwVrNhPwbGBNjqlNu:matrix.org", - "via": [ - "example.com" - ] - }, - { - "type": "room_id", - "id": "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", - "via": [ - "maunium.net", - "matrix.org" - ] - } - ] - } - } - ] -} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/simple.json b/mautrix-patched/event/cmdschema/testdata/commands/simple.json deleted file mode 100644 index 94667323..00000000 --- a/mautrix-patched/event/cmdschema/testdata/commands/simple.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "../commands.schema.json#", - "spec": { - "command": "test simple", - "source": "@testbot", - "parameters": [ - { - "key": "meow", - "schema": { - "schema_type": "primitive", - "type": "string" - } - } - ] - }, - "tests": [ - { - "name": "success", - "input": "/test simple mrrp", - "output": { - "meow": "mrrp" - } - }, - { - "name": "directed success", - "input": "/test simple@testbot mrrp", - "output": { - "meow": "mrrp" - } - }, - { - "name": "missing parameter", - "input": "/test simple", - "error": true, - "output": { - "meow": "" - } - }, - { - "name": "directed at another bot", - "input": "/test simple@anotherbot mrrp", - "error": false, - "output": null - } - ] -} diff --git a/mautrix-patched/event/cmdschema/testdata/commands/tail.json b/mautrix-patched/event/cmdschema/testdata/commands/tail.json deleted file mode 100644 index 9782f8ec..00000000 --- a/mautrix-patched/event/cmdschema/testdata/commands/tail.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "../commands.schema.json#", - "spec": { - "command": "tail", - "source": "@testbot", - "parameters": [ - { - "key": "meow", - "schema": { - "schema_type": "primitive", - "type": "string" - } - }, - { - "key": "reason", - "schema": { - "schema_type": "primitive", - "type": "string" - }, - "optional": true - }, - { - "key": "woof", - "schema": { - "schema_type": "primitive", - "type": "boolean" - }, - "optional": true - } - ], - "fi.mau.tail_parameter": "reason" - }, - "tests": [ - { - "name": "no tail or flag", - "input": "/tail mrrp", - "output": { - "meow": "mrrp", - "reason": "" - } - }, - { - "name": "tail, no flag", - "input": "/tail mrrp meow meow", - "output": { - "meow": "mrrp", - "reason": "meow meow" - } - }, - { - "name": "flag before tail", - "input": "/tail mrrp --woof meow meow", - "output": { - "meow": "mrrp", - "reason": "meow meow", - "woof": true - } - } - ] -} diff --git a/mautrix-patched/event/cmdschema/testdata/data.go b/mautrix-patched/event/cmdschema/testdata/data.go deleted file mode 100644 index eceea3d2..00000000 --- a/mautrix-patched/event/cmdschema/testdata/data.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package testdata - -import ( - "embed" -) - -//go:embed * -var FS embed.FS diff --git a/mautrix-patched/event/cmdschema/testdata/parse_quote.json b/mautrix-patched/event/cmdschema/testdata/parse_quote.json deleted file mode 100644 index 8f52b7f5..00000000 --- a/mautrix-patched/event/cmdschema/testdata/parse_quote.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - {"name": "empty string", "input": "", "output": ["", "", false]}, - {"name": "single word", "input": "meow", "output": ["meow", "", false]}, - {"name": "two words", "input": "meow woof", "output": ["meow", "woof", false]}, - {"name": "many words", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, - {"name": "extra spaces", "input": "meow meow mrrp", "output": ["meow", "meow mrrp", false]}, - {"name": "trailing space", "input": "meow ", "output": ["meow", "", false]}, - {"name": "only spaces", "input": " ", "output": ["", "", false]}, - {"name": "leading spaces", "input": " meow woof", "output": ["", "meow woof", false]}, - {"name": "backslash at end unquoted", "input": "meow\\ woof", "output": ["meow\\", "woof", false]}, - {"name": "quoted word", "input": "\"meow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, - {"name": "quoted words", "input": "\"meow meow\" mrrp", "output": ["meow meow", "mrrp", true]}, - {"name": "spaces in quotes", "input": "\" meow meow \" mrrp", "output": [" meow meow ", "mrrp", true]}, - {"name": "empty quoted string", "input": "\"\"", "output": ["", "", true]}, - {"name": "empty quoted with trailing", "input": "\"\" meow", "output": ["", "meow", true]}, - {"name": "quote no space before next", "input": "\"meow\"woof", "output": ["meow", "woof", true]}, - {"name": "just opening quote", "input": "\"", "output": ["", "", true]}, - {"name": "quote then space then text", "input": "\" meow", "output": [" meow", "", true]}, - {"name": "quotes after word", "input": "meow \" meow mrrp \"", "output": ["meow", "\" meow mrrp \"", false]}, - {"name": "escaped quote", "input": "\"meow\\\" meow\" mrrp", "output": ["meow\" meow", "mrrp", true]}, - {"name": "missing end quote", "input": "\"meow meow mrrp", "output": ["meow meow mrrp", "", true]}, - {"name": "missing end quote with escaped quote", "input": "\"meow\\\" meow mrrp", "output": ["meow\" meow mrrp", "", true]}, - {"name": "quote in the middle", "input": "me\"ow meow mrrp", "output": ["me\"ow", "meow mrrp", false]}, - {"name": "backslash in the middle", "input": "me\\ow meow mrrp", "output": ["me\\ow", "meow mrrp", false]}, - {"name": "other escaped character", "input": "\"m\\eow\" meow mrrp", "output": ["meow", "meow mrrp", true]}, - {"name": "escaped backslashes", "input": "\"m\\\\e\\\"ow\\\\\" meow mrrp", "output": ["m\\e\"ow\\", "meow mrrp", true]}, - {"name": "just quotes", "input": "\"\\\"\\\"\\\\\\\"\" meow", "output": ["\"\"\\\"", "meow", true]}, - {"name": "escape at eof", "input": "\"meow\\", "output": ["meow", "", true]}, - {"name": "escaped backslash at eof", "input": "\"meow\\\\", "output": ["meow\\", "", true]} -] diff --git a/mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json b/mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json deleted file mode 100644 index 9f249116..00000000 --- a/mautrix-patched/event/cmdschema/testdata/parse_quote.schema.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema#", - "$id": "parse_quote.schema.json", - "title": "parseQuote test cases", - "description": "Test cases for the parseQuoted function", - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "input", - "output" - ], - "properties": { - "name": { - "type": "string", - "description": "Name of the test case" - }, - "input": { - "type": "string", - "description": "Input string to be parsed" - }, - "output": { - "type": "array", - "description": "Expected output of parsing: [first word, remaining text, was quoted]", - "minItems": 3, - "maxItems": 3, - "prefixItems": [ - { - "type": "string", - "description": "First parsed word" - }, - { - "type": "string", - "description": "Remaining text after the first word" - }, - { - "type": "boolean", - "description": "Whether the first word was quoted" - } - ] - } - }, - "additionalProperties": false - } -} diff --git a/mautrix-patched/event/content.go b/mautrix-patched/event/content.go deleted file mode 100644 index 3a9b0024..00000000 --- a/mautrix-patched/event/content.go +++ /dev/null @@ -1,613 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "reflect" -) - -// TypeMap is a mapping from event type to the content struct type. -// This is used by Content.ParseRaw() for creating the correct type of struct. -var TypeMap = map[Type]reflect.Type{ - StateMember: reflect.TypeOf(MemberEventContent{}), - StateThirdPartyInvite: reflect.TypeOf(ThirdPartyInviteEventContent{}), - StatePowerLevels: reflect.TypeOf(PowerLevelsEventContent{}), - StateCanonicalAlias: reflect.TypeOf(CanonicalAliasEventContent{}), - StateRoomName: reflect.TypeOf(RoomNameEventContent{}), - StateRoomAvatar: reflect.TypeOf(RoomAvatarEventContent{}), - StateServerACL: reflect.TypeOf(ServerACLEventContent{}), - StateTopic: reflect.TypeOf(TopicEventContent{}), - StateTombstone: reflect.TypeOf(TombstoneEventContent{}), - StateCreate: reflect.TypeOf(CreateEventContent{}), - StateJoinRules: reflect.TypeOf(JoinRulesEventContent{}), - StateHistoryVisibility: reflect.TypeOf(HistoryVisibilityEventContent{}), - StateGuestAccess: reflect.TypeOf(GuestAccessEventContent{}), - StatePinnedEvents: reflect.TypeOf(PinnedEventsEventContent{}), - StatePolicyRoom: reflect.TypeOf(ModPolicyContent{}), - StatePolicyServer: reflect.TypeOf(ModPolicyContent{}), - StatePolicyUser: reflect.TypeOf(ModPolicyContent{}), - StateEncryption: reflect.TypeOf(EncryptionEventContent{}), - StateBridge: reflect.TypeOf(BridgeEventContent{}), - StateHalfShotBridge: reflect.TypeOf(BridgeEventContent{}), - StateSpaceParent: reflect.TypeOf(SpaceParentEventContent{}), - StateSpaceChild: reflect.TypeOf(SpaceChildEventContent{}), - - StateRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}), - StateUnstableRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}), - - StateImagePack: reflect.TypeOf(ImagePackEventContent{}), - StateUnstableImagePack: reflect.TypeOf(ImagePackEventContent{}), - - StateLegacyPolicyRoom: reflect.TypeOf(ModPolicyContent{}), - StateLegacyPolicyServer: reflect.TypeOf(ModPolicyContent{}), - StateLegacyPolicyUser: reflect.TypeOf(ModPolicyContent{}), - StateUnstablePolicyRoom: reflect.TypeOf(ModPolicyContent{}), - StateUnstablePolicyServer: reflect.TypeOf(ModPolicyContent{}), - StateUnstablePolicyUser: reflect.TypeOf(ModPolicyContent{}), - - StateElementFunctionalMembers: reflect.TypeOf(ElementFunctionalMembersContent{}), - StateBeeperRoomFeatures: reflect.TypeOf(RoomFeatures{}), - StateBeeperDisappearingTimer: reflect.TypeOf(BeeperDisappearingTimer{}), - - EventMessage: reflect.TypeOf(MessageEventContent{}), - EventSticker: reflect.TypeOf(MessageEventContent{}), - EventEncrypted: reflect.TypeOf(EncryptedEventContent{}), - EventRedaction: reflect.TypeOf(RedactionEventContent{}), - EventReaction: reflect.TypeOf(ReactionEventContent{}), - - EventUnstablePollStart: reflect.TypeOf(PollStartEventContent{}), - EventUnstablePollResponse: reflect.TypeOf(PollResponseEventContent{}), - - BeeperMessageStatus: reflect.TypeOf(BeeperMessageStatusEventContent{}), - BeeperTranscription: reflect.TypeOf(BeeperTranscriptionEventContent{}), - BeeperDeleteChat: reflect.TypeOf(BeeperChatDeleteEventContent{}), - BeeperAcceptMessageRequest: reflect.TypeOf(BeeperAcceptMessageRequestEventContent{}), - BeeperSendState: reflect.TypeOf(BeeperSendStateEventContent{}), - - AccountDataRoomTags: reflect.TypeOf(TagEventContent{}), - AccountDataDirectChats: reflect.TypeOf(DirectChatsEventContent{}), - AccountDataFullyRead: reflect.TypeOf(FullyReadEventContent{}), - AccountDataIgnoredUserList: reflect.TypeOf(IgnoredUserListEventContent{}), - AccountDataMarkedUnread: reflect.TypeOf(MarkedUnreadEventContent{}), - AccountDataBeeperMute: reflect.TypeOf(BeeperMuteEventContent{}), - - AccountDataImagePackRooms: reflect.TypeOf(ImagePackRoomsEventContent{}), - AccountDataUnstableImagePackRooms: reflect.TypeOf(ImagePackRoomsEventContent{}), - - EphemeralEventTyping: reflect.TypeOf(TypingEventContent{}), - EphemeralEventReceipt: reflect.TypeOf(ReceiptEventContent{}), - EphemeralEventPresence: reflect.TypeOf(PresenceEventContent{}), - - InRoomVerificationReady: reflect.TypeOf(VerificationReadyEventContent{}), - InRoomVerificationStart: reflect.TypeOf(VerificationStartEventContent{}), - InRoomVerificationDone: reflect.TypeOf(VerificationDoneEventContent{}), - InRoomVerificationCancel: reflect.TypeOf(VerificationCancelEventContent{}), - - InRoomVerificationAccept: reflect.TypeOf(VerificationAcceptEventContent{}), - InRoomVerificationKey: reflect.TypeOf(VerificationKeyEventContent{}), - InRoomVerificationMAC: reflect.TypeOf(VerificationMACEventContent{}), - - ToDeviceRoomKey: reflect.TypeOf(RoomKeyEventContent{}), - ToDeviceForwardedRoomKey: reflect.TypeOf(ForwardedRoomKeyEventContent{}), - ToDeviceRoomKeyRequest: reflect.TypeOf(RoomKeyRequestEventContent{}), - ToDeviceEncrypted: reflect.TypeOf(EncryptedEventContent{}), - ToDeviceRoomKeyWithheld: reflect.TypeOf(RoomKeyWithheldEventContent{}), - ToDeviceSecretRequest: reflect.TypeOf(SecretRequestEventContent{}), - ToDeviceSecretSend: reflect.TypeOf(SecretSendEventContent{}), - ToDeviceDummy: reflect.TypeOf(DummyEventContent{}), - - ToDeviceVerificationRequest: reflect.TypeOf(VerificationRequestEventContent{}), - ToDeviceVerificationReady: reflect.TypeOf(VerificationReadyEventContent{}), - ToDeviceVerificationStart: reflect.TypeOf(VerificationStartEventContent{}), - ToDeviceVerificationDone: reflect.TypeOf(VerificationDoneEventContent{}), - ToDeviceVerificationCancel: reflect.TypeOf(VerificationCancelEventContent{}), - - ToDeviceVerificationAccept: reflect.TypeOf(VerificationAcceptEventContent{}), - ToDeviceVerificationKey: reflect.TypeOf(VerificationKeyEventContent{}), - ToDeviceVerificationMAC: reflect.TypeOf(VerificationMACEventContent{}), - - ToDeviceOrgMatrixRoomKeyWithheld: reflect.TypeOf(RoomKeyWithheldEventContent{}), - - ToDeviceBeeperRoomKeyAck: reflect.TypeOf(BeeperRoomKeyAckEventContent{}), - ToDeviceBeeperStreamSubscribe: reflect.TypeOf(BeeperStreamSubscribeEventContent{}), - ToDeviceBeeperStreamUpdate: reflect.TypeOf(BeeperStreamUpdateEventContent{}), - - CallInvite: reflect.TypeOf(CallInviteEventContent{}), - CallCandidates: reflect.TypeOf(CallCandidatesEventContent{}), - CallAnswer: reflect.TypeOf(CallAnswerEventContent{}), - CallReject: reflect.TypeOf(CallRejectEventContent{}), - CallSelectAnswer: reflect.TypeOf(CallSelectAnswerEventContent{}), - CallNegotiate: reflect.TypeOf(CallNegotiateEventContent{}), - CallHangup: reflect.TypeOf(CallHangupEventContent{}), -} - -// Content stores the content of a Matrix event. -// -// By default, the raw JSON bytes are stored in VeryRaw and parsed into a map[string]interface{} in the Raw field. -// Additionally, you can call ParseRaw with the correct event type to parse the (VeryRaw) content into a nicer struct, -// which you can then access from Parsed or via the helper functions. -// -// When being marshaled into JSON, the data in Parsed will be marshaled first and then recursively merged -// with the data in Raw. Values in Raw are preferred, but nested objects will be recursed into before merging, -// rather than overriding the whole object with the one in Raw). -// If one of them is nil, then only the other is used. If both (Parsed and Raw) are nil, VeryRaw is used instead. -type Content struct { - VeryRaw json.RawMessage - Raw map[string]interface{} - Parsed interface{} -} - -type Relatable interface { - GetRelatesTo() *RelatesTo - OptionalGetRelatesTo() *RelatesTo - SetRelatesTo(rel *RelatesTo) -} - -func (content *Content) UnmarshalJSON(data []byte) error { - content.VeryRaw = bytes.Clone(data) - err := json.Unmarshal(data, &content.Raw) - return err -} - -func (content *Content) MarshalJSON() ([]byte, error) { - if content.Raw == nil { - if content.Parsed == nil { - if content.VeryRaw == nil { - return []byte("{}"), nil - } - return content.VeryRaw, nil - } - return json.Marshal(content.Parsed) - } else if content.Parsed != nil { - // TODO this whole thing is incredibly hacky - // It needs to produce JSON, where: - // * content.Parsed is applied after content.Raw - // * MarshalJSON() is respected inside content.Parsed - // * Custom field inside nested objects of content.Raw are preserved, - // even if content.Parsed contains the higher-level objects. - // * content.Raw is not modified - - unparsed, err := json.Marshal(content.Parsed) - if err != nil { - return nil, err - } - - var rawParsed map[string]interface{} - err = json.Unmarshal(unparsed, &rawParsed) - if err != nil { - return nil, err - } - - output := make(map[string]interface{}) - for key, value := range content.Raw { - output[key] = value - } - - mergeMaps(output, rawParsed) - return json.Marshal(output) - } - return json.Marshal(content.Raw) -} - -// Deprecated: use errors.Is directly -func IsUnsupportedContentType(err error) bool { - return errors.Is(err, ErrUnsupportedContentType) -} - -var ErrContentAlreadyParsed = errors.New("content is already parsed") -var ErrUnsupportedContentType = errors.New("unsupported event type") - -func (content *Content) GetRaw() map[string]interface{} { - if content.Raw == nil { - content.Raw = make(map[string]interface{}) - } - return content.Raw -} - -func (content *Content) ParseRaw(evtType Type) error { - if content.Parsed != nil { - return ErrContentAlreadyParsed - } - structType, ok := TypeMap[evtType] - if !ok { - return fmt.Errorf("%w %s", ErrUnsupportedContentType, evtType.Repr()) - } - content.Parsed = reflect.New(structType).Interface() - return json.Unmarshal(content.VeryRaw, &content.Parsed) -} - -func mergeMaps(into, from map[string]interface{}) { - for key, newValue := range from { - existingValue, ok := into[key] - if !ok { - into[key] = newValue - continue - } - existingValueMap, okEx := existingValue.(map[string]interface{}) - newValueMap, okNew := newValue.(map[string]interface{}) - if okEx && okNew { - mergeMaps(existingValueMap, newValueMap) - } else { - into[key] = newValue - } - } -} - -func CastOrDefault[T any](content *Content) *T { - casted, ok := content.Parsed.(*T) - if ok { - return casted - } - casted2, _ := content.Parsed.(T) - return &casted2 -} - -// Helper cast functions below - -func (content *Content) AsMember() *MemberEventContent { - casted, ok := content.Parsed.(*MemberEventContent) - if !ok { - return &MemberEventContent{} - } - return casted -} -func (content *Content) AsPowerLevels() *PowerLevelsEventContent { - casted, ok := content.Parsed.(*PowerLevelsEventContent) - if !ok { - return &PowerLevelsEventContent{} - } - return casted -} -func (content *Content) AsCanonicalAlias() *CanonicalAliasEventContent { - casted, ok := content.Parsed.(*CanonicalAliasEventContent) - if !ok { - return &CanonicalAliasEventContent{} - } - return casted -} -func (content *Content) AsRoomName() *RoomNameEventContent { - casted, ok := content.Parsed.(*RoomNameEventContent) - if !ok { - return &RoomNameEventContent{} - } - return casted -} -func (content *Content) AsRoomAvatar() *RoomAvatarEventContent { - casted, ok := content.Parsed.(*RoomAvatarEventContent) - if !ok { - return &RoomAvatarEventContent{} - } - return casted -} -func (content *Content) AsTopic() *TopicEventContent { - casted, ok := content.Parsed.(*TopicEventContent) - if !ok { - return &TopicEventContent{} - } - return casted -} -func (content *Content) AsTombstone() *TombstoneEventContent { - casted, ok := content.Parsed.(*TombstoneEventContent) - if !ok { - return &TombstoneEventContent{} - } - return casted -} -func (content *Content) AsCreate() *CreateEventContent { - casted, ok := content.Parsed.(*CreateEventContent) - if !ok { - return &CreateEventContent{} - } - return casted -} -func (content *Content) AsJoinRules() *JoinRulesEventContent { - casted, ok := content.Parsed.(*JoinRulesEventContent) - if !ok { - return &JoinRulesEventContent{} - } - return casted -} -func (content *Content) AsHistoryVisibility() *HistoryVisibilityEventContent { - casted, ok := content.Parsed.(*HistoryVisibilityEventContent) - if !ok { - return &HistoryVisibilityEventContent{} - } - return casted -} -func (content *Content) AsGuestAccess() *GuestAccessEventContent { - casted, ok := content.Parsed.(*GuestAccessEventContent) - if !ok { - return &GuestAccessEventContent{} - } - return casted -} -func (content *Content) AsPinnedEvents() *PinnedEventsEventContent { - casted, ok := content.Parsed.(*PinnedEventsEventContent) - if !ok { - return &PinnedEventsEventContent{} - } - return casted -} -func (content *Content) AsEncryption() *EncryptionEventContent { - casted, ok := content.Parsed.(*EncryptionEventContent) - if !ok { - return &EncryptionEventContent{} - } - return casted -} -func (content *Content) AsBridge() *BridgeEventContent { - casted, ok := content.Parsed.(*BridgeEventContent) - if !ok { - return &BridgeEventContent{} - } - return casted -} -func (content *Content) AsSpaceChild() *SpaceChildEventContent { - casted, ok := content.Parsed.(*SpaceChildEventContent) - if !ok { - return &SpaceChildEventContent{} - } - return casted -} -func (content *Content) AsSpaceParent() *SpaceParentEventContent { - casted, ok := content.Parsed.(*SpaceParentEventContent) - if !ok { - return &SpaceParentEventContent{} - } - return casted -} -func (content *Content) AsElementFunctionalMembers() *ElementFunctionalMembersContent { - casted, ok := content.Parsed.(*ElementFunctionalMembersContent) - if !ok { - return &ElementFunctionalMembersContent{} - } - return casted -} -func (content *Content) AsMessage() *MessageEventContent { - casted, ok := content.Parsed.(*MessageEventContent) - if !ok { - return &MessageEventContent{} - } - return casted -} -func (content *Content) AsEncrypted() *EncryptedEventContent { - casted, ok := content.Parsed.(*EncryptedEventContent) - if !ok { - return &EncryptedEventContent{} - } - return casted -} -func (content *Content) AsRedaction() *RedactionEventContent { - casted, ok := content.Parsed.(*RedactionEventContent) - if !ok { - return &RedactionEventContent{} - } - return casted -} -func (content *Content) AsReaction() *ReactionEventContent { - casted, ok := content.Parsed.(*ReactionEventContent) - if !ok { - return &ReactionEventContent{} - } - return casted -} -func (content *Content) AsTag() *TagEventContent { - casted, ok := content.Parsed.(*TagEventContent) - if !ok { - return &TagEventContent{} - } - return casted -} -func (content *Content) AsDirectChats() *DirectChatsEventContent { - casted, ok := content.Parsed.(*DirectChatsEventContent) - if !ok { - return &DirectChatsEventContent{} - } - return casted -} -func (content *Content) AsFullyRead() *FullyReadEventContent { - casted, ok := content.Parsed.(*FullyReadEventContent) - if !ok { - return &FullyReadEventContent{} - } - return casted -} -func (content *Content) AsIgnoredUserList() *IgnoredUserListEventContent { - casted, ok := content.Parsed.(*IgnoredUserListEventContent) - if !ok { - return &IgnoredUserListEventContent{} - } - return casted -} -func (content *Content) AsMarkedUnread() *MarkedUnreadEventContent { - casted, ok := content.Parsed.(*MarkedUnreadEventContent) - if !ok { - return &MarkedUnreadEventContent{} - } - return casted -} -func (content *Content) AsTyping() *TypingEventContent { - casted, ok := content.Parsed.(*TypingEventContent) - if !ok { - return &TypingEventContent{} - } - return casted -} -func (content *Content) AsReceipt() *ReceiptEventContent { - casted, ok := content.Parsed.(*ReceiptEventContent) - if !ok { - return &ReceiptEventContent{} - } - return casted -} -func (content *Content) AsPresence() *PresenceEventContent { - casted, ok := content.Parsed.(*PresenceEventContent) - if !ok { - return &PresenceEventContent{} - } - return casted -} -func (content *Content) AsRoomKey() *RoomKeyEventContent { - casted, ok := content.Parsed.(*RoomKeyEventContent) - if !ok { - return &RoomKeyEventContent{} - } - return casted -} -func (content *Content) AsForwardedRoomKey() *ForwardedRoomKeyEventContent { - casted, ok := content.Parsed.(*ForwardedRoomKeyEventContent) - if !ok { - return &ForwardedRoomKeyEventContent{} - } - return casted -} -func (content *Content) AsRoomKeyRequest() *RoomKeyRequestEventContent { - casted, ok := content.Parsed.(*RoomKeyRequestEventContent) - if !ok { - return &RoomKeyRequestEventContent{} - } - return casted -} -func (content *Content) AsRoomKeyWithheld() *RoomKeyWithheldEventContent { - casted, ok := content.Parsed.(*RoomKeyWithheldEventContent) - if !ok { - return &RoomKeyWithheldEventContent{} - } - return casted -} -func (content *Content) AsBeeperStreamSubscribe() *BeeperStreamSubscribeEventContent { - casted, ok := content.Parsed.(*BeeperStreamSubscribeEventContent) - if !ok { - return &BeeperStreamSubscribeEventContent{} - } - return casted -} - -func (content *Content) AsBeeperStreamUpdate() *BeeperStreamUpdateEventContent { - casted, ok := content.Parsed.(*BeeperStreamUpdateEventContent) - if !ok { - return &BeeperStreamUpdateEventContent{} - } - return casted -} - -func (content *Content) AsCallInvite() *CallInviteEventContent { - casted, ok := content.Parsed.(*CallInviteEventContent) - if !ok { - return &CallInviteEventContent{} - } - return casted -} -func (content *Content) AsCallCandidates() *CallCandidatesEventContent { - casted, ok := content.Parsed.(*CallCandidatesEventContent) - if !ok { - return &CallCandidatesEventContent{} - } - return casted -} -func (content *Content) AsCallAnswer() *CallAnswerEventContent { - casted, ok := content.Parsed.(*CallAnswerEventContent) - if !ok { - return &CallAnswerEventContent{} - } - return casted -} -func (content *Content) AsCallReject() *CallRejectEventContent { - casted, ok := content.Parsed.(*CallRejectEventContent) - if !ok { - return &CallRejectEventContent{} - } - return casted -} -func (content *Content) AsCallSelectAnswer() *CallSelectAnswerEventContent { - casted, ok := content.Parsed.(*CallSelectAnswerEventContent) - if !ok { - return &CallSelectAnswerEventContent{} - } - return casted -} -func (content *Content) AsCallNegotiate() *CallNegotiateEventContent { - casted, ok := content.Parsed.(*CallNegotiateEventContent) - if !ok { - return &CallNegotiateEventContent{} - } - return casted -} -func (content *Content) AsCallHangup() *CallHangupEventContent { - casted, ok := content.Parsed.(*CallHangupEventContent) - if !ok { - return &CallHangupEventContent{} - } - return casted -} -func (content *Content) AsModPolicy() *ModPolicyContent { - casted, ok := content.Parsed.(*ModPolicyContent) - if !ok { - return &ModPolicyContent{} - } - return casted -} -func (content *Content) AsVerificationRequest() *VerificationRequestEventContent { - casted, ok := content.Parsed.(*VerificationRequestEventContent) - if !ok { - return &VerificationRequestEventContent{} - } - return casted -} -func (content *Content) AsVerificationReady() *VerificationReadyEventContent { - casted, ok := content.Parsed.(*VerificationReadyEventContent) - if !ok { - return &VerificationReadyEventContent{} - } - return casted -} -func (content *Content) AsVerificationStart() *VerificationStartEventContent { - casted, ok := content.Parsed.(*VerificationStartEventContent) - if !ok { - return &VerificationStartEventContent{} - } - return casted -} -func (content *Content) AsVerificationDone() *VerificationDoneEventContent { - casted, ok := content.Parsed.(*VerificationDoneEventContent) - if !ok { - return &VerificationDoneEventContent{} - } - return casted -} -func (content *Content) AsVerificationCancel() *VerificationCancelEventContent { - casted, ok := content.Parsed.(*VerificationCancelEventContent) - if !ok { - return &VerificationCancelEventContent{} - } - return casted -} -func (content *Content) AsVerificationAccept() *VerificationAcceptEventContent { - casted, ok := content.Parsed.(*VerificationAcceptEventContent) - if !ok { - return &VerificationAcceptEventContent{} - } - return casted -} -func (content *Content) AsVerificationKey() *VerificationKeyEventContent { - casted, ok := content.Parsed.(*VerificationKeyEventContent) - if !ok { - return &VerificationKeyEventContent{} - } - return casted -} -func (content *Content) AsVerificationMAC() *VerificationMACEventContent { - casted, ok := content.Parsed.(*VerificationMACEventContent) - if !ok { - return &VerificationMACEventContent{} - } - return casted -} diff --git a/mautrix-patched/event/delayed.go b/mautrix-patched/event/delayed.go deleted file mode 100644 index fefb62af..00000000 --- a/mautrix-patched/event/delayed.go +++ /dev/null @@ -1,70 +0,0 @@ -package event - -import ( - "encoding/json" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/id" -) - -type ScheduledDelayedEvent struct { - DelayID id.DelayID `json:"delay_id"` - RoomID id.RoomID `json:"room_id"` - Type Type `json:"type"` - StateKey *string `json:"state_key,omitempty"` - Delay int64 `json:"delay"` - RunningSince jsontime.UnixMilli `json:"running_since"` - Content Content `json:"content"` -} - -func (e ScheduledDelayedEvent) AsEvent(eventID id.EventID, ts jsontime.UnixMilli) (*Event, error) { - evt := &Event{ - ID: eventID, - RoomID: e.RoomID, - Type: e.Type, - StateKey: e.StateKey, - Content: e.Content, - Timestamp: ts.UnixMilli(), - } - return evt, evt.Content.ParseRaw(evt.Type) -} - -type FinalisedDelayedEvent struct { - DelayedEvent *ScheduledDelayedEvent `json:"scheduled_event"` - Outcome DelayOutcome `json:"outcome"` - Reason DelayReason `json:"reason"` - Error json.RawMessage `json:"error,omitempty"` - EventID id.EventID `json:"event_id,omitempty"` - Timestamp jsontime.UnixMilli `json:"origin_server_ts"` -} - -type DelayStatus string - -var ( - DelayStatusScheduled DelayStatus = "scheduled" - DelayStatusFinalised DelayStatus = "finalised" -) - -type DelayAction string - -var ( - DelayActionSend DelayAction = "send" - DelayActionCancel DelayAction = "cancel" - DelayActionRestart DelayAction = "restart" -) - -type DelayOutcome string - -var ( - DelayOutcomeSend DelayOutcome = "send" - DelayOutcomeCancel DelayOutcome = "cancel" -) - -type DelayReason string - -var ( - DelayReasonAction DelayReason = "action" - DelayReasonError DelayReason = "error" - DelayReasonDelay DelayReason = "delay" -) diff --git a/mautrix-patched/event/encryption.go b/mautrix-patched/event/encryption.go deleted file mode 100644 index d3d41d1a..00000000 --- a/mautrix-patched/event/encryption.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "fmt" - - "go.mau.fi/util/jsonbytes" - - "maunium.net/go/mautrix/id" -) - -// EncryptionEventContent represents the content of a m.room.encryption state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomencryption -type EncryptionEventContent struct { - // The encryption algorithm to be used to encrypt messages sent in this room. Must be 'm.megolm.v1.aes-sha2'. - Algorithm id.Algorithm `json:"algorithm"` - // How long the session should be used before changing it. 604800000 (a week) is the recommended default. - RotationPeriodMillis int64 `json:"rotation_period_ms,omitempty"` - // How many messages should be sent before changing the session. 100 is the recommended default. - RotationPeriodMessages int `json:"rotation_period_msgs,omitempty"` -} - -// EncryptedEventContent represents the content of a m.room.encrypted message event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomencrypted -// -// Note that sender_key and device_id are deprecated in Megolm events as of https://github.com/matrix-org/matrix-spec-proposals/pull/3700 -type EncryptedEventContent struct { - Algorithm id.Algorithm `json:"algorithm"` - SenderKey id.SenderKey `json:"sender_key,omitempty"` - // Deprecated: Matrix v1.3 - DeviceID id.DeviceID `json:"device_id,omitempty"` - // Only present for Megolm events - SessionID id.SessionID `json:"session_id,omitempty"` - // Only present for beeper stream events - StreamID string `json:"stream_id,omitempty"` - // Only present for beeper stream events - IV jsonbytes.UnpaddedBytes `json:"iv,omitempty"` - - Ciphertext json.RawMessage `json:"ciphertext"` - - MegolmCiphertext []byte `json:"-"` - // Only present for beeper stream events - BeeperStreamCiphertext jsonbytes.UnpaddedBytes `json:"-"` - OlmCiphertext OlmCiphertexts `json:"-"` - - RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` - Mentions *Mentions `json:"m.mentions,omitempty"` -} - -type OlmCiphertexts map[id.Curve25519]struct { - Body string `json:"body"` - Type id.OlmMsgType `json:"type"` -} - -type serializableEncryptedEventContent EncryptedEventContent - -func (content *EncryptedEventContent) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, (*serializableEncryptedEventContent)(content)) - if err != nil { - return err - } - switch content.Algorithm { - case id.AlgorithmOlmV1: - content.OlmCiphertext = make(OlmCiphertexts) - return json.Unmarshal(content.Ciphertext, &content.OlmCiphertext) - case id.AlgorithmMegolmV1: - if len(content.Ciphertext) == 0 || content.Ciphertext[0] != '"' || content.Ciphertext[len(content.Ciphertext)-1] != '"' { - return fmt.Errorf("ciphertext %w", id.ErrInputNotJSONString) - } - content.MegolmCiphertext = content.Ciphertext[1 : len(content.Ciphertext)-1] - case id.AlgorithmBeeperStreamV1: - return json.Unmarshal(content.Ciphertext, &content.BeeperStreamCiphertext) - } - return nil -} - -func (content *EncryptedEventContent) MarshalJSON() ([]byte, error) { - var err error - switch content.Algorithm { - case id.AlgorithmOlmV1: - content.Ciphertext, err = json.Marshal(content.OlmCiphertext) - case id.AlgorithmMegolmV1: - content.Ciphertext = make([]byte, len(content.MegolmCiphertext)+2) - content.Ciphertext[0] = '"' - content.Ciphertext[len(content.Ciphertext)-1] = '"' - copy(content.Ciphertext[1:len(content.Ciphertext)-1], content.MegolmCiphertext) - case id.AlgorithmBeeperStreamV1: - content.Ciphertext, err = json.Marshal(content.BeeperStreamCiphertext) - } - if err != nil { - return nil, err - } - return json.Marshal((*serializableEncryptedEventContent)(content)) -} - -// RoomKeyEventContent represents the content of a m.room_key to_device event. -// https://spec.matrix.org/v1.2/client-server-api/#mroom_key -type RoomKeyEventContent struct { - Algorithm id.Algorithm `json:"algorithm"` - RoomID id.RoomID `json:"room_id"` - SessionID id.SessionID `json:"session_id"` - SessionKey string `json:"session_key"` - - MaxAge int64 `json:"com.beeper.max_age_ms,omitempty"` - MaxMessages int `json:"com.beeper.max_messages,omitempty"` - IsScheduled bool `json:"com.beeper.is_scheduled,omitempty"` -} - -// ForwardedRoomKeyEventContent represents the content of a m.forwarded_room_key to_device event. -// https://spec.matrix.org/v1.2/client-server-api/#mforwarded_room_key -type ForwardedRoomKeyEventContent struct { - RoomKeyEventContent - SenderKey id.SenderKey `json:"sender_key"` - SenderClaimedKey id.Ed25519 `json:"sender_claimed_ed25519_key"` - ForwardingKeyChain []string `json:"forwarding_curve25519_key_chain"` -} - -type KeyRequestAction string - -const ( - KeyRequestActionRequest = "request" - KeyRequestActionCancel = "request_cancellation" -) - -// RoomKeyRequestEventContent represents the content of a m.room_key_request to_device event. -// https://spec.matrix.org/v1.2/client-server-api/#mroom_key_request -type RoomKeyRequestEventContent struct { - Body RequestedKeyInfo `json:"body"` - Action KeyRequestAction `json:"action"` - RequestingDeviceID id.DeviceID `json:"requesting_device_id"` - RequestID string `json:"request_id"` -} - -type RequestedKeyInfo struct { - Algorithm id.Algorithm `json:"algorithm"` - RoomID id.RoomID `json:"room_id"` - SessionID id.SessionID `json:"session_id"` - // Deprecated: Matrix v1.3 - SenderKey id.SenderKey `json:"sender_key"` -} - -type RoomKeyWithheldCode string - -const ( - RoomKeyWithheldBlacklisted RoomKeyWithheldCode = "m.blacklisted" - RoomKeyWithheldUnverified RoomKeyWithheldCode = "m.unverified" - RoomKeyWithheldUnauthorized RoomKeyWithheldCode = "m.unauthorised" - RoomKeyWithheldUnavailable RoomKeyWithheldCode = "m.unavailable" - RoomKeyWithheldNoOlmSession RoomKeyWithheldCode = "m.no_olm" - - RoomKeyWithheldBeeperRedacted RoomKeyWithheldCode = "com.beeper.redacted" -) - -type RoomKeyWithheldEventContent struct { - RoomID id.RoomID `json:"room_id,omitempty"` - Algorithm id.Algorithm `json:"algorithm"` - SessionID id.SessionID `json:"session_id,omitempty"` - SenderKey id.SenderKey `json:"sender_key"` - Code RoomKeyWithheldCode `json:"code"` - Reason string `json:"reason,omitempty"` -} - -const groupSessionWithheldMsg = "group session has been withheld: %s" - -func (withheld *RoomKeyWithheldEventContent) Error() string { - switch withheld.Code { - case RoomKeyWithheldBlacklisted, RoomKeyWithheldUnverified, RoomKeyWithheldUnauthorized, RoomKeyWithheldUnavailable, RoomKeyWithheldNoOlmSession: - return fmt.Sprintf(groupSessionWithheldMsg, withheld.Code) - default: - return fmt.Sprintf(groupSessionWithheldMsg+" (%s)", withheld.Code, withheld.Reason) - } -} - -func (withheld *RoomKeyWithheldEventContent) Is(other error) bool { - otherWithheld, ok := other.(*RoomKeyWithheldEventContent) - if !ok { - return false - } - return withheld.Code == "" || otherWithheld.Code == "" || withheld.Code == otherWithheld.Code -} - -type SecretRequestAction string - -func (a SecretRequestAction) String() string { - return string(a) -} - -const ( - SecretRequestRequest = "request" - SecretRequestCancellation = "request_cancellation" -) - -type SecretRequestEventContent struct { - Name id.Secret `json:"name,omitempty"` - Action SecretRequestAction `json:"action"` - RequestingDeviceID id.DeviceID `json:"requesting_device_id"` - RequestID string `json:"request_id"` -} - -type SecretSendEventContent struct { - RequestID string `json:"request_id"` - Secret string `json:"secret"` -} - -type DummyEventContent struct{} diff --git a/mautrix-patched/event/ephemeral.go b/mautrix-patched/event/ephemeral.go deleted file mode 100644 index f447404b..00000000 --- a/mautrix-patched/event/ephemeral.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "time" - - "maunium.net/go/mautrix/id" -) - -// TypingEventContent represents the content of a m.typing ephemeral event. -// https://spec.matrix.org/v1.2/client-server-api/#mtyping -type TypingEventContent struct { - UserIDs []id.UserID `json:"user_ids"` -} - -// ReceiptEventContent represents the content of a m.receipt ephemeral event. -// https://spec.matrix.org/v1.2/client-server-api/#mreceipt -type ReceiptEventContent map[id.EventID]Receipts - -func (rec ReceiptEventContent) Set(evtID id.EventID, receiptType ReceiptType, userID id.UserID, receipt ReadReceipt) { - rec.GetOrCreate(evtID).GetOrCreate(receiptType).Set(userID, receipt) -} - -func (rec ReceiptEventContent) GetOrCreate(evt id.EventID) Receipts { - receipts, ok := rec[evt] - if !ok { - receipts = make(Receipts) - rec[evt] = receipts - } - return receipts -} - -type ReceiptType string - -const ( - ReceiptTypeRead ReceiptType = "m.read" - ReceiptTypeReadPrivate ReceiptType = "m.read.private" -) - -type Receipts map[ReceiptType]UserReceipts - -func (rps Receipts) GetOrCreate(receiptType ReceiptType) UserReceipts { - read, ok := rps[receiptType] - if !ok { - read = make(UserReceipts) - rps[receiptType] = read - } - return read -} - -type UserReceipts map[id.UserID]ReadReceipt - -func (ur UserReceipts) Set(userID id.UserID, receipt ReadReceipt) { - ur[userID] = receipt -} - -type ThreadID = id.EventID - -const ReadReceiptThreadMain ThreadID = "main" - -type ReadReceipt struct { - Timestamp time.Time - - // Thread ID for thread-specific read receipts from MSC3771 - ThreadID ThreadID - - // Extra contains any unknown fields in the read receipt event. - // Most servers don't allow clients to set them, so this will be empty in most cases. - Extra map[string]interface{} -} - -func (rr *ReadReceipt) UnmarshalJSON(data []byte) error { - // Hacky compatibility hack against crappy clients that send double-encoded read receipts. - // TODO is this actually needed? clients can't currently set custom content in receipts 🤔 - if data[0] == '"' && data[len(data)-1] == '"' { - var strData string - err := json.Unmarshal(data, &strData) - if err != nil { - return err - } - data = []byte(strData) - } - - var parsed map[string]interface{} - err := json.Unmarshal(data, &parsed) - if err != nil { - return err - } - threadID, _ := parsed["thread_id"].(string) - ts, tsOK := parsed["ts"].(float64) - delete(parsed, "thread_id") - delete(parsed, "ts") - *rr = ReadReceipt{ - ThreadID: ThreadID(threadID), - Extra: parsed, - } - if tsOK { - rr.Timestamp = time.UnixMilli(int64(ts)) - } - return nil -} - -func (rr ReadReceipt) MarshalJSON() ([]byte, error) { - data := rr.Extra - if data == nil { - data = make(map[string]interface{}) - } - if rr.ThreadID != "" { - data["thread_id"] = rr.ThreadID - } - if !rr.Timestamp.IsZero() { - data["ts"] = rr.Timestamp.UnixMilli() - } - return json.Marshal(data) -} - -type Presence string - -const ( - PresenceOnline Presence = "online" - PresenceOffline Presence = "offline" - PresenceUnavailable Presence = "unavailable" -) - -// PresenceEventContent represents the content of a m.presence ephemeral event. -// https://spec.matrix.org/v1.2/client-server-api/#mpresence -type PresenceEventContent struct { - Presence Presence `json:"presence"` - Displayname string `json:"displayname,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - LastActiveAgo int64 `json:"last_active_ago,omitempty"` - CurrentlyActive bool `json:"currently_active,omitempty"` - StatusMessage string `json:"status_msg,omitempty"` -} diff --git a/mautrix-patched/event/events.go b/mautrix-patched/event/events.go deleted file mode 100644 index a5915059..00000000 --- a/mautrix-patched/event/events.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "time" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/id" -) - -// Event represents a single Matrix event. -type Event struct { - StateKey *string `json:"state_key,omitempty"` // The state key for the event. Only present on State Events. - Sender id.UserID `json:"sender,omitempty"` // The user ID of the sender of the event - Type Type `json:"type"` // The event type - Timestamp int64 `json:"origin_server_ts,omitempty"` // The unix timestamp when this message was sent by the origin server - ID id.EventID `json:"event_id,omitempty"` // The unique ID of this event - RoomID id.RoomID `json:"room_id,omitempty"` // The room the event was sent to. May be nil (e.g. for presence) - Content Content `json:"content"` // The JSON content of the event. - Redacts id.EventID `json:"redacts,omitempty"` // The event ID that was redacted if a m.room.redaction event - Unsigned Unsigned `json:"unsigned,omitempty"` // Unsigned content set by own homeserver. - - Sticky *Sticky `json:"msc4354_sticky,omitempty"` - - Mautrix MautrixInfo `json:"-"` - - ToUserID id.UserID `json:"to_user_id,omitempty"` // The user ID that the to-device event was sent to. Only present in MSC2409 appservice transactions. - ToDeviceID id.DeviceID `json:"to_device_id,omitempty"` // The device ID that the to-device event was sent to. Only present in MSC2409 appservice transactions. -} - -type eventForMarshaling struct { - StateKey *string `json:"state_key,omitempty"` - Sender id.UserID `json:"sender,omitempty"` - Type Type `json:"type"` - Timestamp int64 `json:"origin_server_ts,omitempty"` - ID id.EventID `json:"event_id,omitempty"` - RoomID id.RoomID `json:"room_id,omitempty"` - Content Content `json:"content"` - Redacts id.EventID `json:"redacts,omitempty"` - Unsigned *Unsigned `json:"unsigned,omitempty"` - - Sticky *Sticky `json:"msc4354_sticky,omitempty"` - - PrevContent *Content `json:"prev_content,omitempty"` - ReplacesState *id.EventID `json:"replaces_state,omitempty"` - - ToUserID id.UserID `json:"to_user_id,omitempty"` - ToDeviceID id.DeviceID `json:"to_device_id,omitempty"` -} - -// UnmarshalJSON unmarshals the event, including moving prev_content from the top level to inside unsigned. -func (evt *Event) UnmarshalJSON(data []byte) error { - var efm eventForMarshaling - err := json.Unmarshal(data, &efm) - if err != nil { - return err - } - evt.StateKey = efm.StateKey - evt.Sender = efm.Sender - evt.Type = efm.Type - evt.Timestamp = efm.Timestamp - evt.ID = efm.ID - evt.RoomID = efm.RoomID - evt.Content = efm.Content - evt.Redacts = efm.Redacts - evt.Sticky = efm.Sticky - if efm.Unsigned != nil { - evt.Unsigned = *efm.Unsigned - } - if efm.PrevContent != nil && evt.Unsigned.PrevContent == nil { - evt.Unsigned.PrevContent = efm.PrevContent - } - if efm.ReplacesState != nil && *efm.ReplacesState != "" && evt.Unsigned.ReplacesState == "" { - evt.Unsigned.ReplacesState = *efm.ReplacesState - } - evt.ToUserID = efm.ToUserID - evt.ToDeviceID = efm.ToDeviceID - return nil -} - -// MarshalJSON marshals the event, including omitting the unsigned field if it's empty. -// -// This is necessary because Unsigned is not a pointer (for convenience reasons), -// and encoding/json doesn't know how to check if a non-pointer struct is empty. -// -// TODO(tulir): maybe it makes more sense to make Unsigned a pointer and make an easy and safe way to access it? -func (evt *Event) MarshalJSON() ([]byte, error) { - unsigned := &evt.Unsigned - if unsigned.IsEmpty() { - unsigned = nil - } - return json.Marshal(&eventForMarshaling{ - StateKey: evt.StateKey, - Sender: evt.Sender, - Type: evt.Type, - Timestamp: evt.Timestamp, - ID: evt.ID, - RoomID: evt.RoomID, - Content: evt.Content, - Redacts: evt.Redacts, - Unsigned: unsigned, - Sticky: evt.Sticky, - ToUserID: evt.ToUserID, - ToDeviceID: evt.ToDeviceID, - }) -} - -type Sticky struct { - Duration jsontime.Milliseconds `json:"duration_ms"` -} - -const MaxStickyDuration = 1 * time.Hour - -func (sticky *Sticky) GetDuration() time.Duration { - if sticky != nil { - return max(0, min(sticky.Duration.Duration, MaxStickyDuration)) - } - return 0 -} - -func (evt *Event) GetStickyUntil() time.Time { - if evt == nil || evt.Sticky.GetDuration() == 0 { - return time.Time{} - } - return time.UnixMilli(evt.Timestamp).Add(evt.Sticky.GetDuration()) -} - -type MautrixInfo struct { - EventSource Source - - TrustState id.TrustState - ForwardedKeys bool - WasEncrypted bool - TrustSource *id.Device - - ReceivedAt time.Time - EditedAt time.Time - LastEditID id.EventID - DecryptionDuration time.Duration - - CheckpointSent bool - // When using MSC4222 and the state_after field, this field is set - // for timeline events to indicate they shouldn't update room state. - IgnoreState bool -} - -func (evt *Event) GetStateKey() string { - if evt.StateKey != nil { - return *evt.StateKey - } - return "" -} - -type Unsigned struct { - PrevContent *Content `json:"prev_content,omitempty"` - PrevSender id.UserID `json:"prev_sender,omitempty"` - Membership Membership `json:"membership,omitempty"` - ReplacesState id.EventID `json:"replaces_state,omitempty"` - Age int64 `json:"age,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` - Relations *Relations `json:"m.relations,omitempty"` - RedactedBecause *Event `json:"redacted_because,omitempty"` - InviteRoomState []*Event `json:"invite_room_state,omitempty"` - - StickyDurationTTL jsontime.Milliseconds `json:"msc4354_sticky_duration_ttl_ms,omitzero"` - - BeeperHSOrder int64 `json:"com.beeper.hs.order,omitempty"` - BeeperHSSuborder int16 `json:"com.beeper.hs.suborder,omitempty"` - BeeperHSOrderString *BeeperEncodedOrder `json:"com.beeper.hs.order_string,omitempty"` - BeeperFromBackup bool `json:"com.beeper.from_backup,omitempty"` - - ElementSoftFailed bool `json:"io.element.synapse.soft_failed,omitempty"` - ElementPolicyServerSpammy bool `json:"io.element.synapse.policy_server_spammy,omitempty"` -} - -func (us *Unsigned) IsEmpty() bool { - return us.PrevContent == nil && us.PrevSender == "" && us.ReplacesState == "" && us.Age == 0 && us.Membership == "" && - us.TransactionID == "" && us.RedactedBecause == nil && us.InviteRoomState == nil && us.Relations == nil && - us.BeeperHSOrder == 0 && us.BeeperHSSuborder == 0 && us.BeeperHSOrderString.IsZero() && - !us.ElementSoftFailed && us.StickyDurationTTL.Duration == 0 -} diff --git a/mautrix-patched/event/eventsource.go b/mautrix-patched/event/eventsource.go deleted file mode 100644 index 44bbb68e..00000000 --- a/mautrix-patched/event/eventsource.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "fmt" -) - -// Source represents the part of the sync response that an event came from. -type Source int - -const ( - SourcePresence Source = 1 << iota - SourceJoin - SourceInvite - SourceLeave - SourceAccountData - SourceTimeline - SourceSticky - SourceState - SourceEphemeral - SourceToDevice - SourceDecrypted -) - -const primaryTypes = SourcePresence | SourceAccountData | SourceToDevice | SourceTimeline | SourceState -const roomSections = SourceJoin | SourceInvite | SourceLeave -const roomableTypes = SourceAccountData | SourceTimeline | SourceState -const encryptableTypes = roomableTypes | SourceToDevice - -func (es Source) String() string { - var typeName string - switch es & primaryTypes { - case SourcePresence: - typeName = "presence" - case SourceAccountData: - typeName = "account data" - case SourceToDevice: - typeName = "to-device" - case SourceTimeline: - typeName = "timeline" - case SourceState: - typeName = "state" - default: - return fmt.Sprintf("unknown (%d)", es) - } - if es&roomableTypes != 0 { - switch es & roomSections { - case SourceJoin: - typeName = "joined room " + typeName - case SourceInvite: - typeName = "invited room " + typeName - case SourceLeave: - typeName = "left room " + typeName - default: - return fmt.Sprintf("unknown (%s+%d)", typeName, es) - } - es &^= roomSections - } - if es&encryptableTypes != 0 && es&SourceDecrypted != 0 { - typeName += " (decrypted)" - es &^= SourceDecrypted - } - es &^= primaryTypes - if es != 0 { - return fmt.Sprintf("unknown (%s+%d)", typeName, es) - } - return typeName -} diff --git a/mautrix-patched/event/imagepack.go b/mautrix-patched/event/imagepack.go deleted file mode 100644 index a8fb583b..00000000 --- a/mautrix-patched/event/imagepack.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "maunium.net/go/mautrix/id" -) - -type ImagePackImage struct { - URL id.ContentURIString `json:"url"` - Body string `json:"body,omitempty"` - Info *FileInfo `json:"info,omitempty"` -} - -type ImagePackUsage string - -const ( - ImagePackUsageEmoji ImagePackUsage = "emoticon" - ImagePackUsageSticker ImagePackUsage = "sticker" -) - -type ImagePackMetadata struct { - DisplayName string `json:"display_name,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - Usage []ImagePackUsage `json:"usage,omitempty"` - Attribution string `json:"attribution,omitempty"` - - BridgedPack *BridgedStickerPack `json:"fi.mau.bridged_pack,omitempty"` -} - -func (ipm ImagePackMetadata) IsZero() bool { - return ipm.DisplayName == "" && ipm.AvatarURL == "" && len(ipm.Usage) == 0 && ipm.Attribution == "" && ipm.BridgedPack == nil -} - -type ImagePackEventContent struct { - Images map[string]*ImagePackImage `json:"images"` - Metadata ImagePackMetadata `json:"pack,omitzero"` -} - -type ImagePackRoomsEventContent struct { - Rooms map[id.RoomID]map[string]struct{} `json:"rooms"` -} - -type ImageSource struct { - RoomID id.RoomID `json:"room_id"` - Via []string `json:"via,omitempty"` - StateKey string `json:"state_key"` - Shortcode string `json:"shortcode"` -} - -type BridgedStickerPack struct { - Network string `json:"network"` - URL string `json:"url"` -} - -type BridgedSticker struct { - Network string `json:"network"` - ID string `json:"id"` - Emoji string `json:"emoji,omitempty"` - PackURL string `json:"pack_url"` -} diff --git a/mautrix-patched/event/member.go b/mautrix-patched/event/member.go deleted file mode 100644 index 9956a36b..00000000 --- a/mautrix-patched/event/member.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "maunium.net/go/mautrix/id" -) - -// Membership is an enum specifying the membership state of a room member. -type Membership string - -func (ms Membership) IsInviteOrJoin() bool { - return ms == MembershipJoin || ms == MembershipInvite -} - -func (ms Membership) IsLeaveOrBan() bool { - return ms == MembershipLeave || ms == MembershipBan -} - -// The allowed membership states as specified in spec section 10.5.5. -const ( - MembershipJoin Membership = "join" - MembershipLeave Membership = "leave" - MembershipInvite Membership = "invite" - MembershipBan Membership = "ban" - MembershipKnock Membership = "knock" -) - -// MemberEventContent represents the content of a m.room.member state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroommember -type MemberEventContent struct { - Membership Membership `json:"membership"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - Displayname string `json:"displayname,omitempty"` - IsDirect bool `json:"is_direct,omitempty"` - ThirdPartyInvite *ThirdPartyInvite `json:"third_party_invite,omitempty"` - Reason string `json:"reason,omitempty"` - JoinAuthorisedViaUsersServer id.UserID `json:"join_authorised_via_users_server,omitempty"` - MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` - - MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` -} - -type SignedThirdPartyInvite struct { - Token string `json:"token"` - Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` - MXID string `json:"mxid"` -} - -type ThirdPartyInvite struct { - DisplayName string `json:"display_name"` - Signed SignedThirdPartyInvite `json:"signed"` -} - -type ThirdPartyInviteEventContent struct { - DisplayName string `json:"display_name"` - KeyValidityURL string `json:"key_validity_url"` - PublicKey id.Ed25519 `json:"public_key"` - PublicKeys []ThirdPartyInviteKey `json:"public_keys,omitempty"` -} - -type ThirdPartyInviteKey struct { - KeyValidityURL string `json:"key_validity_url,omitempty"` - PublicKey id.Ed25519 `json:"public_key"` -} diff --git a/mautrix-patched/event/message.go b/mautrix-patched/event/message.go deleted file mode 100644 index 1d4e1d62..00000000 --- a/mautrix-patched/event/message.go +++ /dev/null @@ -1,501 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "fmt" - "html" - "slices" - "strconv" - "strings" - - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/id" -) - -// MessageType is the sub-type of a m.room.message event. -// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes -type MessageType string - -func (mt MessageType) IsText() bool { - switch mt { - case MsgText, MsgNotice, MsgEmote: - return true - default: - return false - } -} - -func (mt MessageType) IsMedia() bool { - switch mt { - case MsgImage, MsgVideo, MsgAudio, MsgFile, CapMsgSticker: - return true - default: - return false - } -} - -// Msgtypes -const ( - MsgText MessageType = "m.text" - MsgEmote MessageType = "m.emote" - MsgNotice MessageType = "m.notice" - MsgImage MessageType = "m.image" - MsgLocation MessageType = "m.location" - MsgVideo MessageType = "m.video" - MsgAudio MessageType = "m.audio" - MsgFile MessageType = "m.file" - - MsgVerificationRequest MessageType = "m.key.verification.request" - - MsgBeeperGallery MessageType = "com.beeper.gallery" -) - -// Format specifies the format of the formatted_body in m.room.message events. -// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes -type Format string - -// Message formats -const ( - FormatHTML Format = "org.matrix.custom.html" -) - -// RedactionEventContent represents the content of a m.room.redaction message event. -// -// https://spec.matrix.org/v1.8/client-server-api/#mroomredaction -type RedactionEventContent struct { - Reason string `json:"reason,omitempty"` - - // The event ID is here as of room v11. In old servers it may only be at the top level. - Redacts id.EventID `json:"redacts,omitempty"` - - DontRenderPlaceholder bool `json:"com.beeper.dont_render_redacted_placeholder,omitempty"` -} - -// ReactionEventContent represents the content of a m.reaction message event. -// This is not yet in a spec release, see https://github.com/matrix-org/matrix-doc/pull/1849 -type ReactionEventContent struct { - RelatesTo RelatesTo `json:"m.relates_to"` -} - -func (content *ReactionEventContent) GetRelatesTo() *RelatesTo { - return &content.RelatesTo -} - -func (content *ReactionEventContent) OptionalGetRelatesTo() *RelatesTo { - return &content.RelatesTo -} - -func (content *ReactionEventContent) SetRelatesTo(rel *RelatesTo) { - content.RelatesTo = *rel -} - -// MessageEventContent represents the content of a m.room.message event. -// -// It is also used to represent m.sticker events, as they are equivalent to m.room.message -// with the exception of the msgtype field. -// -// https://spec.matrix.org/v1.2/client-server-api/#mroommessage -type MessageEventContent struct { - // Base m.room.message fields - MsgType MessageType `json:"msgtype,omitempty"` - Body string `json:"body"` - - // Extra fields for text types - Format Format `json:"format,omitempty"` - FormattedBody string `json:"formatted_body,omitempty"` - - // Extra field for m.location - GeoURI string `json:"geo_uri,omitempty"` - - // Extra fields for media types - URL id.ContentURIString `json:"url,omitempty"` - Info *FileInfo `json:"info,omitempty"` - File *EncryptedFileInfo `json:"file,omitempty"` - - FileName string `json:"filename,omitempty"` - - Mentions *Mentions `json:"m.mentions,omitempty"` - - // Edits and relations - NewContent *MessageEventContent `json:"m.new_content,omitempty"` - RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` - - // In-room verification - To id.UserID `json:"to,omitempty"` - FromDevice id.DeviceID `json:"from_device,omitempty"` - Methods []VerificationMethod `json:"methods,omitempty"` - - replyFallbackRemoved bool - - ImageSourcePacks map[id.ContentURIString]*ImageSource `json:"com.beeper.msc4459.image_source_packs,omitempty"` - BridgedEmojis map[id.ContentURIString]*BridgedSticker `json:"com.beeper.bridged_emojis,omitempty"` - - MessageSendRetry *BeeperRetryMetadata `json:"com.beeper.message_send_retry,omitempty"` - BeeperGalleryImages []*MessageEventContent `json:"com.beeper.gallery.images,omitempty"` - BeeperGalleryCaption string `json:"com.beeper.gallery.caption,omitempty"` - BeeperGalleryCaptionHTML string `json:"com.beeper.gallery.caption_html,omitempty"` - BeeperPerMessageProfile *BeeperPerMessageProfile `json:"com.beeper.per_message_profile,omitempty"` - BeeperActionMessage *BeeperActionMessage `json:"com.beeper.action_message,omitempty"` - BeeperLinkPreviews []*BeeperLinkPreview `json:"com.beeper.linkpreviews,omitempty"` - BeeperStream *BeeperStreamInfo `json:"com.beeper.stream,omitempty"` - BeeperDisappearingTimer *BeeperDisappearingTimer `json:"com.beeper.disappearing_timer,omitempty"` - - MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"` - MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"` - - MSC4391BotCommand *MSC4391BotCommandInput `json:"org.matrix.msc4391.command,omitempty"` -} - -func (content *MessageEventContent) GetCapMsgType() CapabilityMsgType { - switch content.MsgType { - case CapMsgSticker: - return CapMsgSticker - case "": - if content.URL != "" || content.File != nil { - return CapMsgSticker - } - case MsgImage: - return MsgImage - case MsgAudio: - if content.MSC3245Voice != nil { - return CapMsgVoice - } - return MsgAudio - case MsgVideo: - if content.Info != nil && content.Info.MauGIF { - return CapMsgGIF - } - return MsgVideo - case MsgFile: - return MsgFile - } - return "" -} - -func (content *MessageEventContent) GetFileName() string { - if content.FileName != "" { - return content.FileName - } - return content.Body -} - -func (content *MessageEventContent) GetCaption() string { - if content.FileName != "" && content.Body != "" && content.Body != content.FileName { - return content.Body - } - return "" -} - -func (content *MessageEventContent) GetFormattedCaption() string { - if content.Format == FormatHTML && content.FormattedBody != "" { - return content.FormattedBody - } - return "" -} - -func (content *MessageEventContent) GetRelatesTo() *RelatesTo { - if content.RelatesTo == nil { - content.RelatesTo = &RelatesTo{} - } - return content.RelatesTo -} - -func (content *MessageEventContent) OptionalGetRelatesTo() *RelatesTo { - return content.RelatesTo -} - -func (content *MessageEventContent) SetRelatesTo(rel *RelatesTo) { - content.RelatesTo = rel -} - -func (content *MessageEventContent) SetEdit(original id.EventID) { - newContent := *content - content.NewContent = &newContent - content.RelatesTo = (&RelatesTo{}).SetReplace(original) - if content.MsgType == MsgText || content.MsgType == MsgNotice { - content.Body = "* " + content.Body - content.Mentions = &Mentions{} - if content.Format == FormatHTML && len(content.FormattedBody) > 0 { - content.FormattedBody = "* " + content.FormattedBody - } - // If the message is long, remove most of the useless edit fallback to avoid event size issues. - if len(content.Body) > 10000 { - content.FormattedBody = "" - content.Format = "" - content.Body = content.Body[:50] + "[edit fallback cut…]" - } - } -} - -// TextToHTML converts the given text to a HTML-safe representation by escaping HTML characters -// and replacing newlines with
          tags. -func TextToHTML(text string) string { - return strings.ReplaceAll(html.EscapeString(text), "\n", "
          ") -} - -// ReverseTextToHTML reverses the modifications made by TextToHTML, i.e. replaces
          tags with newlines -// and unescapes HTML escape codes. For actually parsing HTML, use the format package instead. -func ReverseTextToHTML(input string) string { - return html.UnescapeString(strings.ReplaceAll(input, "
          ", "\n")) -} - -func (content *MessageEventContent) EnsureHasHTML() { - if content.MsgType.IsMedia() && (content.FileName == "" || content.FileName == content.Body) { - content.FileName = content.Body - content.Body = "" - } - if len(content.FormattedBody) == 0 || content.Format != FormatHTML { - content.FormattedBody = TextToHTML(content.Body) - content.Format = FormatHTML - } -} - -func (content *MessageEventContent) GetFile() *EncryptedFileInfo { - if content.File == nil { - content.File = &EncryptedFileInfo{} - } - return content.File -} - -func (content *MessageEventContent) GetInfo() *FileInfo { - if content.Info == nil { - content.Info = &FileInfo{} - } - return content.Info -} - -type Mentions struct { - UserIDs []id.UserID `json:"user_ids,omitempty"` - Room bool `json:"room,omitempty"` -} - -func (m *Mentions) Add(userID id.UserID) { - if userID != "" && !slices.Contains(m.UserIDs, userID) { - m.UserIDs = append(m.UserIDs, userID) - } -} - -func (m *Mentions) Has(userID id.UserID) bool { - return m != nil && slices.Contains(m.UserIDs, userID) -} - -func (m *Mentions) Merge(other *Mentions) *Mentions { - if m == nil { - return other - } else if other == nil { - return m - } - return &Mentions{ - UserIDs: slices.Concat(m.UserIDs, other.UserIDs), - Room: m.Room || other.Room, - } -} - -type MSC4391BotCommandInputCustom[T any] struct { - Command string `json:"command"` - Arguments T `json:"arguments,omitempty"` -} - -type MSC4391BotCommandInput = MSC4391BotCommandInputCustom[json.RawMessage] - -type EncryptedFileInfo struct { - attachment.EncryptedFile - URL id.ContentURIString `json:"url"` -} - -type FileInfo struct { - MimeType string - ThumbnailInfo *FileInfo - ThumbnailURL id.ContentURIString - ThumbnailFile *EncryptedFileInfo - - Blurhash string - AnoaBlurhash string - - MauGIF bool - IsAnimated bool - - Width int - Height int - Duration int - Size int - - BridgedSticker *BridgedSticker - - Extra map[string]any -} - -type serializableFileInfo struct { - MimeType string `json:"mimetype,omitempty"` - ThumbnailInfo *serializableFileInfo `json:"thumbnail_info,omitempty"` - ThumbnailURL id.ContentURIString `json:"thumbnail_url,omitempty"` - ThumbnailFile *EncryptedFileInfo `json:"thumbnail_file,omitempty"` - - Blurhash string `json:"blurhash,omitempty"` - AnoaBlurhash string `json:"xyz.amorgan.blurhash,omitempty"` - - MauGIF bool `json:"fi.mau.gif,omitempty"` - IsAnimated bool `json:"is_animated,omitempty"` - - Width json.Number `json:"w,omitempty"` - Height json.Number `json:"h,omitempty"` - Duration json.Number `json:"duration,omitempty"` - Size json.Number `json:"size,omitempty"` - - BridgedSticker *BridgedSticker `json:"fi.mau.bridged_sticker,omitempty"` -} - -func (fileInfo *FileInfo) IsZero() bool { - return fileInfo.MimeType == "" && - fileInfo.ThumbnailInfo == nil && - fileInfo.ThumbnailURL == "" && - fileInfo.ThumbnailFile == nil && - fileInfo.Blurhash == "" && - fileInfo.AnoaBlurhash == "" && - !fileInfo.MauGIF && - !fileInfo.IsAnimated && - fileInfo.Width == 0 && - fileInfo.Height == 0 && - fileInfo.Duration == 0 && - fileInfo.Size == 0 && - fileInfo.BridgedSticker == nil && - len(fileInfo.Extra) == 0 -} - -func (sfi *serializableFileInfo) CopyFrom(fileInfo *FileInfo) *serializableFileInfo { - if fileInfo == nil { - return nil - } - *sfi = serializableFileInfo{ - MimeType: fileInfo.MimeType, - ThumbnailURL: fileInfo.ThumbnailURL, - ThumbnailInfo: (&serializableFileInfo{}).CopyFrom(fileInfo.ThumbnailInfo), - ThumbnailFile: fileInfo.ThumbnailFile, - - MauGIF: fileInfo.MauGIF, - IsAnimated: fileInfo.IsAnimated, - - Blurhash: fileInfo.Blurhash, - AnoaBlurhash: fileInfo.AnoaBlurhash, - - BridgedSticker: fileInfo.BridgedSticker, - } - if fileInfo.Width > 0 { - sfi.Width = json.Number(strconv.Itoa(fileInfo.Width)) - } - if fileInfo.Height > 0 { - sfi.Height = json.Number(strconv.Itoa(fileInfo.Height)) - } - if fileInfo.Size > 0 { - sfi.Size = json.Number(strconv.Itoa(fileInfo.Size)) - - } - if fileInfo.Duration > 0 { - sfi.Duration = json.Number(strconv.Itoa(int(fileInfo.Duration))) - } - return sfi -} - -func (sfi *serializableFileInfo) CopyTo(fileInfo *FileInfo) { - *fileInfo = FileInfo{ - Width: numberToInt(sfi.Width), - Height: numberToInt(sfi.Height), - Size: numberToInt(sfi.Size), - Duration: numberToInt(sfi.Duration), - MimeType: sfi.MimeType, - ThumbnailURL: sfi.ThumbnailURL, - ThumbnailFile: sfi.ThumbnailFile, - MauGIF: sfi.MauGIF, - IsAnimated: sfi.IsAnimated, - Blurhash: sfi.Blurhash, - AnoaBlurhash: sfi.AnoaBlurhash, - BridgedSticker: sfi.BridgedSticker, - } - if sfi.ThumbnailInfo != nil { - fileInfo.ThumbnailInfo = &FileInfo{} - sfi.ThumbnailInfo.CopyTo(fileInfo.ThumbnailInfo) - } -} - -func (fileInfo *FileInfo) deleteStandardExtraFields() { - if len(fileInfo.Extra) == 0 { - return - } - delete(fileInfo.Extra, "mimetype") - delete(fileInfo.Extra, "thumbnail_info") - delete(fileInfo.Extra, "thumbnail_url") - delete(fileInfo.Extra, "thumbnail_file") - delete(fileInfo.Extra, "blurhash") - delete(fileInfo.Extra, "xyz.amorgan.blurhash") - delete(fileInfo.Extra, "fi.mau.gif") - delete(fileInfo.Extra, "is_animated") - delete(fileInfo.Extra, "w") - delete(fileInfo.Extra, "h") - delete(fileInfo.Extra, "duration") - delete(fileInfo.Extra, "size") - delete(fileInfo.Extra, "fi.mau.bridged_sticker") -} - -func (fileInfo *FileInfo) UnmarshalJSON(data []byte) error { - sfi := &serializableFileInfo{} - if err := json.Unmarshal(data, sfi); err != nil { - return err - } - sfi.CopyTo(fileInfo) - if err := json.Unmarshal(data, &fileInfo.Extra); err != nil { - return err - } - fileInfo.deleteStandardExtraFields() - return nil -} - -func MarshalMerge(base any, extraMap map[string]any) (json.RawMessage, error) { - // TODO replace all uses of this method with jsonv2's extra field once jsonv2 is available - baseSerialized, err := json.Marshal(base) - if len(extraMap) == 0 || err != nil { - return baseSerialized, err - } - extraSerialized, err := json.Marshal(extraMap) - if err != nil { - return nil, err - } - if len(baseSerialized) <= 4 || baseSerialized[0] != '{' { - return extraSerialized, nil - } else if len(extraSerialized) <= 4 || extraSerialized[0] != '{' { - return baseSerialized, nil - } - output := make([]byte, 0, len(baseSerialized)+len(extraSerialized)-1) - output = append(output, baseSerialized[:len(baseSerialized)-1]...) - output = append(output, ',') - output = append(output, extraSerialized[1:]...) - if !json.Valid(output) { - return nil, fmt.Errorf("failed to merge extra: %s", output) - } - return output, nil -} - -func (fileInfo *FileInfo) MarshalJSON() ([]byte, error) { - fileInfo.deleteStandardExtraFields() - return MarshalMerge((&serializableFileInfo{}).CopyFrom(fileInfo), fileInfo.Extra) -} - -func numberToInt(val json.Number) int { - f64, _ := val.Float64() - if f64 > 0 { - return int(f64) - } - return 0 -} - -func (fileInfo *FileInfo) GetThumbnailInfo() *FileInfo { - if fileInfo.ThumbnailInfo == nil { - fileInfo.ThumbnailInfo = &FileInfo{} - } - return fileInfo.ThumbnailInfo -} diff --git a/mautrix-patched/event/message_test.go b/mautrix-patched/event/message_test.go deleted file mode 100644 index 7ef116c9..00000000 --- a/mautrix-patched/event/message_test.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -const invalidMessageEvent = `{ - "sender": "@tulir:maunium.net", - "type": "m.room.message", - "origin_server_ts": 1587252684192, - "event_id": "$foo", - "room_id": "!bar", - "content": { - "body": { - "hmm": false - } - } -}` - -func TestMessageEventContent__ParseInvalid(t *testing.T) { - var evt *event.Event - err := json.Unmarshal([]byte(invalidMessageEvent), &evt) - assert.NoError(t, err) - - assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) - assert.Equal(t, event.EventMessage, evt.Type) - assert.Equal(t, int64(1587252684192), evt.Timestamp) - assert.Equal(t, id.EventID("$foo"), evt.ID) - assert.Equal(t, id.RoomID("!bar"), evt.RoomID) - - err = evt.Content.ParseRaw(evt.Type) - assert.Error(t, err) -} - -const messageEvent = `{ - "sender": "@tulir:maunium.net", - "type": "m.room.message", - "origin_server_ts": 1587252684192, - "event_id": "$foo", - "room_id": "!bar", - "content": { - "msgtype": "m.text", - "body": "* **Hello**, World!", - "format": "org.matrix.custom.html", - "formatted_body": "* Hello, World!", - "m.new_content": { - "msgtype": "m.text", - "body": "**Hello**, World!", - "format": "org.matrix.custom.html", - "formatted_body": "Hello, World!" - } - } -}` - -func TestMessageEventContent__ParseEdit(t *testing.T) { - var evt *event.Event - err := json.Unmarshal([]byte(messageEvent), &evt) - assert.NoError(t, err) - - assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) - assert.Equal(t, event.EventMessage, evt.Type) - assert.Equal(t, int64(1587252684192), evt.Timestamp) - assert.Equal(t, id.EventID("$foo"), evt.ID) - assert.Equal(t, id.RoomID("!bar"), evt.RoomID) - - err = evt.Content.ParseRaw(evt.Type) - require.NoError(t, err) - - assert.IsType(t, &event.MessageEventContent{}, evt.Content.Parsed) - content := evt.Content.Parsed.(*event.MessageEventContent) - assert.Equal(t, event.MsgText, content.MsgType) - assert.Equal(t, event.MsgText, content.NewContent.MsgType) - assert.Equal(t, "**Hello**, World!", content.NewContent.Body) - assert.Equal(t, "Hello, World!", content.NewContent.FormattedBody) -} - -const imageMessageEvent = `{ - "sender": "@tulir:maunium.net", - "type": "m.room.message", - "origin_server_ts": 1587252684192, - "event_id": "$foo", - "room_id": "!bar", - "content": { - "msgtype": "m.image", - "body": "image.png", - "url": "mxc://example.com/image", - "info": { - "mimetype": "image/png", - "w": 64, - "h": 64, - "size": 12345, - "thumbnail_url": "mxc://example.com/image_thumb", - "custom_field": "meow" - } - } -}` - -func TestMessageEventContent__ParseMedia(t *testing.T) { - var evt *event.Event - err := json.Unmarshal([]byte(imageMessageEvent), &evt) - assert.NoError(t, err) - - assert.Equal(t, id.UserID("@tulir:maunium.net"), evt.Sender) - assert.Equal(t, event.EventMessage, evt.Type) - assert.Equal(t, int64(1587252684192), evt.Timestamp) - assert.Equal(t, id.EventID("$foo"), evt.ID) - assert.Equal(t, id.RoomID("!bar"), evt.RoomID) - - err = evt.Content.ParseRaw(evt.Type) - require.NoError(t, err) - - assert.IsType(t, &event.MessageEventContent{}, evt.Content.Parsed) - content := evt.Content.Parsed.(*event.MessageEventContent) - assert.Equal(t, event.MsgImage, content.MsgType) - parsedURL, err := content.URL.Parse() - assert.NoError(t, err) - assert.Equal(t, id.ContentURI{Homeserver: "example.com", FileID: "image"}, parsedURL) - assert.Nil(t, content.NewContent) - assert.Equal(t, "image/png", content.GetInfo().MimeType) - assert.Equal(t, 64, content.GetInfo().Width) - assert.Equal(t, 64, content.GetInfo().Height) - assert.Equal(t, 12345, content.GetInfo().Size) - assert.Equal(t, map[string]any{"custom_field": "meow"}, content.GetInfo().Extra) - - content.GetInfo().Extra["cat"] = 5 - content.GetInfo().Extra["w"] = 1234 - marshaledExtra, err := json.Marshal(content.GetInfo()) - assert.NoError(t, err) - var unmarshaledMap map[string]any - err = json.Unmarshal(marshaledExtra, &unmarshaledMap) - assert.NoError(t, err) - assert.Equal(t, map[string]any{ - "mimetype": "image/png", - "w": 64.0, - "h": 64.0, - "size": 12345.0, - "thumbnail_url": "mxc://example.com/image_thumb", - "custom_field": "meow", - "cat": 5.0, - }, unmarshaledMap) -} - -var parsedMessage = &event.Content{ - Parsed: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "test", - }, -} - -const expectedMarshalResult = `{"msgtype":"m.text","body":"test"}` - -func TestMessageEventContent__Marshal(t *testing.T) { - data, err := json.Marshal(parsedMessage) - assert.NoError(t, err) - assert.Equal(t, expectedMarshalResult, string(data)) -} - -var customParsedMessage = &event.Content{ - Raw: map[string]interface{}{ - "net.maunium.custom": "hello world", - }, - Parsed: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "test", - }, -} - -const expectedCustomMarshalResult = `{"body":"test","msgtype":"m.text","net.maunium.custom":"hello world"}` - -func TestMessageEventContent__Marshal_Custom(t *testing.T) { - data, err := json.Marshal(customParsedMessage) - assert.NoError(t, err) - assert.Equal(t, expectedCustomMarshalResult, string(data)) -} diff --git a/mautrix-patched/event/poll.go b/mautrix-patched/event/poll.go deleted file mode 100644 index 9082f65e..00000000 --- a/mautrix-patched/event/poll.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -type PollResponseEventContent struct { - RelatesTo RelatesTo `json:"m.relates_to"` - Response struct { - Answers []string `json:"answers"` - } `json:"org.matrix.msc3381.poll.response"` -} - -func (content *PollResponseEventContent) GetRelatesTo() *RelatesTo { - return &content.RelatesTo -} - -func (content *PollResponseEventContent) OptionalGetRelatesTo() *RelatesTo { - if content.RelatesTo.Type == "" { - return nil - } - return &content.RelatesTo -} - -func (content *PollResponseEventContent) SetRelatesTo(rel *RelatesTo) { - content.RelatesTo = *rel -} - -type MSC1767Message struct { - Text string `json:"org.matrix.msc1767.text,omitempty"` - HTML string `json:"org.matrix.msc1767.html,omitempty"` - Message []ExtensibleText `json:"org.matrix.msc1767.message,omitempty"` -} - -type PollStartEventContent struct { - RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` - Mentions *Mentions `json:"m.mentions,omitempty"` - PollStart struct { - Kind string `json:"kind"` - MaxSelections int `json:"max_selections"` - Question MSC1767Message `json:"question"` - Answers []struct { - ID string `json:"id"` - MSC1767Message - } `json:"answers"` - } `json:"org.matrix.msc3381.poll.start"` -} - -func (content *PollStartEventContent) GetRelatesTo() *RelatesTo { - if content.RelatesTo == nil { - content.RelatesTo = &RelatesTo{} - } - return content.RelatesTo -} - -func (content *PollStartEventContent) OptionalGetRelatesTo() *RelatesTo { - return content.RelatesTo -} - -func (content *PollStartEventContent) SetRelatesTo(rel *RelatesTo) { - content.RelatesTo = rel -} diff --git a/mautrix-patched/event/powerlevels.go b/mautrix-patched/event/powerlevels.go deleted file mode 100644 index 708721f9..00000000 --- a/mautrix-patched/event/powerlevels.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "math" - "slices" - "sync" - - "go.mau.fi/util/ptr" - "golang.org/x/exp/maps" - - "maunium.net/go/mautrix/id" -) - -// PowerLevelsEventContent represents the content of a m.room.power_levels state event content. -// https://spec.matrix.org/v1.5/client-server-api/#mroompower_levels -type PowerLevelsEventContent struct { - usersLock sync.RWMutex - Users map[id.UserID]int `json:"users,omitempty"` - UsersDefault int `json:"users_default,omitempty"` - - eventsLock sync.RWMutex - Events map[string]int `json:"events,omitempty"` - EventsDefault int `json:"events_default,omitempty"` - - Notifications *NotificationPowerLevels `json:"notifications,omitempty"` - - StateDefaultPtr *int `json:"state_default,omitempty"` - - InvitePtr *int `json:"invite,omitempty"` - KickPtr *int `json:"kick,omitempty"` - BanPtr *int `json:"ban,omitempty"` - RedactPtr *int `json:"redact,omitempty"` - - // This is not a part of power levels, it's added by mautrix-go internally in certain places - // in order to detect creator power accurately. - CreateEvent *Event `json:"-"` -} - -func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent { - if pl == nil { - return nil - } - return &PowerLevelsEventContent{ - Users: maps.Clone(pl.Users), - UsersDefault: pl.UsersDefault, - Events: maps.Clone(pl.Events), - EventsDefault: pl.EventsDefault, - StateDefaultPtr: ptr.Clone(pl.StateDefaultPtr), - - Notifications: pl.Notifications.Clone(), - - InvitePtr: ptr.Clone(pl.InvitePtr), - KickPtr: ptr.Clone(pl.KickPtr), - BanPtr: ptr.Clone(pl.BanPtr), - RedactPtr: ptr.Clone(pl.RedactPtr), - - CreateEvent: pl.CreateEvent, - } -} - -type NotificationPowerLevels struct { - RoomPtr *int `json:"room,omitempty"` -} - -func (npl *NotificationPowerLevels) Clone() *NotificationPowerLevels { - if npl == nil { - return nil - } - return &NotificationPowerLevels{ - RoomPtr: ptr.Clone(npl.RoomPtr), - } -} - -func (npl *NotificationPowerLevels) Room() int { - if npl != nil && npl.RoomPtr != nil { - return *npl.RoomPtr - } - return 50 -} - -func (pl *PowerLevelsEventContent) Invite() int { - if pl.InvitePtr != nil { - return *pl.InvitePtr - } - return 0 -} - -func (pl *PowerLevelsEventContent) Kick() int { - if pl.KickPtr != nil { - return *pl.KickPtr - } - return 50 -} - -func (pl *PowerLevelsEventContent) Ban() int { - if pl.BanPtr != nil { - return *pl.BanPtr - } - return 50 -} - -func (pl *PowerLevelsEventContent) Redact() int { - if pl.RedactPtr != nil { - return *pl.RedactPtr - } - return 50 -} - -func (pl *PowerLevelsEventContent) StateDefault() int { - if pl.StateDefaultPtr != nil { - return *pl.StateDefaultPtr - } - return 50 -} - -func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int { - if pl.isCreator(userID) { - return math.MaxInt - } - pl.usersLock.RLock() - defer pl.usersLock.RUnlock() - level, ok := pl.Users[userID] - if !ok { - return pl.UsersDefault - } - return level -} - -const maxPL = 1<<53 - 1 - -func (pl *PowerLevelsEventContent) SetUserLevel(userID id.UserID, level int) { - pl.usersLock.Lock() - defer pl.usersLock.Unlock() - if pl.isCreator(userID) { - return - } - if level == math.MaxInt && maxPL < math.MaxInt { - // Hack to avoid breaking on 32-bit systems (they're only slightly supported) - x := int64(maxPL) - level = int(x) - } - if level == pl.UsersDefault { - delete(pl.Users, userID) - } else { - if pl.Users == nil { - pl.Users = make(map[id.UserID]int) - } - pl.Users[userID] = level - } -} - -func (pl *PowerLevelsEventContent) EnsureUserLevel(target id.UserID, level int) bool { - return pl.EnsureUserLevelAs("", target, level) -} - -func (pl *PowerLevelsEventContent) createContent() *CreateEventContent { - if pl.CreateEvent == nil { - return &CreateEventContent{} - } - return pl.CreateEvent.Content.AsCreate() -} - -func (pl *PowerLevelsEventContent) isCreator(userID id.UserID) bool { - cc := pl.createContent() - return cc.SupportsCreatorPower() && (userID == pl.CreateEvent.Sender || slices.Contains(cc.AdditionalCreators, userID)) -} - -func (pl *PowerLevelsEventContent) EnsureUserLevelAs(actor, target id.UserID, level int) bool { - if pl.isCreator(target) { - return false - } - existingLevel := pl.GetUserLevel(target) - if actor != "" && !pl.isCreator(actor) { - actorLevel := pl.GetUserLevel(actor) - if actorLevel <= existingLevel || actorLevel < level { - return false - } - } - if existingLevel != level { - pl.SetUserLevel(target, level) - return true - } - return false -} - -func (pl *PowerLevelsEventContent) GetEventLevel(eventType Type) int { - pl.eventsLock.RLock() - defer pl.eventsLock.RUnlock() - level, ok := pl.Events[eventType.String()] - if !ok { - if eventType.IsState() { - return pl.StateDefault() - } - return pl.EventsDefault - } - return level -} - -func (pl *PowerLevelsEventContent) SetEventLevel(eventType Type, level int) { - pl.eventsLock.Lock() - defer pl.eventsLock.Unlock() - if (eventType.IsState() && level == pl.StateDefault()) || (!eventType.IsState() && level == pl.EventsDefault) { - delete(pl.Events, eventType.String()) - } else { - if pl.Events == nil { - pl.Events = make(map[string]int) - } - pl.Events[eventType.String()] = level - } -} - -func (pl *PowerLevelsEventContent) EnsureEventLevel(eventType Type, level int) bool { - return pl.EnsureEventLevelAs("", eventType, level) -} - -func (pl *PowerLevelsEventContent) EnsureEventLevelAs(actor id.UserID, eventType Type, level int) bool { - existingLevel := pl.GetEventLevel(eventType) - if actor != "" && !pl.isCreator(actor) { - actorLevel := pl.GetUserLevel(actor) - if existingLevel > actorLevel || level > actorLevel { - return false - } - } - if existingLevel != level { - pl.SetEventLevel(eventType, level) - return true - } - return false -} diff --git a/mautrix-patched/event/relations.go b/mautrix-patched/event/relations.go deleted file mode 100644 index 38232b43..00000000 --- a/mautrix-patched/event/relations.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - - "maunium.net/go/mautrix/id" -) - -type RelationType string - -const ( - RelReplace RelationType = "m.replace" - RelReference RelationType = "m.reference" - RelAnnotation RelationType = "m.annotation" - RelThread RelationType = "m.thread" - RelBeeperTranscription RelationType = "com.beeper.transcription" -) - -type RelatesTo struct { - Type RelationType `json:"rel_type,omitempty"` - EventID id.EventID `json:"event_id,omitempty"` - Key string `json:"key,omitempty"` - - InReplyTo *InReplyTo `json:"m.in_reply_to,omitempty"` - IsFallingBack bool `json:"is_falling_back,omitempty"` -} - -type InReplyTo struct { - EventID id.EventID `json:"event_id,omitempty"` - - UnstableRoomID id.RoomID `json:"com.beeper.cross_room_id,omitempty"` - BeeperQuote json.RawMessage `json:"com.beeper.quote,omitempty"` -} - -func (rel *RelatesTo) Copy() *RelatesTo { - if rel == nil { - return nil - } - cp := *rel - return &cp -} - -func (rel *RelatesTo) GetReplaceID() id.EventID { - if rel != nil && rel.Type == RelReplace { - return rel.EventID - } - return "" -} - -func (rel *RelatesTo) GetReferenceID() id.EventID { - if rel != nil && rel.Type == RelReference { - return rel.EventID - } - return "" -} - -func (rel *RelatesTo) GetThreadParent() id.EventID { - if rel != nil && rel.Type == RelThread { - return rel.EventID - } - return "" -} - -func (rel *RelatesTo) GetReplyTo() id.EventID { - if rel != nil && rel.InReplyTo != nil { - return rel.InReplyTo.EventID - } - return "" -} - -func (rel *RelatesTo) GetNonFallbackReplyTo() id.EventID { - if rel != nil && rel.InReplyTo != nil && (rel.Type != RelThread || !rel.IsFallingBack) { - return rel.InReplyTo.EventID - } - return "" -} - -func (rel *RelatesTo) GetAnnotationID() id.EventID { - if rel != nil && rel.Type == RelAnnotation { - return rel.EventID - } - return "" -} - -func (rel *RelatesTo) GetAnnotationKey() string { - if rel != nil && rel.Type == RelAnnotation { - return rel.Key - } - return "" -} - -func (rel *RelatesTo) SetReplace(mxid id.EventID) *RelatesTo { - rel.Type = RelReplace - rel.EventID = mxid - return rel -} - -func (rel *RelatesTo) SetReplyTo(mxid id.EventID) *RelatesTo { - if rel.Type != RelThread { - rel.Type = "" - rel.EventID = "" - } - rel.InReplyTo = &InReplyTo{EventID: mxid} - rel.IsFallingBack = false - return rel -} - -func (rel *RelatesTo) SetThread(mxid, fallback id.EventID) *RelatesTo { - rel.Type = RelThread - rel.EventID = mxid - if fallback != "" && rel.GetReplyTo() == "" { - rel.SetReplyTo(fallback) - rel.IsFallingBack = true - } - return rel -} - -func (rel *RelatesTo) SetAnnotation(mxid id.EventID, key string) *RelatesTo { - rel.Type = RelAnnotation - rel.EventID = mxid - rel.Key = key - return rel -} - -type RelationChunkItem struct { - Type RelationType `json:"type"` - EventID string `json:"event_id,omitempty"` - Key string `json:"key,omitempty"` - Count int `json:"count,omitempty"` -} - -type RelationChunk struct { - Chunk []RelationChunkItem `json:"chunk"` - - Limited bool `json:"limited"` - Count int `json:"count"` -} - -type AnnotationChunk struct { - RelationChunk - Map map[string]int `json:"-"` -} - -type serializableAnnotationChunk AnnotationChunk - -func (ac *AnnotationChunk) UnmarshalJSON(data []byte) error { - if err := json.Unmarshal(data, (*serializableAnnotationChunk)(ac)); err != nil { - return err - } - ac.Map = make(map[string]int) - for _, item := range ac.Chunk { - if item.Key != "" { - ac.Map[item.Key] += item.Count - } - } - return nil -} - -func (ac *AnnotationChunk) Serialize() RelationChunk { - ac.Chunk = make([]RelationChunkItem, len(ac.Map)) - i := 0 - for key, count := range ac.Map { - ac.Chunk[i] = RelationChunkItem{ - Type: RelAnnotation, - Key: key, - Count: count, - } - i++ - } - return ac.RelationChunk -} - -type EventIDChunk struct { - RelationChunk - List []string `json:"-"` -} - -type serializableEventIDChunk EventIDChunk - -func (ec *EventIDChunk) UnmarshalJSON(data []byte) error { - if err := json.Unmarshal(data, (*serializableEventIDChunk)(ec)); err != nil { - return err - } - for _, item := range ec.Chunk { - ec.List = append(ec.List, item.EventID) - } - return nil -} - -func (ec *EventIDChunk) Serialize(typ RelationType) RelationChunk { - ec.Chunk = make([]RelationChunkItem, len(ec.List)) - for i, eventID := range ec.List { - ec.Chunk[i] = RelationChunkItem{ - Type: typ, - EventID: eventID, - } - } - return ec.RelationChunk -} - -type Relations struct { - Raw map[RelationType]RelationChunk `json:"-"` - - Annotations AnnotationChunk `json:"m.annotation,omitempty"` - References EventIDChunk `json:"m.reference,omitempty"` - Replaces EventIDChunk `json:"m.replace,omitempty"` -} - -type serializableRelations Relations - -func (relations *Relations) UnmarshalJSON(data []byte) error { - if err := json.Unmarshal(data, &relations.Raw); err != nil { - return err - } - return json.Unmarshal(data, (*serializableRelations)(relations)) -} - -func (relations *Relations) MarshalJSON() ([]byte, error) { - if relations.Raw == nil { - relations.Raw = make(map[RelationType]RelationChunk) - } - relations.Raw[RelAnnotation] = relations.Annotations.Serialize() - relations.Raw[RelReference] = relations.References.Serialize(RelReference) - relations.Raw[RelReplace] = relations.Replaces.Serialize(RelReplace) - for key, item := range relations.Raw { - if !item.Limited { - item.Count = len(item.Chunk) - } - if item.Count == 0 { - delete(relations.Raw, key) - } - } - return json.Marshal(relations.Raw) -} diff --git a/mautrix-patched/event/reply.go b/mautrix-patched/event/reply.go deleted file mode 100644 index 5f55bb80..00000000 --- a/mautrix-patched/event/reply.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "regexp" - "strings" - - "maunium.net/go/mautrix/id" -) - -var HTMLReplyFallbackRegex = regexp.MustCompile(`^[\s\S]+?`) - -func TrimReplyFallbackHTML(html string) string { - return HTMLReplyFallbackRegex.ReplaceAllString(html, "") -} - -func TrimReplyFallbackText(text string) string { - if (!strings.HasPrefix(text, "> <") && !strings.HasPrefix(text, "> * <")) || !strings.Contains(text, "\n") { - return text - } - - lines := strings.Split(text, "\n") - for len(lines) > 0 && strings.HasPrefix(lines[0], "> ") { - lines = lines[1:] - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} - -func (content *MessageEventContent) RemoveReplyFallback() { - if len(content.RelatesTo.GetReplyTo()) > 0 && !content.replyFallbackRemoved && content.Format == FormatHTML { - origHTML := content.FormattedBody - content.FormattedBody = TrimReplyFallbackHTML(content.FormattedBody) - if content.FormattedBody != origHTML { - content.Body = TrimReplyFallbackText(content.Body) - content.replyFallbackRemoved = true - } - } -} - -// Deprecated: RelatesTo methods are nil-safe, so RelatesTo.GetReplyTo can be used directly -func (content *MessageEventContent) GetReplyTo() id.EventID { - return content.RelatesTo.GetReplyTo() -} - -func (content *MessageEventContent) SetReply(inReplyTo *Event) { - if content.RelatesTo == nil { - content.RelatesTo = &RelatesTo{} - } - content.RelatesTo.SetReplyTo(inReplyTo.ID) - if content.Mentions == nil { - content.Mentions = &Mentions{} - } - content.Mentions.Add(inReplyTo.Sender) -} - -func (content *MessageEventContent) SetThread(inReplyTo *Event) { - root := inReplyTo.ID - relatable, ok := inReplyTo.Content.Parsed.(Relatable) - if ok { - targetRoot := relatable.OptionalGetRelatesTo().GetThreadParent() - if targetRoot != "" { - root = targetRoot - } - } - if content.RelatesTo == nil { - content.RelatesTo = &RelatesTo{} - } - content.RelatesTo.SetThread(root, inReplyTo.ID) -} diff --git a/mautrix-patched/event/state.go b/mautrix-patched/event/state.go deleted file mode 100644 index ace170a5..00000000 --- a/mautrix-patched/event/state.go +++ /dev/null @@ -1,357 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/base64" - "encoding/json" - "slices" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/id" -) - -// CanonicalAliasEventContent represents the content of a m.room.canonical_alias state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomcanonical_alias -type CanonicalAliasEventContent struct { - Alias id.RoomAlias `json:"alias"` - AltAliases []id.RoomAlias `json:"alt_aliases,omitempty"` -} - -// RoomNameEventContent represents the content of a m.room.name state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomname -type RoomNameEventContent struct { - Name string `json:"name"` -} - -// RoomAvatarEventContent represents the content of a m.room.avatar state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomavatar -type RoomAvatarEventContent struct { - URL id.ContentURIString `json:"url,omitempty"` - Info *FileInfo `json:"info,omitempty"` - MSC3414File *EncryptedFileInfo `json:"org.matrix.msc3414.file,omitempty"` -} - -// ServerACLEventContent represents the content of a m.room.server_acl state event. -// https://spec.matrix.org/v1.2/client-server-api/#server-access-control-lists-acls-for-rooms -type ServerACLEventContent struct { - Allow []string `json:"allow,omitempty"` - AllowIPLiterals bool `json:"allow_ip_literals"` - Deny []string `json:"deny,omitempty"` -} - -// TopicEventContent represents the content of a m.room.topic state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomtopic -type TopicEventContent struct { - Topic string `json:"topic"` - ExtensibleTopic *ExtensibleTopic `json:"m.topic,omitempty"` -} - -// ExtensibleTopic represents the contents of the m.topic field within the -// m.room.topic state event as described in [MSC3765]. -// -// [MSC3765]: https://github.com/matrix-org/matrix-spec-proposals/pull/3765 -type ExtensibleTopic = ExtensibleTextContainer - -type ExtensibleTextContainer struct { - Text []ExtensibleText `json:"m.text"` -} - -func (c *ExtensibleTextContainer) Equals(description *ExtensibleTextContainer) bool { - if c == nil || description == nil { - return c == description - } - return slices.Equal(c.Text, description.Text) -} - -func MakeExtensibleText(text string) *ExtensibleTextContainer { - return &ExtensibleTextContainer{ - Text: []ExtensibleText{{ - Body: text, - MimeType: "text/plain", - }}, - } -} - -func MakeExtensibleFormattedText(plaintext, html string) *ExtensibleTextContainer { - return &ExtensibleTextContainer{ - Text: []ExtensibleText{{ - Body: plaintext, - MimeType: "text/plain", - }, { - Body: html, - MimeType: "text/html", - }}, - } -} - -// ExtensibleText represents the contents of an m.text field. -type ExtensibleText struct { - MimeType string `json:"mimetype,omitempty"` - Body string `json:"body"` -} - -// TombstoneEventContent represents the content of a m.room.tombstone state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomtombstone -type TombstoneEventContent struct { - Body string `json:"body"` - ReplacementRoom id.RoomID `json:"replacement_room"` -} - -func (tec *TombstoneEventContent) GetReplacementRoom() id.RoomID { - if tec == nil { - return "" - } - return tec.ReplacementRoom -} - -type Predecessor struct { - RoomID id.RoomID `json:"room_id"` - EventID id.EventID `json:"event_id"` -} - -// Deprecated: use id.RoomVersion instead -type RoomVersion = id.RoomVersion - -// Deprecated: use id.RoomVX constants instead -const ( - RoomV1 = id.RoomV1 - RoomV2 = id.RoomV2 - RoomV3 = id.RoomV3 - RoomV4 = id.RoomV4 - RoomV5 = id.RoomV5 - RoomV6 = id.RoomV6 - RoomV7 = id.RoomV7 - RoomV8 = id.RoomV8 - RoomV9 = id.RoomV9 - RoomV10 = id.RoomV10 - RoomV11 = id.RoomV11 - RoomV12 = id.RoomV12 -) - -// CreateEventContent represents the content of a m.room.create state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomcreate -type CreateEventContent struct { - Type RoomType `json:"type,omitempty"` - Federate *bool `json:"m.federate,omitempty"` - RoomVersion id.RoomVersion `json:"room_version,omitempty"` - Predecessor *Predecessor `json:"predecessor,omitempty"` - - // Room v12+ only - AdditionalCreators []id.UserID `json:"additional_creators,omitempty"` - - // Deprecated: use the event sender instead - Creator id.UserID `json:"creator,omitempty"` -} - -func (cec *CreateEventContent) GetPredecessor() (p Predecessor) { - if cec != nil && cec.Predecessor != nil { - p = *cec.Predecessor - } - return -} - -func (cec *CreateEventContent) SupportsCreatorPower() bool { - if cec == nil { - return false - } - return cec.RoomVersion.PrivilegedRoomCreators() -} - -// JoinRule specifies how open a room is to new members. -// https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules -type JoinRule string - -const ( - JoinRulePublic JoinRule = "public" - JoinRuleKnock JoinRule = "knock" - JoinRuleInvite JoinRule = "invite" - JoinRuleRestricted JoinRule = "restricted" - JoinRuleKnockRestricted JoinRule = "knock_restricted" - JoinRulePrivate JoinRule = "private" -) - -// JoinRulesEventContent represents the content of a m.room.join_rules state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules -type JoinRulesEventContent struct { - JoinRule JoinRule `json:"join_rule"` - Allow []JoinRuleAllow `json:"allow,omitempty"` -} - -type JoinRuleAllowType string - -const ( - JoinRuleAllowRoomMembership JoinRuleAllowType = "m.room_membership" -) - -type JoinRuleAllow struct { - RoomID id.RoomID `json:"room_id"` - Type JoinRuleAllowType `json:"type"` -} - -// PinnedEventsEventContent represents the content of a m.room.pinned_events state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroompinned_events -type PinnedEventsEventContent struct { - Pinned []id.EventID `json:"pinned"` -} - -// HistoryVisibility specifies who can see new messages. -// https://spec.matrix.org/v1.2/client-server-api/#mroomhistory_visibility -type HistoryVisibility string - -const ( - HistoryVisibilityInvited HistoryVisibility = "invited" - HistoryVisibilityJoined HistoryVisibility = "joined" - HistoryVisibilityShared HistoryVisibility = "shared" - HistoryVisibilityWorldReadable HistoryVisibility = "world_readable" -) - -// HistoryVisibilityEventContent represents the content of a m.room.history_visibility state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomhistory_visibility -type HistoryVisibilityEventContent struct { - HistoryVisibility HistoryVisibility `json:"history_visibility"` -} - -// GuestAccess specifies whether or not guest accounts can join. -// https://spec.matrix.org/v1.2/client-server-api/#mroomguest_access -type GuestAccess string - -const ( - GuestAccessCanJoin GuestAccess = "can_join" - GuestAccessForbidden GuestAccess = "forbidden" -) - -// GuestAccessEventContent represents the content of a m.room.guest_access state event. -// https://spec.matrix.org/v1.2/client-server-api/#mroomguest_access -type GuestAccessEventContent struct { - GuestAccess GuestAccess `json:"guest_access"` -} - -type BridgeInfoSection struct { - ID string `json:"id"` - DisplayName string `json:"displayname,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - ExternalURL string `json:"external_url,omitempty"` - - Receiver string `json:"fi.mau.receiver,omitempty"` - MessageRequest bool `json:"com.beeper.message_request,omitempty"` -} - -// BridgeEventContent represents the content of a m.bridge state event. -// https://github.com/matrix-org/matrix-doc/pull/2346 -type BridgeEventContent struct { - BridgeBot id.UserID `json:"bridgebot"` - Creator id.UserID `json:"creator,omitempty"` - Protocol BridgeInfoSection `json:"protocol"` - Network *BridgeInfoSection `json:"network,omitempty"` - Channel BridgeInfoSection `json:"channel"` - - BeeperRoomType string `json:"com.beeper.room_type,omitempty"` - BeeperRoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"` - - TempSlackRemoteIDMigratedFlag bool `json:"com.beeper.slack_remote_id_migrated,omitempty"` - TempSlackRemoteIDMigratedFlag2 bool `json:"com.beeper.slack_remote_id_really_migrated,omitempty"` -} - -// DisappearingType represents the type of a disappearing message timer. -type DisappearingType string - -const ( - DisappearingTypeNone DisappearingType = "" - DisappearingTypeAfterRead DisappearingType = "after_read" - DisappearingTypeAfterSend DisappearingType = "after_send" -) - -type BeeperDisappearingTimer struct { - Type DisappearingType `json:"type"` - Timer jsontime.Milliseconds `json:"timer"` -} - -type marshalableBeeperDisappearingTimer BeeperDisappearingTimer - -func (bdt *BeeperDisappearingTimer) MarshalJSON() ([]byte, error) { - if bdt == nil || bdt.Type == DisappearingTypeNone { - return []byte("{}"), nil - } - return json.Marshal((*marshalableBeeperDisappearingTimer)(bdt)) -} - -type SpaceChildEventContent struct { - Via []string `json:"via,omitempty"` - Order string `json:"order,omitempty"` - Suggested bool `json:"suggested,omitempty"` -} - -type SpaceParentEventContent struct { - Via []string `json:"via,omitempty"` - Canonical bool `json:"canonical,omitempty"` -} - -type PolicyRecommendation string - -const ( - PolicyRecommendationBan PolicyRecommendation = "m.ban" - PolicyRecommendationUnstableTakedown PolicyRecommendation = "org.matrix.msc4204.takedown" - PolicyRecommendationUnstableBan PolicyRecommendation = "org.matrix.mjolnir.ban" - PolicyRecommendationUnban PolicyRecommendation = "fi.mau.meowlnir.unban" -) - -type PolicyHashes struct { - SHA256 string `json:"sha256"` -} - -func (ph *PolicyHashes) DecodeSHA256() *[32]byte { - if ph == nil || ph.SHA256 == "" { - return nil - } - decoded, _ := base64.StdEncoding.DecodeString(ph.SHA256) - if len(decoded) == 32 { - return (*[32]byte)(decoded) - } - return nil -} - -// ModPolicyContent represents the content of a m.room.rule.user, m.room.rule.room, and m.room.rule.server state event. -// https://spec.matrix.org/v1.2/client-server-api/#moderation-policy-lists -type ModPolicyContent struct { - Entity string `json:"entity,omitempty"` - Reason string `json:"reason"` - Recommendation PolicyRecommendation `json:"recommendation"` - UnstableHashes *PolicyHashes `json:"org.matrix.msc4205.hashes,omitempty"` -} - -func (mpc *ModPolicyContent) EntityOrHash() string { - if mpc.UnstableHashes != nil && mpc.UnstableHashes.SHA256 != "" { - return mpc.UnstableHashes.SHA256 - } - return mpc.Entity -} - -type ElementFunctionalMembersContent struct { - ServiceMembers []id.UserID `json:"service_members"` -} - -func (efmc *ElementFunctionalMembersContent) Add(mxid id.UserID) bool { - if slices.Contains(efmc.ServiceMembers, mxid) { - return false - } - efmc.ServiceMembers = append(efmc.ServiceMembers, mxid) - return true -} - -type PolicyServerPublicKeys struct { - Ed25519 id.Ed25519 `json:"ed25519,omitempty"` -} - -type RoomPolicyEventContent struct { - Via string `json:"via,omitempty"` - PublicKeys *PolicyServerPublicKeys `json:"public_keys,omitempty"` - - // Deprecated, only for legacy use - PublicKey id.Ed25519 `json:"public_key,omitempty"` -} diff --git a/mautrix-patched/event/type.go b/mautrix-patched/event/type.go deleted file mode 100644 index 0cffbcc6..00000000 --- a/mautrix-patched/event/type.go +++ /dev/null @@ -1,312 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "fmt" - "strings" - - "maunium.net/go/mautrix/id" -) - -type RoomType string - -const ( - RoomTypeDefault RoomType = "" - RoomTypeSpace RoomType = "m.space" -) - -type TypeClass int - -func (tc TypeClass) Name() string { - switch tc { - case MessageEventType: - return "message" - case StateEventType: - return "state" - case EphemeralEventType: - return "ephemeral" - case AccountDataEventType: - return "account data" - case ToDeviceEventType: - return "to-device" - default: - return "unknown" - } -} - -const ( - // Unknown events - UnknownEventType TypeClass = iota - // Normal message events - MessageEventType - // State events - StateEventType - // Ephemeral events - EphemeralEventType - // Account data events - AccountDataEventType - // Device-to-device events - ToDeviceEventType -) - -type Type struct { - Type string - Class TypeClass -} - -func NewEventType(name string) Type { - evtType := Type{Type: name} - evtType.Class = evtType.GuessClass() - return evtType -} - -func (et *Type) IsState() bool { - return et.Class == StateEventType -} - -func (et *Type) IsEphemeral() bool { - return et.Class == EphemeralEventType -} - -func (et *Type) IsAccountData() bool { - return et.Class == AccountDataEventType -} - -func (et *Type) IsToDevice() bool { - return et.Class == ToDeviceEventType -} - -func (et *Type) IsInRoomVerification() bool { - switch et.Type { - case InRoomVerificationStart.Type, InRoomVerificationReady.Type, InRoomVerificationAccept.Type, - InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type: - return true - default: - return false - } -} - -func (et *Type) IsCall() bool { - switch et.Type { - case CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, - CallNegotiate.Type, CallHangup.Type: - return true - default: - return false - } -} - -func (et *Type) IsCustom() bool { - return !strings.HasPrefix(et.Type, "m.") -} - -func (et *Type) GuessClass() TypeClass { - switch et.Type { - case StateAliases.Type, StateCanonicalAlias.Type, StateCreate.Type, StateJoinRules.Type, StateMember.Type, StateThirdPartyInvite.Type, - StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type, - StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type, - StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type, - StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type, - StateMSC4391BotCommand.Type, StateRoomPolicy.Type, StateUnstableRoomPolicy.Type, StateImagePack.Type, - StateUnstableImagePack.Type: - return StateEventType - case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type: - return EphemeralEventType - case AccountDataDirectChats.Type, AccountDataPushRules.Type, AccountDataRoomTags.Type, - AccountDataFullyRead.Type, AccountDataIgnoredUserList.Type, AccountDataMarkedUnread.Type, - AccountDataSecretStorageKey.Type, AccountDataSecretStorageDefaultKey.Type, - AccountDataCrossSigningMaster.Type, AccountDataCrossSigningSelf.Type, AccountDataCrossSigningUser.Type, - AccountDataFullyRead.Type, AccountDataMegolmBackupKey.Type, AccountDataImagePackRooms.Type, - AccountDataUnstableImagePackRooms.Type: - return AccountDataEventType - case EventRedaction.Type, EventMessage.Type, EventEncrypted.Type, EventReaction.Type, EventSticker.Type, - InRoomVerificationStart.Type, InRoomVerificationReady.Type, InRoomVerificationAccept.Type, - InRoomVerificationKey.Type, InRoomVerificationMAC.Type, InRoomVerificationCancel.Type, - CallInvite.Type, CallCandidates.Type, CallAnswer.Type, CallReject.Type, CallSelectAnswer.Type, - CallNegotiate.Type, CallHangup.Type, BeeperMessageStatus.Type, EventUnstablePollStart.Type, EventUnstablePollResponse.Type, - EventUnstablePollEnd.Type, BeeperTranscription.Type, BeeperDeleteChat.Type, BeeperAcceptMessageRequest.Type: - return MessageEventType - case ToDeviceRoomKey.Type, ToDeviceRoomKeyRequest.Type, ToDeviceForwardedRoomKey.Type, ToDeviceRoomKeyWithheld.Type, - ToDeviceBeeperRoomKeyAck.Type, ToDeviceBeeperStreamSubscribe.Type, ToDeviceBeeperStreamUpdate.Type: - return ToDeviceEventType - default: - return UnknownEventType - } -} - -func (et *Type) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, &et.Type) - if err != nil { - return err - } - et.Class = et.GuessClass() - return nil -} - -func (et *Type) MarshalJSON() ([]byte, error) { - return json.Marshal(&et.Type) -} - -func (et *Type) UnmarshalText(data []byte) error { - et.Type = string(data) - et.Class = et.GuessClass() - return nil -} - -func (et Type) MarshalText() ([]byte, error) { - return []byte(et.Type), nil -} - -func (et Type) String() string { - return et.Type -} - -func (et Type) Repr() string { - return fmt.Sprintf("%s (%s)", et.Type, et.Class.Name()) -} - -// State events -var ( - StateAliases = Type{"m.room.aliases", StateEventType} - StateCanonicalAlias = Type{"m.room.canonical_alias", StateEventType} - StateCreate = Type{"m.room.create", StateEventType} - StateJoinRules = Type{"m.room.join_rules", StateEventType} - StateHistoryVisibility = Type{"m.room.history_visibility", StateEventType} - StateGuestAccess = Type{"m.room.guest_access", StateEventType} - StateMember = Type{"m.room.member", StateEventType} - StateThirdPartyInvite = Type{"m.room.third_party_invite", StateEventType} - StatePowerLevels = Type{"m.room.power_levels", StateEventType} - StateRoomName = Type{"m.room.name", StateEventType} - StateTopic = Type{"m.room.topic", StateEventType} - StateRoomAvatar = Type{"m.room.avatar", StateEventType} - StatePinnedEvents = Type{"m.room.pinned_events", StateEventType} - StateServerACL = Type{"m.room.server_acl", StateEventType} - StateTombstone = Type{"m.room.tombstone", StateEventType} - StatePolicyRoom = Type{"m.policy.rule.room", StateEventType} - StatePolicyServer = Type{"m.policy.rule.server", StateEventType} - StatePolicyUser = Type{"m.policy.rule.user", StateEventType} - StateEncryption = Type{"m.room.encryption", StateEventType} - StateBridge = Type{"m.bridge", StateEventType} - StateHalfShotBridge = Type{"uk.half-shot.bridge", StateEventType} - StateSpaceChild = Type{"m.space.child", StateEventType} - StateSpaceParent = Type{"m.space.parent", StateEventType} - - StateRoomPolicy = Type{"m.room.policy", StateEventType} - StateUnstableRoomPolicy = Type{"org.matrix.msc4284.policy", StateEventType} - - StateImagePack = Type{"m.room.image_pack", StateEventType} - StateUnstableImagePack = Type{"im.ponies.room_emotes", StateEventType} - - StateLegacyPolicyRoom = Type{"m.room.rule.room", StateEventType} - StateLegacyPolicyServer = Type{"m.room.rule.server", StateEventType} - StateLegacyPolicyUser = Type{"m.room.rule.user", StateEventType} - StateUnstablePolicyRoom = Type{"org.matrix.mjolnir.rule.room", StateEventType} - StateUnstablePolicyServer = Type{"org.matrix.mjolnir.rule.server", StateEventType} - StateUnstablePolicyUser = Type{"org.matrix.mjolnir.rule.user", StateEventType} - - StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType} - StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType} - StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType} - StateMSC4391BotCommand = Type{"org.matrix.msc4391.command_description", StateEventType} -) - -// Message events -var ( - EventRedaction = Type{"m.room.redaction", MessageEventType} - EventMessage = Type{"m.room.message", MessageEventType} - EventEncrypted = Type{"m.room.encrypted", MessageEventType} - EventReaction = Type{"m.reaction", MessageEventType} - EventSticker = Type{"m.sticker", MessageEventType} - - InRoomVerificationReady = Type{"m.key.verification.ready", MessageEventType} - InRoomVerificationStart = Type{"m.key.verification.start", MessageEventType} - InRoomVerificationDone = Type{"m.key.verification.done", MessageEventType} - InRoomVerificationCancel = Type{"m.key.verification.cancel", MessageEventType} - - // SAS Verification Events - InRoomVerificationAccept = Type{"m.key.verification.accept", MessageEventType} - InRoomVerificationKey = Type{"m.key.verification.key", MessageEventType} - InRoomVerificationMAC = Type{"m.key.verification.mac", MessageEventType} - - CallInvite = Type{"m.call.invite", MessageEventType} - CallCandidates = Type{"m.call.candidates", MessageEventType} - CallAnswer = Type{"m.call.answer", MessageEventType} - CallReject = Type{"m.call.reject", MessageEventType} - CallSelectAnswer = Type{"m.call.select_answer", MessageEventType} - CallNegotiate = Type{"m.call.negotiate", MessageEventType} - CallHangup = Type{"m.call.hangup", MessageEventType} - - BeeperMessageStatus = Type{"com.beeper.message_send_status", MessageEventType} - BeeperTranscription = Type{"com.beeper.transcription", MessageEventType} - BeeperDeleteChat = Type{"com.beeper.delete_chat", MessageEventType} - BeeperAcceptMessageRequest = Type{"com.beeper.accept_message_request", MessageEventType} - BeeperSendState = Type{"com.beeper.send_state", MessageEventType} - - EventUnstablePollStart = Type{Type: "org.matrix.msc3381.poll.start", Class: MessageEventType} - EventUnstablePollResponse = Type{Type: "org.matrix.msc3381.poll.response", Class: MessageEventType} - EventUnstablePollEnd = Type{Type: "org.matrix.msc3381.poll.end", Class: MessageEventType} -) - -// Ephemeral events -var ( - EphemeralEventReceipt = Type{"m.receipt", EphemeralEventType} - EphemeralEventTyping = Type{"m.typing", EphemeralEventType} - EphemeralEventPresence = Type{"m.presence", EphemeralEventType} -) - -// Account data events -var ( - AccountDataDirectChats = Type{"m.direct", AccountDataEventType} - AccountDataPushRules = Type{"m.push_rules", AccountDataEventType} - AccountDataRoomTags = Type{"m.tag", AccountDataEventType} - AccountDataFullyRead = Type{"m.fully_read", AccountDataEventType} - AccountDataIgnoredUserList = Type{"m.ignored_user_list", AccountDataEventType} - AccountDataMarkedUnread = Type{"m.marked_unread", AccountDataEventType} - AccountDataBeeperMute = Type{"com.beeper.mute", AccountDataEventType} - AccountDataSpaceOrder = Type{"org.matrix.msc3230.space_order", AccountDataEventType} - - AccountDataImagePackRooms = Type{"m.image_pack.rooms", AccountDataEventType} - AccountDataUnstableImagePackRooms = Type{"im.ponies.emote_rooms", AccountDataEventType} - - AccountDataSecretStorageDefaultKey = Type{"m.secret_storage.default_key", AccountDataEventType} - AccountDataSecretStorageKey = Type{"m.secret_storage.key", AccountDataEventType} - AccountDataCrossSigningMaster = Type{string(id.SecretXSMaster), AccountDataEventType} - AccountDataCrossSigningUser = Type{string(id.SecretXSUserSigning), AccountDataEventType} - AccountDataCrossSigningSelf = Type{string(id.SecretXSSelfSigning), AccountDataEventType} - AccountDataMegolmBackupKey = Type{string(id.SecretMegolmBackupV1), AccountDataEventType} -) - -// Device-to-device events -var ( - ToDeviceRoomKey = Type{"m.room_key", ToDeviceEventType} - ToDeviceRoomKeyRequest = Type{"m.room_key_request", ToDeviceEventType} - ToDeviceForwardedRoomKey = Type{"m.forwarded_room_key", ToDeviceEventType} - ToDeviceEncrypted = Type{"m.room.encrypted", ToDeviceEventType} - ToDeviceRoomKeyWithheld = Type{"m.room_key.withheld", ToDeviceEventType} - ToDeviceSecretRequest = Type{"m.secret.request", ToDeviceEventType} - ToDeviceSecretSend = Type{"m.secret.send", ToDeviceEventType} - ToDeviceDummy = Type{"m.dummy", ToDeviceEventType} - - ToDeviceVerificationRequest = Type{"m.key.verification.request", ToDeviceEventType} - ToDeviceVerificationReady = Type{"m.key.verification.ready", ToDeviceEventType} - ToDeviceVerificationStart = Type{"m.key.verification.start", ToDeviceEventType} - ToDeviceVerificationDone = Type{"m.key.verification.done", ToDeviceEventType} - ToDeviceVerificationCancel = Type{"m.key.verification.cancel", ToDeviceEventType} - - // SAS Verification Events - ToDeviceVerificationAccept = Type{"m.key.verification.accept", ToDeviceEventType} - ToDeviceVerificationKey = Type{"m.key.verification.key", ToDeviceEventType} - ToDeviceVerificationMAC = Type{"m.key.verification.mac", ToDeviceEventType} - - ToDeviceOrgMatrixRoomKeyWithheld = Type{"org.matrix.room_key.withheld", ToDeviceEventType} - - ToDeviceBeeperRoomKeyAck = Type{"com.beeper.room_key.ack", ToDeviceEventType} - ToDeviceBeeperStreamSubscribe = Type{"com.beeper.stream.subscribe", ToDeviceEventType} - ToDeviceBeeperStreamUpdate = Type{"com.beeper.stream.update", ToDeviceEventType} -) diff --git a/mautrix-patched/event/verification.go b/mautrix-patched/event/verification.go deleted file mode 100644 index 6101896f..00000000 --- a/mautrix-patched/event/verification.go +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright (c) 2020 Nikos Filippakis -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "go.mau.fi/util/jsonbytes" - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/id" -) - -type VerificationMethod string - -const ( - VerificationMethodSAS VerificationMethod = "m.sas.v1" - - VerificationMethodReciprocate VerificationMethod = "m.reciprocate.v1" - VerificationMethodQRCodeShow VerificationMethod = "m.qr_code.show.v1" - VerificationMethodQRCodeScan VerificationMethod = "m.qr_code.scan.v1" -) - -type VerificationTransactionable interface { - GetTransactionID() id.VerificationTransactionID - SetTransactionID(id.VerificationTransactionID) -} - -// ToDeviceVerificationEvent contains the fields common to all to-device -// verification events. -type ToDeviceVerificationEvent struct { - // TransactionID is an opaque identifier for the verification request. Must - // be unique with respect to the devices involved. - TransactionID id.VerificationTransactionID `json:"transaction_id,omitempty"` -} - -var _ VerificationTransactionable = (*ToDeviceVerificationEvent)(nil) - -func (ve *ToDeviceVerificationEvent) GetTransactionID() id.VerificationTransactionID { - return ve.TransactionID -} - -func (ve *ToDeviceVerificationEvent) SetTransactionID(id id.VerificationTransactionID) { - ve.TransactionID = id -} - -// InRoomVerificationEvent contains the fields common to all in-room -// verification events. -type InRoomVerificationEvent struct { - // RelatesTo indicates the m.key.verification.request that this message is - // related to. Note that for encrypted messages, this property should be in - // the unencrypted portion of the event. - RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` -} - -var _ Relatable = (*InRoomVerificationEvent)(nil) - -func (ve *InRoomVerificationEvent) GetRelatesTo() *RelatesTo { - if ve.RelatesTo == nil { - ve.RelatesTo = &RelatesTo{} - } - return ve.RelatesTo -} - -func (ve *InRoomVerificationEvent) OptionalGetRelatesTo() *RelatesTo { - return ve.RelatesTo -} - -func (ve *InRoomVerificationEvent) SetRelatesTo(rel *RelatesTo) { - ve.RelatesTo = rel -} - -// VerificationRequestEventContent represents the content of an -// [m.key.verification.request] to-device event as described in [Section -// 11.12.2.1] of the Spec. -// -// For the in-room version, use a standard [MessageEventContent] struct. -// -// [m.key.verification.request]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationrequest -// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework -type VerificationRequestEventContent struct { - ToDeviceVerificationEvent - // FromDevice is the device ID which is initiating the request. - FromDevice id.DeviceID `json:"from_device"` - // Methods is a list of the verification methods supported by the sender. - Methods []VerificationMethod `json:"methods"` - // Timestamp is the time at which the request was made. - Timestamp jsontime.UnixMilli `json:"timestamp,omitempty"` -} - -// VerificationRequestEventContentFromMessage converts an in-room verification -// request message event to a [VerificationRequestEventContent]. -func VerificationRequestEventContentFromMessage(evt *Event) *VerificationRequestEventContent { - content := evt.Content.AsMessage() - return &VerificationRequestEventContent{ - ToDeviceVerificationEvent: ToDeviceVerificationEvent{ - TransactionID: id.VerificationTransactionID(evt.ID), - }, - Timestamp: jsontime.UMInt(evt.Timestamp), - FromDevice: content.FromDevice, - Methods: content.Methods, - } -} - -// VerificationReadyEventContent represents the content of an -// [m.key.verification.ready] event (both the to-device and the in-room -// version) as described in [Section 11.12.2.1] of the Spec. -// -// [m.key.verification.ready]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationready -// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework -type VerificationReadyEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent - - // FromDevice is the device ID which is initiating the request. - FromDevice id.DeviceID `json:"from_device"` - // Methods is a list of the verification methods supported by the sender. - Methods []VerificationMethod `json:"methods"` -} - -type KeyAgreementProtocol string - -const ( - KeyAgreementProtocolCurve25519 KeyAgreementProtocol = "curve25519" - KeyAgreementProtocolCurve25519HKDFSHA256 KeyAgreementProtocol = "curve25519-hkdf-sha256" -) - -type VerificationHashMethod string - -const VerificationHashMethodSHA256 VerificationHashMethod = "sha256" - -type MACMethod string - -const ( - MACMethodHKDFHMACSHA256 MACMethod = "hkdf-hmac-sha256" - MACMethodHKDFHMACSHA256V2 MACMethod = "hkdf-hmac-sha256.v2" -) - -type SASMethod string - -const ( - SASMethodDecimal SASMethod = "decimal" - SASMethodEmoji SASMethod = "emoji" -) - -// VerificationStartEventContent represents the content of an -// [m.key.verification.start] event (both the to-device and the in-room -// version) as described in [Section 11.12.2.1] of the Spec. -// -// This struct also contains the fields for an [m.key.verification.start] event -// using the [VerificationMethodSAS] method as described in [Section -// 11.12.2.2.2] and an [m.key.verification.start] using -// [VerificationMethodReciprocate] as described in [Section 11.12.2.4.2]. -// -// [m.key.verification.start]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationstart -// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#key-verification-framework -// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas -// [Section 11.12.2.4.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-qr-codes -type VerificationStartEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent - - // FromDevice is the device ID which is initiating the request. - FromDevice id.DeviceID `json:"from_device"` - // Method is the verification method to use. - Method VerificationMethod `json:"method"` - // NextMethod is an optional method to use to verify the other user's key. - // Applicable when the method chosen only verifies one user’s key. This - // field will never be present if the method verifies keys both ways. - NextMethod VerificationMethod `json:"next_method,omitempty"` - - // Hashes are the hash methods the sending device understands. This field - // is only applicable when the method is m.sas.v1. - Hashes []VerificationHashMethod `json:"hashes,omitempty"` - // KeyAgreementProtocols is the list of key agreement protocols the sending - // device understands. This field is only applicable when the method is - // m.sas.v1. - KeyAgreementProtocols []KeyAgreementProtocol `json:"key_agreement_protocols,omitempty"` - // MessageAuthenticationCodes is a list of the MAC methods that the sending - // device understands. This field is only applicable when the method is - // m.sas.v1. - MessageAuthenticationCodes []MACMethod `json:"message_authentication_codes"` - // ShortAuthenticationString is a list of SAS methods the sending device - // (and the sending device's user) understands. This field is only - // applicable when the method is m.sas.v1. - ShortAuthenticationString []SASMethod `json:"short_authentication_string"` - - // Secret is the shared secret from the QR code. This field is only - // applicable when the method is m.reciprocate.v1. - Secret jsonbytes.UnpaddedBytes `json:"secret,omitempty"` -} - -// VerificationDoneEventContent represents the content of an -// [m.key.verification.done] event (both the to-device and the in-room version) -// as described in [Section 11.12.2.1] of the Spec. -// -// This type is an alias for [VerificationRelatable] since there are no -// additional fields defined by the spec. -// -// [m.key.verification.done]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationdone -// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationdone -type VerificationDoneEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent -} - -type VerificationCancelCode string - -const ( - VerificationCancelCodeUser VerificationCancelCode = "m.user" - VerificationCancelCodeTimeout VerificationCancelCode = "m.timeout" - VerificationCancelCodeUnknownTransaction VerificationCancelCode = "m.unknown_transaction" - VerificationCancelCodeUnknownMethod VerificationCancelCode = "m.unknown_method" - VerificationCancelCodeUnexpectedMessage VerificationCancelCode = "m.unexpected_message" - VerificationCancelCodeKeyMismatch VerificationCancelCode = "m.key_mismatch" - VerificationCancelCodeUserMismatch VerificationCancelCode = "m.user_mismatch" - VerificationCancelCodeInvalidMessage VerificationCancelCode = "m.invalid_message" - VerificationCancelCodeAccepted VerificationCancelCode = "m.accepted" - VerificationCancelCodeSASMismatch VerificationCancelCode = "m.mismatched_sas" - VerificationCancelCodeCommitmentMismatch VerificationCancelCode = "m.mismatched_commitment" - - // Non-spec codes - VerificationCancelCodeInternalError VerificationCancelCode = "com.beeper.internal_error" - VerificationCancelCodeMasterKeyNotTrusted VerificationCancelCode = "com.beeper.master_key_not_trusted" // the master key is not trusted by this device, but the QR code that was scanned was from a device that doesn't trust the master key -) - -// VerificationCancelEventContent represents the content of an -// [m.key.verification.cancel] event (both the to-device and the in-room -// version) as described in [Section 11.12.2.1] of the Spec. -// -// [m.key.verification.cancel]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationcancel -// [Section 11.12.2.1]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationdone -type VerificationCancelEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent - - // Code is the error code for why the process/request was cancelled by the - // user. - Code VerificationCancelCode `json:"code"` - // Reason is a human readable description of the code. The client should - // only rely on this string if it does not understand the code. - Reason string `json:"reason"` -} - -// VerificationAcceptEventContent represents the content of an -// [m.key.verification.accept] event (both the to-device and the in-room -// version) as described in [Section 11.12.2.2.2] of the Spec. -// -// [m.key.verification.accept]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationaccept -// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas -type VerificationAcceptEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent - - // Commitment is the hash of the concatenation of the device's ephemeral - // public key (encoded as unpadded base64) and the canonical JSON - // representation of the m.key.verification.start message. - Commitment jsonbytes.UnpaddedBytes `json:"commitment"` - // Hash is the hash method the device is choosing to use, out of the - // options in the m.key.verification.start message. - Hash VerificationHashMethod `json:"hash"` - // KeyAgreementProtocol is the key agreement protocol the device is - // choosing to use, out of the options in the m.key.verification.start - // message. - KeyAgreementProtocol KeyAgreementProtocol `json:"key_agreement_protocol"` - // MessageAuthenticationCode is the message authentication code the device - // is choosing to use, out of the options in the m.key.verification.start - // message. - MessageAuthenticationCode MACMethod `json:"message_authentication_code"` - // ShortAuthenticationString is a list of SAS methods both devices involved - // in the verification process understand. Must be a subset of the options - // in the m.key.verification.start message. - ShortAuthenticationString []SASMethod `json:"short_authentication_string"` -} - -// VerificationKeyEventContent represents the content of an -// [m.key.verification.key] event (both the to-device and the in-room version) -// as described in [Section 11.12.2.2.2] of the Spec. -// -// [m.key.verification.key]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationkey -// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas -type VerificationKeyEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent - - // Key is the device’s ephemeral public key. - Key jsonbytes.UnpaddedBytes `json:"key"` -} - -// VerificationMACEventContent represents the content of an -// [m.key.verification.mac] event (both the to-device and the in-room version) -// as described in [Section 11.12.2.2.2] of the Spec. -// -// [m.key.verification.mac]: https://spec.matrix.org/v1.9/client-server-api/#mkeyverificationmac -// [Section 11.12.2.2.2]: https://spec.matrix.org/v1.9/client-server-api/#verification-messages-specific-to-sas -type VerificationMACEventContent struct { - ToDeviceVerificationEvent - InRoomVerificationEvent - - // Keys is the MAC of the comma-separated, sorted, list of key IDs given in - // the MAC property. - Keys jsonbytes.UnpaddedBytes `json:"keys"` - // MAC is a map of the key ID to the MAC of the key, using the algorithm in - // the verification process. - MAC map[id.KeyID]jsonbytes.UnpaddedBytes `json:"mac"` -} diff --git a/mautrix-patched/event/voip.go b/mautrix-patched/event/voip.go deleted file mode 100644 index cd8364a1..00000000 --- a/mautrix-patched/event/voip.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event - -import ( - "encoding/json" - "fmt" - "strconv" -) - -type CallHangupReason string - -const ( - CallHangupICEFailed CallHangupReason = "ice_failed" - CallHangupInviteTimeout CallHangupReason = "invite_timeout" - CallHangupUserHangup CallHangupReason = "user_hangup" - CallHangupUserMediaFailed CallHangupReason = "user_media_failed" - CallHangupUnknownError CallHangupReason = "unknown_error" -) - -type CallDataType string - -const ( - CallDataTypeOffer CallDataType = "offer" - CallDataTypeAnswer CallDataType = "answer" -) - -type CallData struct { - SDP string `json:"sdp"` - Type CallDataType `json:"type"` -} - -type CallCandidate struct { - Candidate string `json:"candidate"` - SDPMLineIndex int `json:"sdpMLineIndex"` - SDPMID string `json:"sdpMid"` -} - -type CallVersion string - -func (cv *CallVersion) UnmarshalJSON(raw []byte) error { - var numberVersion int - err := json.Unmarshal(raw, &numberVersion) - if err != nil { - var stringVersion string - err = json.Unmarshal(raw, &stringVersion) - if err != nil { - return fmt.Errorf("failed to parse CallVersion: %w", err) - } - *cv = CallVersion(stringVersion) - } else { - *cv = CallVersion(strconv.Itoa(numberVersion)) - } - return nil -} - -func (cv *CallVersion) MarshalJSON() ([]byte, error) { - for _, char := range *cv { - if char < '0' || char > '9' { - // The version contains weird characters, return as string. - return json.Marshal(string(*cv)) - } - } - // The version consists of only ASCII digits, return as an integer. - return []byte(*cv), nil -} - -func (cv *CallVersion) Int() (int, error) { - return strconv.Atoi(string(*cv)) -} - -type BaseCallEventContent struct { - CallID string `json:"call_id"` - PartyID string `json:"party_id"` - Version CallVersion `json:"version,omitempty"` -} - -type CallInviteEventContent struct { - BaseCallEventContent - Lifetime int `json:"lifetime"` - Offer CallData `json:"offer"` -} - -type CallCandidatesEventContent struct { - BaseCallEventContent - Candidates []CallCandidate `json:"candidates"` -} - -type CallRejectEventContent struct { - BaseCallEventContent -} - -type CallAnswerEventContent struct { - BaseCallEventContent - Answer CallData `json:"answer"` -} - -type CallSelectAnswerEventContent struct { - BaseCallEventContent - SelectedPartyID string `json:"selected_party_id"` -} - -type CallNegotiateEventContent struct { - BaseCallEventContent - Lifetime int `json:"lifetime"` - Description CallData `json:"description"` -} - -type CallHangupEventContent struct { - BaseCallEventContent - Reason CallHangupReason `json:"reason"` -} diff --git a/mautrix-patched/event/voip_test.go b/mautrix-patched/event/voip_test.go deleted file mode 100644 index 81014444..00000000 --- a/mautrix-patched/event/voip_test.go +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package event_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/event" -) - -const callCandidates = `{ - "type": "m.call.candidates", - "event_id": "$143273582443PhrSn:example.org", - "origin_server_ts": 1432735824653, - "room_id": "!jEsUZKDJdhlrceRyVU:example.org", - "sender": "@example:example.org", - "content": { - "call_id": "12345", - "candidates": [ - { - "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0", - "sdpMLineIndex": 0, - "sdpMid": "audio" - } - ], - "version": 0 - }, - "unsigned": { - "age": 1234 - } -}` - -const callSelectAnswer = `{ - "type": "m.call.select_answer", - "event_id": "$143273582443PhrSn:example.org", - "origin_server_ts": 1432735824653, - "room_id": "!jEsUZKDJdhlrceRyVU:example.org", - "sender": "@example:example.org", - "content": { - "version": 1, - "call_id": "12345", - "party_id": "67890", - "selected_party_id": "111213" - }, - "unsigned": { - "age": 1234 - } -}` - -const callAnswerStringVersion = `{ - "type": "m.call.answer", - "event_id": "$143273582443PhrSn:example.org", - "origin_server_ts": 1432735824653, - "room_id": "!jEsUZKDJdhlrceRyVU:example.org", - "sender": "@example:example.org", - "content": { - "answer": { - "sdp": "v=0\r\no=- 6584580628695956864 2 IN IP4 127.0.0.1[...]", - "type": "answer" - }, - "call_id": "12345", - "lifetime": 60000, - "version": "com.example.call.version" - }, - "unsigned": { - "age": 1234 - } -}` - -func TestCallCandidatesEventContent_Parse(t *testing.T) { - var evt *event.Event - err := json.Unmarshal([]byte(callCandidates), &evt) - require.NoError(t, err) - require.Equal(t, evt.Type, event.CallCandidates) - err = evt.Content.ParseRaw(evt.Type) - require.NoError(t, err) - content := evt.Content.AsCallCandidates() - require.NotNil(t, content) - assert.Equal(t, event.CallVersion("0"), content.Version) -} - -func TestCallSelectAnswerEventContent_Parse(t *testing.T) { - var evt *event.Event - err := json.Unmarshal([]byte(callSelectAnswer), &evt) - require.NoError(t, err) - require.Equal(t, evt.Type, event.CallSelectAnswer) - err = evt.Content.ParseRaw(evt.Type) - require.NoError(t, err) - content := evt.Content.AsCallSelectAnswer() - require.NotNil(t, content) - assert.Equal(t, event.CallVersion("1"), content.Version) -} - -func TestCallAnswerContent_Parse(t *testing.T) { - var evt *event.Event - err := json.Unmarshal([]byte(callAnswerStringVersion), &evt) - require.NoError(t, err) - require.Equal(t, evt.Type, event.CallAnswer) - err = evt.Content.ParseRaw(evt.Type) - require.NoError(t, err) - content := evt.Content.AsCallAnswer() - require.NotNil(t, content) - assert.Equal(t, event.CallVersion("com.example.call.version"), content.Version) -} - -func TestCallVersion_MarshalJSON(t *testing.T) { - var version event.CallVersion - var data []byte - var err error - - version = "1" - data, err = json.Marshal(&version) - assert.NoError(t, err) - assert.Equal(t, []byte("1"), data) - - version = "0" - data, err = json.Marshal(&version) - assert.NoError(t, err) - assert.Equal(t, []byte("0"), data) - - version = "1234" - data, err = json.Marshal(&version) - assert.NoError(t, err) - assert.Equal(t, []byte("1234"), data) - - version = "com.example.call.version" - data, err = json.Marshal(&version) - assert.NoError(t, err) - assert.Equal(t, []byte(`"com.example.call.version"`), data) -} - -func TestCallVersion_UnmarshalJSON(t *testing.T) { - var version event.CallVersion - var err error - - err = json.Unmarshal([]byte(`1`), &version) - assert.NoError(t, err) - assert.Equal(t, event.CallVersion("1"), version) - - err = json.Unmarshal([]byte(`0`), &version) - assert.NoError(t, err) - assert.Equal(t, event.CallVersion("0"), version) - - err = json.Unmarshal([]byte(`1234`), &version) - assert.NoError(t, err) - assert.Equal(t, event.CallVersion("1234"), version) - - err = json.Unmarshal([]byte(`"1234"`), &version) - assert.NoError(t, err) - assert.Equal(t, event.CallVersion("1234"), version) - - err = json.Unmarshal([]byte(`"com.example.call.version"`), &version) - assert.NoError(t, err) - assert.Equal(t, event.CallVersion("com.example.call.version"), version) - - err = json.Unmarshal([]byte(`1.234`), &version) - assert.Error(t, err) - - err = json.Unmarshal([]byte(`false`), &version) - assert.Error(t, err) - - err = json.Unmarshal([]byte(`["hmm"]`), &version) - assert.Error(t, err) - - err = json.Unmarshal([]byte(`{"hmm": true}`), &version) - assert.Error(t, err) -} diff --git a/mautrix-patched/example/main.go b/mautrix-patched/example/main.go deleted file mode 100644 index 2bf4bef3..00000000 --- a/mautrix-patched/example/main.go +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (C) 2017 Tulir Asokan -// Copyright (C) 2018-2020 Luca Weiss -// Copyright (C) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "os" - "sync" - "time" - - "github.com/chzyer/readline" - _ "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog" - "go.mau.fi/util/exzerolog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/cryptohelper" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var homeserver = flag.String("homeserver", "", "Matrix homeserver") -var username = flag.String("username", "", "Matrix username localpart") -var password = flag.String("password", "", "Matrix password") -var database = flag.String("database", "mautrix-example.db", "SQLite database path") -var debug = flag.Bool("debug", false, "Enable debug logs") - -func main() { - flag.Parse() - if *username == "" || *password == "" || *homeserver == "" { - _, _ = fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) - flag.PrintDefaults() - os.Exit(1) - } - - client, err := mautrix.NewClient(*homeserver, "", "") - if err != nil { - panic(err) - } - rl, err := readline.New("[no room]> ") - if err != nil { - panic(err) - } - defer rl.Close() - log := zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { - w.Out = rl.Stdout() - w.TimeFormat = time.Stamp - })).With().Timestamp().Logger() - if !*debug { - log = log.Level(zerolog.InfoLevel) - } - exzerolog.SetupDefaults(&log) - client.Log = log - - var lastRoomID id.RoomID - - syncer := client.Syncer.(*mautrix.DefaultSyncer) - syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { - lastRoomID = evt.RoomID - rl.SetPrompt(fmt.Sprintf("%s> ", lastRoomID)) - log.Info(). - Str("sender", evt.Sender.String()). - Str("type", evt.Type.String()). - Str("id", evt.ID.String()). - Str("body", evt.Content.AsMessage().Body). - Msg("Received message") - }) - syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { - if evt.GetStateKey() == client.UserID.String() && evt.Content.AsMember().Membership == event.MembershipInvite { - _, err := client.JoinRoomByID(ctx, evt.RoomID) - if err == nil { - lastRoomID = evt.RoomID - rl.SetPrompt(fmt.Sprintf("%s> ", lastRoomID)) - log.Info(). - Str("room_id", evt.RoomID.String()). - Str("inviter", evt.Sender.String()). - Msg("Joined room after invite") - } else { - log.Error().Err(err). - Str("room_id", evt.RoomID.String()). - Str("inviter", evt.Sender.String()). - Msg("Failed to join room after invite") - } - } - }) - - cryptoHelper, err := cryptohelper.NewCryptoHelper(client, []byte("meow"), *database) - if err != nil { - panic(err) - } - - // You can also store the user/device IDs and access token and put them in the client beforehand instead of using LoginAs. - //client.UserID = "..." - //client.DeviceID = "..." - //client.AccessToken = "..." - // You don't need to set a device ID in LoginAs because the crypto helper will set it for you if necessary. - cryptoHelper.LoginAs = &mautrix.ReqLogin{ - Type: mautrix.AuthTypePassword, - Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: *username}, - Password: *password, - } - // If you want to use multiple clients with the same DB, you should set a distinct database account ID for each one. - //cryptoHelper.DBAccountID = "" - err = cryptoHelper.Init(context.TODO()) - if err != nil { - panic(err) - } - // Set the client crypto helper in order to automatically encrypt outgoing messages - client.Crypto = cryptoHelper - - log.Info().Msg("Now running") - syncCtx, cancelSync := context.WithCancel(context.Background()) - var syncStopWait sync.WaitGroup - syncStopWait.Add(1) - - go func() { - err = client.SyncWithContext(syncCtx) - defer syncStopWait.Done() - if err != nil && !errors.Is(err, context.Canceled) { - panic(err) - } - }() - - for { - line, err := rl.Readline() - if err != nil { // io.EOF - break - } - if lastRoomID == "" { - log.Error().Msg("Wait for an incoming message before sending messages") - continue - } - resp, err := client.SendText(context.TODO(), lastRoomID, line) - if err != nil { - log.Error().Err(err).Msg("Failed to send event") - } else { - log.Info().Stringer("event_id", resp.EventID).Msg("Event sent") - } - } - cancelSync() - syncStopWait.Wait() - err = cryptoHelper.Close() - if err != nil { - log.Error().Err(err).Msg("Error closing database") - } -} diff --git a/mautrix-patched/federation/cache.go b/mautrix-patched/federation/cache.go deleted file mode 100644 index 24154974..00000000 --- a/mautrix-patched/federation/cache.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "errors" - "fmt" - "math" - "sync" - "time" -) - -// ResolutionCache is an interface for caching resolved server names. -type ResolutionCache interface { - StoreResolution(*ResolvedServerName) - // LoadResolution loads a resolved server name from the cache. - // Expired entries MUST NOT be returned. - LoadResolution(serverName string) (*ResolvedServerName, error) -} - -type KeyCache interface { - StoreKeys(*ServerKeyResponse) - StoreFetchError(serverName string, err error) - ShouldReQuery(serverName string) bool - LoadKeys(serverName string) (*ServerKeyResponse, error) -} - -type InMemoryCache struct { - MinKeyRefetchDelay time.Duration - - resolutions map[string]*ResolvedServerName - resolutionsLock sync.RWMutex - keys map[string]*ServerKeyResponse - lastReQueryAt map[string]time.Time - lastError map[string]*resolutionErrorCache - keysLock sync.RWMutex -} - -var ( - _ ResolutionCache = (*InMemoryCache)(nil) - _ KeyCache = (*InMemoryCache)(nil) -) - -func NewInMemoryCache() *InMemoryCache { - return &InMemoryCache{ - resolutions: make(map[string]*ResolvedServerName), - keys: make(map[string]*ServerKeyResponse), - lastReQueryAt: make(map[string]time.Time), - lastError: make(map[string]*resolutionErrorCache), - MinKeyRefetchDelay: 1 * time.Hour, - } -} - -func (c *InMemoryCache) StoreResolution(resolution *ResolvedServerName) { - c.resolutionsLock.Lock() - defer c.resolutionsLock.Unlock() - c.resolutions[resolution.ServerName] = resolution -} - -func (c *InMemoryCache) LoadResolution(serverName string) (*ResolvedServerName, error) { - c.resolutionsLock.RLock() - defer c.resolutionsLock.RUnlock() - resolution, ok := c.resolutions[serverName] - if !ok || time.Until(resolution.Expires) < 0 { - return nil, nil - } - return resolution, nil -} - -func (c *InMemoryCache) StoreKeys(keys *ServerKeyResponse) { - c.keysLock.Lock() - defer c.keysLock.Unlock() - c.keys[keys.ServerName] = keys - delete(c.lastError, keys.ServerName) -} - -type resolutionErrorCache struct { - Error error - Time time.Time - Count int -} - -const MaxBackoff = 7 * 24 * time.Hour - -func (rec *resolutionErrorCache) ShouldRetry() bool { - backoff := time.Duration(math.Exp(float64(rec.Count))) * time.Second - return time.Since(rec.Time) > backoff -} - -var ErrRecentKeyQueryFailed = errors.New("last retry was too recent") - -func (c *InMemoryCache) LoadKeys(serverName string) (*ServerKeyResponse, error) { - c.keysLock.RLock() - defer c.keysLock.RUnlock() - keys, ok := c.keys[serverName] - if !ok || time.Until(keys.ValidUntilTS.Time) < 0 { - err, ok := c.lastError[serverName] - if ok && !err.ShouldRetry() { - return nil, fmt.Errorf( - "%w (%s ago) and failed with %w", - ErrRecentKeyQueryFailed, - time.Since(err.Time).String(), - err.Error, - ) - } - return nil, nil - } - return keys, nil -} - -func (c *InMemoryCache) StoreFetchError(serverName string, err error) { - c.keysLock.Lock() - defer c.keysLock.Unlock() - errorCache, ok := c.lastError[serverName] - if ok { - errorCache.Time = time.Now() - errorCache.Error = err - errorCache.Count++ - } else { - c.lastError[serverName] = &resolutionErrorCache{Error: err, Time: time.Now(), Count: 1} - } -} - -func (c *InMemoryCache) ShouldReQuery(serverName string) bool { - c.keysLock.Lock() - defer c.keysLock.Unlock() - lastQuery, ok := c.lastReQueryAt[serverName] - if ok && time.Since(lastQuery) < c.MinKeyRefetchDelay { - return false - } - c.lastReQueryAt[serverName] = time.Now() - return true -} - -type noopCache struct{} - -func (*noopCache) StoreKeys(_ *ServerKeyResponse) {} -func (*noopCache) LoadKeys(_ string) (*ServerKeyResponse, error) { return nil, nil } -func (*noopCache) StoreFetchError(_ string, _ error) {} -func (*noopCache) ShouldReQuery(_ string) bool { return true } -func (*noopCache) StoreResolution(_ *ResolvedServerName) {} -func (*noopCache) LoadResolution(_ string) (*ResolvedServerName, error) { return nil, nil } - -var ( - _ ResolutionCache = (*noopCache)(nil) - _ KeyCache = (*noopCache)(nil) -) - -var NoopCache *noopCache diff --git a/mautrix-patched/federation/client.go b/mautrix-patched/federation/client.go deleted file mode 100644 index 4f3ca083..00000000 --- a/mautrix-patched/federation/client.go +++ /dev/null @@ -1,629 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "time" - - "go.mau.fi/util/exhttp" - "go.mau.fi/util/exslices" - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/federation/signutil" - "maunium.net/go/mautrix/id" -) - -type Client struct { - HTTP *http.Client - ExtHTTP *http.Client - Dialer *net.Dialer - AllowIP func(net.IP) bool - ServerName string - UserAgent string - Key *SigningKey - - ResponseSizeLimit int64 -} - -func NewClient(serverName string, key *SigningKey, cache ResolutionCache, httpSettings exhttp.ClientSettings) *Client { - dialer := &net.Dialer{Timeout: httpSettings.DialTimeout} - c := &Client{ - HTTP: &http.Client{ - Transport: NewServerResolvingTransport(cache, dialer.DialContext, httpSettings), - Timeout: httpSettings.GlobalTimeout, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Federation requests do not allow redirects. - return http.ErrUseLastResponse - }, - }, - ExtHTTP: httpSettings.WithDial(dialer.DialContext).Compile(), - Dialer: dialer, - UserAgent: mautrix.DefaultUserAgent, - ServerName: serverName, - Key: key, - AllowIP: DefaultAllowIP, - - ResponseSizeLimit: mautrix.DefaultResponseSizeLimit, - } - c.ExtHTTP.CheckRedirect = func(req *http.Request, via []*http.Request) error { - // External requests (like media download redirects) can redirect further themselves, - // but only allow secure URLs. - if req.URL.Scheme != "https" { - return fmt.Errorf("attempted to redirect to non-https URL") - } - return nil - } - dialer.ControlContext = c.controlConn - return c -} - -func DefaultAllowIP(ip net.IP) bool { - return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLinkLocalMulticast() -} - -func (c *Client) Version(ctx context.Context, serverName string) (resp *RespServerVersion, err error) { - err = c.MakeRequest(ctx, serverName, false, http.MethodGet, URLPath{"v1", "version"}, nil, &resp) - return -} - -func (c *Client) ServerKeys(ctx context.Context, serverName string) (resp *ServerKeyResponse, err error) { - err = c.MakeRequest(ctx, serverName, false, http.MethodGet, KeyURLPath{"v2", "server"}, nil, &resp) - return -} - -func (c *Client) QueryKeys(ctx context.Context, serverName string, req *ReqQueryKeys) (resp *QueryKeysResponse, err error) { - err = c.MakeRequest(ctx, serverName, false, http.MethodPost, KeyURLPath{"v2", "query"}, req, &resp) - return -} - -type PDU = json.RawMessage -type EDU = json.RawMessage - -type ReqSendTransaction struct { - Destination string `json:"destination"` - TxnID string `json:"-"` - - Origin string `json:"origin"` - OriginServerTS jsontime.UnixMilli `json:"origin_server_ts"` - PDUs []PDU `json:"pdus"` - EDUs []EDU `json:"edus,omitempty"` -} - -type PDUProcessingResult struct { - Error string `json:"error,omitempty"` -} - -type RespSendTransaction struct { - PDUs map[id.EventID]PDUProcessingResult `json:"pdus"` -} - -func (c *Client) SendTransaction(ctx context.Context, req *ReqSendTransaction) (resp *RespSendTransaction, err error) { - err = c.MakeRequest(ctx, req.Destination, true, http.MethodPut, URLPath{"v1", "send", req.TxnID}, req, &resp) - return -} - -type RespGetEventAuthChain struct { - AuthChain []PDU `json:"auth_chain"` -} - -func (c *Client) GetEventAuthChain(ctx context.Context, serverName string, roomID id.RoomID, eventID id.EventID) (resp *RespGetEventAuthChain, err error) { - err = c.MakeRequest(ctx, serverName, true, http.MethodGet, URLPath{"v1", "event_auth", roomID, eventID}, nil, &resp) - return -} - -type ReqBackfill struct { - ServerName string - RoomID id.RoomID - Limit int - BackfillFrom []id.EventID -} - -type RespBackfill struct { - Origin string `json:"origin"` - OriginServerTS jsontime.UnixMilli `json:"origin_server_ts"` - PDUs []PDU `json:"pdus"` -} - -func (c *Client) Backfill(ctx context.Context, req *ReqBackfill) (resp *RespBackfill, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.ServerName, - Method: http.MethodGet, - Path: URLPath{"v1", "backfill", req.RoomID}, - Query: url.Values{ - "limit": {strconv.Itoa(req.Limit)}, - "v": exslices.CastToString[string](req.BackfillFrom), - }, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -type ReqGetMissingEvents struct { - ServerName string `json:"-"` - RoomID id.RoomID `json:"-"` - EarliestEvents []id.EventID `json:"earliest_events"` - LatestEvents []id.EventID `json:"latest_events"` - Limit int `json:"limit,omitempty"` - MinDepth int `json:"min_depth,omitempty"` -} - -type RespGetMissingEvents struct { - Events []PDU `json:"events"` -} - -func (c *Client) GetMissingEvents(ctx context.Context, req *ReqGetMissingEvents) (resp *RespGetMissingEvents, err error) { - err = c.MakeRequest(ctx, req.ServerName, true, http.MethodPost, URLPath{"v1", "get_missing_events", req.RoomID}, req, &resp) - return -} - -func (c *Client) GetEvent(ctx context.Context, serverName string, eventID id.EventID) (resp *RespBackfill, err error) { - err = c.MakeRequest(ctx, serverName, true, http.MethodGet, URLPath{"v1", "event", eventID}, nil, &resp) - return -} - -type RespGetState struct { - AuthChain []PDU `json:"auth_chain"` - PDUs []PDU `json:"pdus"` -} - -func (c *Client) GetState(ctx context.Context, serverName string, roomID id.RoomID, eventID id.EventID) (resp *RespGetState, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "state", roomID}, - Query: url.Values{ - "event_id": {string(eventID)}, - }, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -type RespGetStateIDs struct { - AuthChain []id.EventID `json:"auth_chain_ids"` - PDUs []id.EventID `json:"pdu_ids"` -} - -func (c *Client) GetStateIDs(ctx context.Context, serverName string, roomID id.RoomID, eventID id.EventID) (resp *RespGetStateIDs, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "state_ids", roomID}, - Query: url.Values{ - "event_id": {string(eventID)}, - }, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) TimestampToEvent(ctx context.Context, serverName string, roomID id.RoomID, timestamp time.Time, dir mautrix.Direction) (resp *mautrix.RespTimestampToEvent, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "timestamp_to_event", roomID}, - Query: url.Values{ - "dir": {string(dir)}, - "ts": {strconv.FormatInt(timestamp.UnixMilli(), 10)}, - }, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) QueryProfile(ctx context.Context, serverName string, userID id.UserID) (resp *mautrix.RespUserProfile, err error) { - err = c.Query(ctx, serverName, "profile", url.Values{"user_id": {userID.String()}}, &resp) - return -} - -func (c *Client) QueryDirectory(ctx context.Context, serverName string, roomAlias id.RoomAlias) (resp *mautrix.RespAliasResolve, err error) { - err = c.Query(ctx, serverName, "directory", url.Values{"room_alias": {roomAlias.String()}}, &resp) - return -} - -func (c *Client) Query(ctx context.Context, serverName, queryType string, queryParams url.Values, respStruct any) (err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "query", queryType}, - Query: queryParams, - Authenticate: true, - ResponseJSON: respStruct, - }) - return -} - -func queryToValues(query map[string]string) url.Values { - values := make(url.Values, len(query)) - for k, v := range query { - values[k] = []string{v} - } - return values -} - -func (c *Client) PublicRooms(ctx context.Context, serverName string, req *mautrix.ReqPublicRooms) (resp *mautrix.RespPublicRooms, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "publicRooms"}, - Query: queryToValues(req.Query()), - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -type RespOpenIDUserInfo struct { - Sub id.UserID `json:"sub"` -} - -func (c *Client) GetOpenIDUserInfo(ctx context.Context, serverName, accessToken string) (resp *RespOpenIDUserInfo, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "openid", "userinfo"}, - Query: url.Values{"access_token": {accessToken}}, - ResponseJSON: &resp, - }) - return -} - -type ReqMakeJoin struct { - RoomID id.RoomID - UserID id.UserID - Via string - SupportedVersions []id.RoomVersion -} - -type RespMakeJoin struct { - RoomVersion id.RoomVersion `json:"room_version"` - Event PDU `json:"event"` -} - -type ReqSendJoin struct { - RoomID id.RoomID - EventID id.EventID - OmitMembers bool - Event PDU - Via string -} - -type ReqSendKnock struct { - RoomID id.RoomID - EventID id.EventID - Event PDU - Via string -} - -type RespSendJoin struct { - AuthChain []PDU `json:"auth_chain"` - Event PDU `json:"event"` - MembersOmitted bool `json:"members_omitted"` - ServersInRoom []string `json:"servers_in_room"` - State []PDU `json:"state"` -} - -type RespSendKnock struct { - KnockRoomState []PDU `json:"knock_room_state"` -} - -type ReqSendInvite struct { - RoomID id.RoomID `json:"-"` - UserID id.UserID `json:"-"` - Event PDU `json:"event"` - InviteRoomState []PDU `json:"invite_room_state"` - RoomVersion id.RoomVersion `json:"room_version"` -} - -type RespSendInvite struct { - Event PDU `json:"event"` -} - -type ReqMakeLeave struct { - RoomID id.RoomID - UserID id.UserID - Via string -} - -type ReqSendLeave struct { - RoomID id.RoomID - EventID id.EventID - Event PDU - Via string -} - -type ( - ReqMakeKnock = ReqMakeJoin - RespMakeKnock = RespMakeJoin - RespMakeLeave = RespMakeJoin -) - -func (c *Client) MakeJoin(ctx context.Context, req *ReqMakeJoin) (resp *RespMakeJoin, err error) { - versions := make([]string, len(req.SupportedVersions)) - for i, v := range req.SupportedVersions { - versions[i] = string(v) - } - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.Via, - Method: http.MethodGet, - Path: URLPath{"v1", "make_join", req.RoomID, req.UserID}, - Query: url.Values{"ver": versions}, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) MakeKnock(ctx context.Context, req *ReqMakeKnock) (resp *RespMakeKnock, err error) { - versions := make([]string, len(req.SupportedVersions)) - for i, v := range req.SupportedVersions { - versions[i] = string(v) - } - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.Via, - Method: http.MethodGet, - Path: URLPath{"v1", "make_knock", req.RoomID, req.UserID}, - Query: url.Values{"ver": versions}, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) SendJoin(ctx context.Context, req *ReqSendJoin) (resp *RespSendJoin, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.Via, - Method: http.MethodPut, - Path: URLPath{"v2", "send_join", req.RoomID, req.EventID}, - Query: url.Values{ - "omit_members": {strconv.FormatBool(req.OmitMembers)}, - }, - Authenticate: true, - RequestJSON: req.Event, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) SendKnock(ctx context.Context, req *ReqSendKnock) (resp *RespSendKnock, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.Via, - Method: http.MethodPut, - Path: URLPath{"v1", "send_knock", req.RoomID, req.EventID}, - Authenticate: true, - RequestJSON: req.Event, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) SendInvite(ctx context.Context, req *ReqSendInvite) (resp *RespSendInvite, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.UserID.Homeserver(), - Method: http.MethodPut, - Path: URLPath{"v2", "invite", req.RoomID, req.UserID}, - Authenticate: true, - RequestJSON: req, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) MakeLeave(ctx context.Context, req *ReqMakeLeave) (resp *RespMakeLeave, err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.Via, - Method: http.MethodGet, - Path: URLPath{"v1", "make_leave", req.RoomID, req.UserID}, - Authenticate: true, - ResponseJSON: &resp, - }) - return -} - -func (c *Client) SendLeave(ctx context.Context, req *ReqSendLeave) (err error) { - _, _, err = c.MakeFullRequest(ctx, RequestParams{ - ServerName: req.Via, - Method: http.MethodPut, - Path: URLPath{"v2", "send_leave", req.RoomID, req.EventID}, - Authenticate: true, - RequestJSON: req.Event, - }) - return -} - -type URLPath []any - -func (fup URLPath) FullPath() []any { - return append([]any{"_matrix", "federation"}, []any(fup)...) -} - -type KeyURLPath []any - -func (fkup KeyURLPath) FullPath() []any { - return append([]any{"_matrix", "key"}, []any(fkup)...) -} - -type RequestParams struct { - ServerName string - Method string - Path mautrix.PrefixableURLPath - Query url.Values - Authenticate bool - RequestJSON any - - ResponseJSON any - DontReadBody bool -} - -func (c *Client) MakeRequest(ctx context.Context, serverName string, authenticate bool, method string, path mautrix.PrefixableURLPath, reqJSON, respJSON any) error { - _, _, err := c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: method, - Path: path, - Authenticate: authenticate, - RequestJSON: reqJSON, - ResponseJSON: respJSON, - }) - return err -} - -func (c *Client) MakeFullRequest(ctx context.Context, params RequestParams) ([]byte, *http.Response, error) { - req, err := c.compileRequest(ctx, params) - if err != nil { - return nil, nil, err - } - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, nil, mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "request error", - WrappedError: err, - } - } - if !params.DontReadBody { - defer resp.Body.Close() - } - var body []byte - if resp.StatusCode >= 300 { - body, err = mautrix.ParseErrorResponse(req, resp) - return body, resp, err - } else if params.ResponseJSON != nil || !params.DontReadBody { - if resp.ContentLength > c.ResponseSizeLimit { - return body, resp, mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "not reading response", - WrappedError: fmt.Errorf("%w (%.2f MiB)", mautrix.ErrResponseTooLong, float64(resp.ContentLength)/1024/1024), - } - } - body, err = io.ReadAll(io.LimitReader(resp.Body, c.ResponseSizeLimit+1)) - if err == nil && len(body) > int(c.ResponseSizeLimit) { - err = mautrix.ErrBodyReadReachedLimit - } - if err != nil { - return body, resp, mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "failed to read response body", - WrappedError: err, - } - } - if params.ResponseJSON != nil { - err = json.Unmarshal(body, params.ResponseJSON) - if err != nil { - return body, resp, mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "failed to unmarshal response JSON", - ResponseBody: string(body), - WrappedError: err, - } - } - } - } - return body, resp, nil -} - -func (c *Client) compileRequest(ctx context.Context, params RequestParams) (*http.Request, error) { - reqURL := mautrix.BuildURL(&url.URL{ - Scheme: "matrix-federation", - Host: params.ServerName, - }, params.Path.FullPath()...) - reqURL.RawQuery = params.Query.Encode() - var reqJSON json.RawMessage - var reqBody io.Reader - if params.RequestJSON != nil { - var err error - reqJSON, err = json.Marshal(params.RequestJSON) - if err != nil { - return nil, mautrix.HTTPError{ - Message: "failed to marshal JSON", - WrappedError: err, - } - } - reqBody = bytes.NewReader(reqJSON) - } - req, err := http.NewRequestWithContext(ctx, params.Method, reqURL.String(), reqBody) - if err != nil { - return nil, mautrix.HTTPError{ - Message: "failed to create request", - WrappedError: err, - } - } - req.Header.Set("User-Agent", c.UserAgent) - if params.Authenticate { - if c.ServerName == "" || c.Key == nil { - return nil, mautrix.HTTPError{ - Message: "client not configured for authentication", - } - } - auth, err := (&signableRequest{ - Method: req.Method, - URI: reqURL.RequestURI(), - Origin: c.ServerName, - Destination: params.ServerName, - Content: reqJSON, - }).Sign(c.Key) - if err != nil { - return nil, mautrix.HTTPError{ - Message: "failed to sign request", - WrappedError: err, - } - } - req.Header.Set("Authorization", auth) - } - return req, nil -} - -type signableRequest struct { - Method string `json:"method"` - URI string `json:"uri"` - Origin string `json:"origin"` - Destination string `json:"destination"` - Content json.RawMessage `json:"content,omitempty"` -} - -func (r *signableRequest) Verify(key id.SigningKey, sig string) error { - message, err := canonicaljson.Marshal(r) - if err != nil { - return fmt.Errorf("failed to marshal data: %w", err) - } - return signutil.VerifyJSONCanonical(key, sig, message) -} - -func (r *signableRequest) Sign(key *SigningKey) (string, error) { - sig, err := key.SignJSON(r) - if err != nil { - return "", err - } - return XMatrixAuth{ - Origin: r.Origin, - Destination: r.Destination, - KeyID: key.ID, - Signature: sig, - }.String(), nil -} diff --git a/mautrix-patched/federation/client_test.go b/mautrix-patched/federation/client_test.go deleted file mode 100644 index 785bae2e..00000000 --- a/mautrix-patched/federation/client_test.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - "go.mau.fi/util/exhttp" - - "maunium.net/go/mautrix/federation" -) - -func TestClient_Version(t *testing.T) { - cli := federation.NewClient("", nil, nil, exhttp.SensibleClientSettings) - cli.AllowIP = nil - resp, err := cli.Version(context.TODO(), "maunium.net") - require.NoError(t, err) - require.Equal(t, "Synapse", resp.Server.Name) -} diff --git a/mautrix-patched/federation/context.go b/mautrix-patched/federation/context.go deleted file mode 100644 index eedb2dc1..00000000 --- a/mautrix-patched/federation/context.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "context" - "net/http" -) - -type contextKey int - -const ( - contextKeyIPPort contextKey = iota - contextKeyDestinationServer - contextKeyOriginServer -) - -func DestinationServerNameFromRequest(r *http.Request) string { - return DestinationServerName(r.Context()) -} - -func DestinationServerName(ctx context.Context) string { - if dest, ok := ctx.Value(contextKeyDestinationServer).(string); ok { - return dest - } - return "" -} - -func OriginServerNameFromRequest(r *http.Request) string { - return OriginServerName(r.Context()) -} - -func OriginServerName(ctx context.Context) string { - if origin, ok := ctx.Value(contextKeyOriginServer).(string); ok { - return origin - } - return "" -} diff --git a/mautrix-patched/federation/eventauth/eventauth.go b/mautrix-patched/federation/eventauth/eventauth.go deleted file mode 100644 index 27d1e166..00000000 --- a/mautrix-patched/federation/eventauth/eventauth.go +++ /dev/null @@ -1,851 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package eventauth - -import ( - "encoding/json" - "encoding/json/jsontext" - "errors" - "fmt" - "slices" - "strconv" - "strings" - - "github.com/tidwall/gjson" - "go.mau.fi/util/exgjson" - "go.mau.fi/util/exstrings" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/federation/pdu" - "maunium.net/go/mautrix/federation/signutil" - "maunium.net/go/mautrix/id" -) - -type AuthFailError struct { - Index string - Message string - Wrapped error -} - -func (afe AuthFailError) Error() string { - if afe.Message != "" { - return fmt.Sprintf("fail %s: %s", afe.Index, afe.Message) - } else if afe.Wrapped != nil { - return fmt.Sprintf("fail %s: %s", afe.Index, afe.Wrapped.Error()) - } - return fmt.Sprintf("fail %s", afe.Index) -} - -func (afe AuthFailError) Unwrap() error { - return afe.Wrapped -} - -var mFederatePath = exgjson.Path("m.federate") - -var ( - ErrCreateHasPrevEvents = AuthFailError{Index: "1.1", Message: "m.room.create event has prev_events"} - ErrCreateHasRoomID = AuthFailError{Index: "1.2", Message: "m.room.create event has room_id set"} - ErrRoomIDDoesntMatchSender = AuthFailError{Index: "1.2", Message: "room ID server doesn't match sender server"} - ErrUnknownRoomVersion = AuthFailError{Index: "1.3", Wrapped: id.ErrUnknownRoomVersion} - ErrInvalidAdditionalCreators = AuthFailError{Index: "1.4", Message: "m.room.create event has invalid additional_creators"} - ErrMissingCreator = AuthFailError{Index: "1.4", Message: "m.room.create event is missing creator field"} - - ErrInvalidRoomIDLength = AuthFailError{Index: "2", Message: "room ID length is invalid"} - ErrFailedToGetCreateEvent = AuthFailError{Index: "2", Message: "failed to get m.room.create event"} - ErrCreateEventNotFound = AuthFailError{Index: "2", Message: "m.room.create event not found using room ID as event ID"} - ErrRejectedCreateEvent = AuthFailError{Index: "2", Message: "m.room.create event was rejected"} - - ErrFailedToGetAuthEvents = AuthFailError{Index: "3", Message: "failed to get auth events"} - ErrFailedToParsePowerLevels = AuthFailError{Index: "?", Message: "failed to parse power levels"} - ErrDuplicateAuthEvent = AuthFailError{Index: "3.1", Message: "duplicate type/state key pair in auth events"} - ErrNonStateAuthEvent = AuthFailError{Index: "3.2", Message: "non-state event in auth events"} - ErrMissingAuthEvent = AuthFailError{Index: "3.2", Message: "missing auth event"} - ErrUnexpectedAuthEvent = AuthFailError{Index: "3.2", Message: "unexpected type/state key pair in auth events"} - ErrNoCreateEvent = AuthFailError{Index: "3.2", Message: "no m.room.create event found in auth events"} - ErrRejectedAuthEvent = AuthFailError{Index: "3.3", Message: "auth event was rejected"} - ErrMismatchingRoomIDInAuthEvent = AuthFailError{Index: "3.4", Message: "auth event room ID does not match event room ID"} - - ErrFederationDisabled = AuthFailError{Index: "4", Message: "federation is disabled for this room"} - - ErrMemberNotState = AuthFailError{Index: "5.1", Message: "m.room.member event is not a state event"} - ErrNotSignedByAuthoriser = AuthFailError{Index: "5.2", Message: "m.room.member event is not signed by server of join_authorised_via_users_server"} - ErrCantJoinOtherUser = AuthFailError{Index: "5.3.2", Message: "can't send join event with different state key"} - ErrCantJoinBanned = AuthFailError{Index: "5.3.3", Message: "user is banned from the room"} - ErrAuthoriserCantInvite = AuthFailError{Index: "5.3.5.2", Message: "authoriser doesn't have sufficient power level to invite"} - ErrAuthoriserNotInRoom = AuthFailError{Index: "5.3.5.2", Message: "authoriser isn't a member of the room"} - ErrCantJoinWithoutInvite = AuthFailError{Index: "5.3.7", Message: "can't join invite-only room without invite"} - ErrInvalidJoinRule = AuthFailError{Index: "5.3.7", Message: "invalid join rule in room"} - ErrThirdPartyInviteBanned = AuthFailError{Index: "5.4.1.1", Message: "third party invite target user is banned"} - ErrThirdPartyInviteMissingFields = AuthFailError{Index: "5.4.1.3", Message: "third party invite is missing mxid or token fields"} - ErrThirdPartyInviteMXIDMismatch = AuthFailError{Index: "5.4.1.4", Message: "mxid in signed third party invite doesn't match event state key"} - ErrThirdPartyInviteNotFound = AuthFailError{Index: "5.4.1.5", Message: "matching m.room.third_party_invite event not found in auth events"} - ErrThirdPartyInviteSenderMismatch = AuthFailError{Index: "5.4.1.6", Message: "sender of third party invite doesn't match sender of member event"} - ErrThirdPartyInviteNotSigned = AuthFailError{Index: "5.4.1.8", Message: "no valid signatures found for third party invite"} - ErrInviterNotInRoom = AuthFailError{Index: "5.4.2", Message: "inviter's membership is not join"} - ErrInviteTargetAlreadyInRoom = AuthFailError{Index: "5.4.3", Message: "invite target user is already in the room"} - ErrInviteTargetBanned = AuthFailError{Index: "5.4.3", Message: "invite target user is banned"} - ErrInsufficientPermissionForInvite = AuthFailError{Index: "5.4.5", Message: "inviter does not have sufficient permission to send invites"} - ErrCantLeaveWithoutBeingInRoom = AuthFailError{Index: "5.5.1", Message: "can't leave room without being in it"} - ErrCantKickWithoutBeingInRoom = AuthFailError{Index: "5.5.2", Message: "can't kick another user without being in the room"} - ErrInsufficientPermissionForUnban = AuthFailError{Index: "5.5.3", Message: "sender does not have sufficient permission to unban users"} - ErrInsufficientPermissionForKick = AuthFailError{Index: "5.5.5", Message: "sender does not have sufficient permission to kick the user"} - ErrCantBanWithoutBeingInRoom = AuthFailError{Index: "5.6.1", Message: "can't ban another user without being in the room"} - ErrInsufficientPermissionForBan = AuthFailError{Index: "5.6.3", Message: "sender does not have sufficient permission to ban the user"} - ErrNotKnockableRoom = AuthFailError{Index: "5.7.1", Message: "join rule doesn't allow knocking"} - ErrCantKnockOtherUser = AuthFailError{Index: "5.7.1", Message: "can't send knock event with different state key"} - ErrCantKnockWhileInRoom = AuthFailError{Index: "5.7.2", Message: "can't knock while joined, invited or banned"} - ErrUnknownMembership = AuthFailError{Index: "5.8", Message: "unknown membership in m.room.member event"} - - ErrNotInRoom = AuthFailError{Index: "6", Message: "sender is not a member of the room"} - - ErrInsufficientPowerForThirdPartyInvite = AuthFailError{Index: "7.1", Message: "sender does not have sufficient power level to send third party invite"} - - ErrInsufficientPowerLevel = AuthFailError{Index: "8", Message: "sender does not have sufficient power level to send event"} - - ErrMismatchingPrivateStateKey = AuthFailError{Index: "9", Message: "state keys starting with @ must match sender user ID"} - - ErrTopLevelPLNotInteger = AuthFailError{Index: "10.1", Message: "invalid type for top-level power level field"} - ErrPLNotInteger = AuthFailError{Index: "10.2", Message: "invalid type for power level"} - ErrInvalidUserIDInPL = AuthFailError{Index: "10.3", Message: "invalid user ID in power levels"} - ErrUserPLNotInteger = AuthFailError{Index: "10.3", Message: "invalid type for user power level"} - ErrCreatorInPowerLevels = AuthFailError{Index: "10.4", Message: "room creators must not be specified in power levels"} - ErrInvalidPowerChange = AuthFailError{Index: "10.x", Message: "illegal power level change"} - ErrInvalidUserPowerChange = AuthFailError{Index: "10.9", Message: "illegal power level change"} -) - -func isRejected(evt *pdu.PDU) bool { - return evt.InternalMeta.Rejected -} - -type GetEventsFunc = func(ids []id.EventID) ([]*pdu.PDU, error) - -func Authorize(roomVersion id.RoomVersion, evt *pdu.PDU, getEvents GetEventsFunc, getKey pdu.GetKeyFunc) error { - if evt.Type == event.StateCreate.Type { - // 1. If type is m.room.create: - return authorizeCreate(roomVersion, evt) - } - var createEvt *pdu.PDU - if roomVersion.RoomIDIsCreateEventID() { - // 2. If the event’s room_id is not an event ID for an accepted (not rejected) m.room.create event, - // with the sigil ! instead of $, reject. - if len(evt.RoomID) != 44 { - return fmt.Errorf("%w (%d)", ErrInvalidRoomIDLength, len(evt.RoomID)) - } else if createEvts, err := getEvents([]id.EventID{id.EventID("$" + evt.RoomID[1:])}); err != nil { - return fmt.Errorf("%w: %w", ErrFailedToGetCreateEvent, err) - } else if len(createEvts) != 1 { - return fmt.Errorf("%w (%s)", ErrCreateEventNotFound, evt.RoomID) - } else if isRejected(createEvts[0]) { - return ErrRejectedCreateEvent - } else { - createEvt = createEvts[0] - } - } - authEvents, err := getEvents(evt.AuthEvents) - if err != nil { - return fmt.Errorf("%w: %w", ErrFailedToGetAuthEvents, err) - } - expectedAuthEvents := evt.AuthEventSelection(roomVersion) - deduplicator := make(map[pdu.StateKey]id.EventID, len(expectedAuthEvents)) - // 3. Considering the event’s auth_events: - for i, ae := range authEvents { - authEvtID := evt.AuthEvents[i] - if ae == nil { - return fmt.Errorf("%w (%s)", ErrMissingAuthEvent, authEvtID) - } else if ae.StateKey == nil { - // This approximately falls under rule 3.2. - return fmt.Errorf("%w (%s)", ErrNonStateAuthEvent, authEvtID) - } - key := pdu.StateKey{Type: ae.Type, StateKey: *ae.StateKey} - if prevEvtID, alreadyFound := deduplicator[key]; alreadyFound { - // 3.1. If there are duplicate entries for a given type and state_key pair, reject. - return fmt.Errorf("%w for %s/%s: found %s and %s", ErrDuplicateAuthEvent, ae.Type, *ae.StateKey, prevEvtID, authEvtID) - } else if !expectedAuthEvents.Has(key) { - // 3.2. If there are entries whose type and state_key don’t match those specified by - // the auth events selection algorithm described in the server specification, reject. - return fmt.Errorf("%w: found %s with key %s/%s", ErrUnexpectedAuthEvent, authEvtID, ae.Type, *ae.StateKey) - } else if isRejected(ae) { - // 3.3. If there are entries which were themselves rejected under the checks performed on receipt of a PDU, reject. - return fmt.Errorf("%w (%s)", ErrRejectedAuthEvent, authEvtID) - } else if ae.RoomID != evt.RoomID { - // 3.4. If any event in auth_events has a room_id which does not match that of the event being authorised, reject. - return fmt.Errorf("%w (%s)", ErrMismatchingRoomIDInAuthEvent, authEvtID) - } else { - deduplicator[key] = authEvtID - } - if ae.Type == event.StateCreate.Type { - if createEvt == nil { - createEvt = ae - } else { - // Duplicates are prevented by deduplicator, AuthEventSelection also won't allow a create event at all for v12+ - panic(fmt.Errorf("impossible case: multiple create events found in auth events")) - } - } - } - if createEvt == nil { - // This comes either from auth_events or room_id depending on the room version. - // The checks above make sure it's from the right source. - return ErrNoCreateEvent - } - if federateVal := gjson.GetBytes(createEvt.Content, mFederatePath); federateVal.Type == gjson.False && createEvt.Sender.Homeserver() != evt.Sender.Homeserver() { - // 4. If the content of the m.room.create event in the room state has the property m.federate set to false, - // and the sender domain of the event does not match the sender domain of the create event, reject. - return ErrFederationDisabled - } - if evt.Type == event.StateMember.Type { - // 5. If type is m.room.member: - return authorizeMember(roomVersion, evt, createEvt, authEvents, getKey) - } - senderMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, evt.Sender.String(), "membership", "leave")) - if senderMembership != event.MembershipJoin { - // 6. If the sender’s current membership state is not join, reject. - return ErrNotInRoom - } - powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) - if err != nil { - return err - } - senderPL := powerLevels.GetUserLevel(evt.Sender) - if evt.Type == event.StateThirdPartyInvite.Type { - // 7.1. Allow if and only if sender’s current power level is greater than or equal to the invite level. - if senderPL >= powerLevels.Invite() { - return nil - } - return ErrInsufficientPowerForThirdPartyInvite - } - typeClass := event.MessageEventType - if evt.StateKey != nil { - typeClass = event.StateEventType - } - evtLevel := powerLevels.GetEventLevel(event.Type{Type: evt.Type, Class: typeClass}) - if evtLevel > senderPL { - // 8. If the event type’s required power level is greater than the sender’s power level, reject. - return fmt.Errorf("%w (%d > %d)", ErrInsufficientPowerLevel, evtLevel, senderPL) - } - - if evt.StateKey != nil && strings.HasPrefix(*evt.StateKey, "@") && *evt.StateKey != evt.Sender.String() { - // 9. If the event has a state_key that starts with an @ and does not match the sender, reject. - return ErrMismatchingPrivateStateKey - } - - if evt.Type == event.StatePowerLevels.Type { - // 10. If type is m.room.power_levels: - return authorizePowerLevels(roomVersion, evt, createEvt, authEvents) - } - - // 11. Otherwise, allow. - return nil -} - -var ErrUserIDNotAString = errors.New("not a string") -var ErrUserIDNotValid = errors.New("not a valid user ID") - -func isValidUserID(roomVersion id.RoomVersion, userID gjson.Result) error { - if userID.Type != gjson.String { - return ErrUserIDNotAString - } - // In a future room version, user IDs will have stricter validation - _, _, err := id.UserID(userID.Str).Parse() - if err != nil { - return ErrUserIDNotValid - } - return nil -} - -func authorizeCreate(roomVersion id.RoomVersion, evt *pdu.PDU) error { - if len(evt.PrevEvents) > 0 { - // 1.1. If it has any prev_events, reject. - return ErrCreateHasPrevEvents - } - if roomVersion.RoomIDIsCreateEventID() { - if evt.RoomID != "" { - // 1.2. If the event has a room_id, reject. - return ErrCreateHasRoomID - } - } else { - _, _, server := id.ParseCommonIdentifier(evt.RoomID) - if server == "" || server != evt.Sender.Homeserver() { - // 1.2. (v11 and below) If the domain of the room_id does not match the domain of the sender, reject. - return ErrRoomIDDoesntMatchSender - } - } - if !roomVersion.IsKnown() { - // 1.3. If content.room_version is present and is not a recognised version, reject. - return fmt.Errorf("%w %s", ErrUnknownRoomVersion, roomVersion) - } - if roomVersion.PrivilegedRoomCreators() { - additionalCreators := gjson.GetBytes(evt.Content, "additional_creators") - if additionalCreators.Exists() { - if !additionalCreators.IsArray() { - return fmt.Errorf("%w: not an array", ErrInvalidAdditionalCreators) - } - for i, item := range additionalCreators.Array() { - // 1.4. If additional_creators is present in content and is not an array of strings - // where each string passes the same user ID validation applied to sender, reject. - if err := isValidUserID(roomVersion, item); err != nil { - return fmt.Errorf("%w: item #%d %w", ErrInvalidAdditionalCreators, i+1, err) - } - } - } - } - if roomVersion.CreatorInContent() { - // 1.4. (v10 and below) If content has no creator property, reject. - if !gjson.GetBytes(evt.Content, "creator").Exists() { - return ErrMissingCreator - } - } - // 1.5. Otherwise, allow. - return nil -} - -func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEvents []*pdu.PDU, getKey pdu.GetKeyFunc) error { - membership := event.Membership(gjson.GetBytes(evt.Content, "membership").Str) - if evt.StateKey == nil { - // 5.1. If there is no state_key property, or no membership property in content, reject. - return ErrMemberNotState - } - authorizedVia := id.UserID(gjson.GetBytes(evt.Content, "join_authorised_via_users_server").Str) - if authorizedVia != "" { - homeserver := authorizedVia.Homeserver() - err := evt.VerifySignature(roomVersion, homeserver, getKey) - if err != nil { - // 5.2. If content has a join_authorised_via_users_server key: - // 5.2.1. If the event is not validly signed by the homeserver of the user ID denoted by the key, reject. - return fmt.Errorf("%w: %w", ErrNotSignedByAuthoriser, err) - } - } - targetPrevMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, *evt.StateKey, "membership", "leave")) - senderMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, evt.Sender.String(), "membership", "leave")) - switch membership { - case event.MembershipJoin: - createEvtID, err := createEvt.GetEventID(roomVersion) - if err != nil { - return fmt.Errorf("failed to get create event ID: %w", err) - } - creator := createEvt.Sender.String() - if roomVersion.CreatorInContent() { - creator = gjson.GetBytes(createEvt.Content, "creator").Str - } - if len(evt.PrevEvents) == 1 && - len(evt.AuthEvents) <= 1 && - evt.PrevEvents[0] == createEvtID && - *evt.StateKey == creator { - // 5.3.1. If the only previous event is an m.room.create and the state_key is the sender of the m.room.create, allow. - return nil - } - // Spec wart: this would make more sense before the check above. - // Now you can set anyone as the sender of the first join. - if evt.Sender.String() != *evt.StateKey { - // 5.3.2. If the sender does not match state_key, reject. - return ErrCantJoinOtherUser - } - - if senderMembership == event.MembershipBan { - // 5.3.3. If the sender is banned, reject. - return ErrCantJoinBanned - } - - joinRule := event.JoinRule(findEventAndReadString(authEvents, event.StateJoinRules.Type, "", "join_rule", "invite")) - switch joinRule { - case event.JoinRuleKnock: - if !roomVersion.Knocks() { - return ErrInvalidJoinRule - } - fallthrough - case event.JoinRuleInvite: - // 5.3.4. If the join_rule is invite or knock then allow if membership state is invite or join. - if targetPrevMembership == event.MembershipJoin || targetPrevMembership == event.MembershipInvite { - return nil - } - return ErrCantJoinWithoutInvite - case event.JoinRuleKnockRestricted: - if !roomVersion.KnockRestricted() { - return ErrInvalidJoinRule - } - fallthrough - case event.JoinRuleRestricted: - if joinRule == event.JoinRuleRestricted && !roomVersion.RestrictedJoins() { - return ErrInvalidJoinRule - } - if targetPrevMembership == event.MembershipJoin || targetPrevMembership == event.MembershipInvite { - // 5.3.5.1. If membership state is join or invite, allow. - return nil - } - powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) - if err != nil { - return err - } - if powerLevels.GetUserLevel(authorizedVia) < powerLevels.Invite() { - // 5.3.5.2. If the join_authorised_via_users_server key in content is not a user with sufficient permission to invite other users, reject. - return ErrAuthoriserCantInvite - } - authorizerMembership := event.Membership(findEventAndReadString(authEvents, event.StateMember.Type, authorizedVia.String(), "membership", string(event.MembershipLeave))) - if authorizerMembership != event.MembershipJoin { - return ErrAuthoriserNotInRoom - } - // 5.3.5.3. Otherwise, allow. - return nil - case event.JoinRulePublic: - // 5.3.6. If the join_rule is public, allow. - return nil - default: - // 5.3.7. Otherwise, reject. - return ErrInvalidJoinRule - } - case event.MembershipInvite: - tpiVal := gjson.GetBytes(evt.Content, "third_party_invite") - if tpiVal.Exists() { - if targetPrevMembership == event.MembershipBan { - return ErrThirdPartyInviteBanned - } - signed := tpiVal.Get("signed") - mxid := signed.Get("mxid").Str - token := signed.Get("token").Str - if mxid == "" || token == "" { - // 5.4.1.2. If content.third_party_invite does not have a signed property, reject. - // 5.4.1.3. If signed does not have mxid and token properties, reject. - return ErrThirdPartyInviteMissingFields - } - if mxid != *evt.StateKey { - // 5.4.1.4. If mxid does not match state_key, reject. - return ErrThirdPartyInviteMXIDMismatch - } - tpiEvt := findEvent(authEvents, event.StateThirdPartyInvite.Type, token) - if tpiEvt == nil { - // 5.4.1.5. If there is no m.room.third_party_invite event in the current room state with state_key matching token, reject. - return ErrThirdPartyInviteNotFound - } - if tpiEvt.Sender != evt.Sender { - // 5.4.1.6. If sender does not match sender of the m.room.third_party_invite, reject. - return ErrThirdPartyInviteSenderMismatch - } - var keys []id.Ed25519 - const ed25519Base64Len = 43 - oldPubKey := gjson.GetBytes(evt.Content, "public_key.token") - if oldPubKey.Type == gjson.String && len(oldPubKey.Str) == ed25519Base64Len { - keys = append(keys, id.Ed25519(oldPubKey.Str)) - } - gjson.GetBytes(evt.Content, "public_keys").ForEach(func(key, value gjson.Result) bool { - if key.Type != gjson.Number { - return false - } - if value.Type == gjson.String && len(value.Str) == ed25519Base64Len { - keys = append(keys, id.Ed25519(value.Str)) - } - return true - }) - rawSigned := jsontext.Value(exstrings.UnsafeBytes(signed.Str)) - var validated bool - for _, key := range keys { - if signutil.VerifyJSONAny(key, rawSigned) == nil { - validated = true - } - } - if validated { - // 4.4.1.7. If any signature in signed matches any public key in the m.room.third_party_invite event, allow. - return nil - } - // 4.4.1.8. Otherwise, reject. - return ErrThirdPartyInviteNotSigned - } - if senderMembership != event.MembershipJoin { - // 5.4.2. If the sender’s current membership state is not join, reject. - return ErrInviterNotInRoom - } - // 5.4.3. If target user’s current membership state is join or ban, reject. - if targetPrevMembership == event.MembershipJoin { - return ErrInviteTargetAlreadyInRoom - } else if targetPrevMembership == event.MembershipBan { - return ErrInviteTargetBanned - } - powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) - if err != nil { - return err - } - if powerLevels.GetUserLevel(evt.Sender) >= powerLevels.Invite() { - // 5.4.4. If the sender’s power level is greater than or equal to the invite level, allow. - return nil - } - // 5.4.5. Otherwise, reject. - return ErrInsufficientPermissionForInvite - case event.MembershipLeave: - if evt.Sender.String() == *evt.StateKey { - // 5.5.1. If the sender matches state_key, allow if and only if that user’s current membership state is invite, join, or knock. - if senderMembership == event.MembershipInvite || - senderMembership == event.MembershipJoin || - (senderMembership == event.MembershipKnock && roomVersion.Knocks()) { - return nil - } - return ErrCantLeaveWithoutBeingInRoom - } - if senderMembership != event.MembershipJoin { - // 5.5.2. If the sender’s current membership state is not join, reject. - return ErrCantKickWithoutBeingInRoom - } - powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) - if err != nil { - return err - } - senderLevel := powerLevels.GetUserLevel(evt.Sender) - if targetPrevMembership == event.MembershipBan && senderLevel < powerLevels.Ban() { - // 5.5.3. If the target user’s current membership state is ban, and the sender’s power level is less than the ban level, reject. - return ErrInsufficientPermissionForUnban - } - if senderLevel >= powerLevels.Kick() && powerLevels.GetUserLevel(id.UserID(*evt.StateKey)) < senderLevel { - // 5.5.4. If the sender’s power level is greater than or equal to the kick level, and the target user’s power level is less than the sender’s power level, allow. - return nil - } - // TODO separate errors for < kick and < target user level? - // 5.5.5. Otherwise, reject. - return ErrInsufficientPermissionForKick - case event.MembershipBan: - if senderMembership != event.MembershipJoin { - // 5.6.1. If the sender’s current membership state is not join, reject. - return ErrCantBanWithoutBeingInRoom - } - powerLevels, err := getPowerLevels(roomVersion, authEvents, createEvt) - if err != nil { - return err - } - senderLevel := powerLevels.GetUserLevel(evt.Sender) - if senderLevel >= powerLevels.Ban() && powerLevels.GetUserLevel(id.UserID(*evt.StateKey)) < senderLevel { - // 5.6.2. If the sender’s power level is greater than or equal to the ban level, and the target user’s power level is less than the sender’s power level, allow. - return nil - } - // 5.6.3. Otherwise, reject. - return ErrInsufficientPermissionForBan - case event.MembershipKnock: - joinRule := event.JoinRule(findEventAndReadString(authEvents, event.StateJoinRules.Type, "", "join_rule", "invite")) - validKnockRule := roomVersion.Knocks() && joinRule == event.JoinRuleKnock - validKnockRestrictedRule := roomVersion.KnockRestricted() && joinRule == event.JoinRuleKnockRestricted - if !validKnockRule && !validKnockRestrictedRule { - // 5.7.1. If the join_rule is anything other than knock or knock_restricted, reject. - return ErrNotKnockableRoom - } - if evt.Sender.String() != *evt.StateKey { - // 5.7.2. If the sender does not match state_key, reject. - return ErrCantKnockOtherUser - } - if senderMembership != event.MembershipBan && senderMembership != event.MembershipInvite && senderMembership != event.MembershipJoin { - // 5.7.3. If the sender’s current membership is not ban, invite, or join, allow. - return nil - } - // 5.7.4. Otherwise, reject. - return ErrCantKnockWhileInRoom - default: - // 5.8. Otherwise, the membership is unknown. Reject. - return ErrUnknownMembership - } -} - -func authorizePowerLevels(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEvents []*pdu.PDU) error { - if roomVersion.ValidatePowerLevelInts() { - for _, key := range []string{"users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"} { - res := gjson.GetBytes(evt.Content, key) - if !res.Exists() { - continue - } - if parseIntWithVersion(roomVersion, res) == nil { - // 10.1. If any of the properties users_default, events_default, state_default, ban, redact, kick, or invite in content are present and not an integer, reject. - return fmt.Errorf("%w %s", ErrTopLevelPLNotInteger, key) - } - } - for _, key := range []string{"events", "notifications"} { - obj := gjson.GetBytes(evt.Content, key) - if !obj.Exists() { - continue - } - // 10.2. If either of the properties events or notifications in content are present and not an object [...], reject. - if !obj.IsObject() { - return fmt.Errorf("%w %s", ErrTopLevelPLNotInteger, key) - } - var err error - // 10.2. [...] are not an object with values that are integers, reject. - obj.ForEach(func(innerKey, value gjson.Result) bool { - if parseIntWithVersion(roomVersion, value) == nil { - err = fmt.Errorf("%w %s.%s", ErrPLNotInteger, key, innerKey.Str) - return false - } - return true - }) - if err != nil { - return err - } - } - } - var creators []id.UserID - if roomVersion.PrivilegedRoomCreators() { - creators = append(creators, createEvt.Sender) - gjson.GetBytes(createEvt.Content, "additional_creators").ForEach(func(key, value gjson.Result) bool { - creators = append(creators, id.UserID(value.Str)) - return true - }) - } - users := gjson.GetBytes(evt.Content, "users") - if users.Exists() { - if !users.IsObject() { - // 10.3. If the users property in content is not an object [...], reject. - return fmt.Errorf("%w users", ErrTopLevelPLNotInteger) - } - var err error - users.ForEach(func(key, value gjson.Result) bool { - if validatorErr := isValidUserID(roomVersion, key); validatorErr != nil { - // 10.3. [...] is not an object with keys that are valid user IDs [...], reject. - err = fmt.Errorf("%w: %q %w", ErrInvalidUserIDInPL, key.Str, validatorErr) - return false - } - if parseIntWithVersion(roomVersion, value) == nil { - // 10.3. [...] is not an object [...] with values that are integers, reject. - err = fmt.Errorf("%w %q", ErrUserPLNotInteger, key.Str) - return false - } - // creators is only filled if the room version has privileged room creators - if slices.Contains(creators, id.UserID(key.Str)) { - // 10.4. If the users property in content contains the sender of the m.room.create event or any of - // the additional_creators array (if present) from the content of the m.room.create event, reject. - err = fmt.Errorf("%w: %q", ErrCreatorInPowerLevels, key.Str) - return false - } - return true - }) - if err != nil { - return err - } - } - oldPL := findEvent(authEvents, event.StatePowerLevels.Type, "") - if oldPL == nil { - // 10.5. If there is no previous m.room.power_levels event in the room, allow. - return nil - } - if slices.Contains(creators, evt.Sender) { - // Skip remaining checks for creators - return nil - } - senderPLPtr := parsePythonInt(gjson.GetBytes(oldPL.Content, exgjson.Path("users", evt.Sender.String()))) - if senderPLPtr == nil { - senderPLPtr = parsePythonInt(gjson.GetBytes(oldPL.Content, "users_default")) - if senderPLPtr == nil { - senderPLPtr = ptr.Ptr(0) - } - } - for _, key := range []string{"users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"} { - oldVal := gjson.GetBytes(oldPL.Content, key) - newVal := gjson.GetBytes(evt.Content, key) - if err := allowPowerChange(roomVersion, *senderPLPtr, key, oldVal, newVal); err != nil { - return err - } - } - if err := allowPowerChangeMap( - roomVersion, *senderPLPtr, "events", "", - gjson.GetBytes(oldPL.Content, "events"), - gjson.GetBytes(evt.Content, "events"), - ); err != nil { - return err - } - if err := allowPowerChangeMap( - roomVersion, *senderPLPtr, "notifications", "", - gjson.GetBytes(oldPL.Content, "notifications"), - gjson.GetBytes(evt.Content, "notifications"), - ); err != nil { - return err - } - if err := allowPowerChangeMap( - roomVersion, *senderPLPtr, "users", evt.Sender.String(), - gjson.GetBytes(oldPL.Content, "users"), - gjson.GetBytes(evt.Content, "users"), - ); err != nil { - return err - } - return nil -} - -func allowPowerChangeMap(roomVersion id.RoomVersion, maxVal int, path, ownID string, old, new gjson.Result) (err error) { - old.ForEach(func(key, value gjson.Result) bool { - newVal := new.Get(exgjson.Path(key.Str)) - err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, value, newVal) - if err == nil && ownID != "" && key.Str != ownID { - parsedOldVal := parseIntWithVersion(roomVersion, value) - parsedNewVal := parseIntWithVersion(roomVersion, newVal) - if *parsedOldVal >= maxVal && *parsedOldVal != *parsedNewVal { - err = fmt.Errorf("%w: can't change users.%s from %s to %s with sender level %d", ErrInvalidUserPowerChange, key.Str, stringifyForError(value), stringifyForError(newVal), maxVal) - } - } - return err == nil - }) - if err != nil { - return - } - new.ForEach(func(key, value gjson.Result) bool { - err = allowPowerChange(roomVersion, maxVal, path+"."+key.Str, old.Get(exgjson.Path(key.Str)), value) - return err == nil - }) - return -} - -func allowPowerChange(roomVersion id.RoomVersion, maxVal int, path string, old, new gjson.Result) error { - oldVal := parseIntWithVersion(roomVersion, old) - newVal := parseIntWithVersion(roomVersion, new) - if oldVal == nil { - if newVal == nil || *newVal <= maxVal { - return nil - } - } else if newVal == nil { - if *oldVal <= maxVal { - return nil - } - } else if *oldVal == *newVal || (*oldVal <= maxVal && *newVal <= maxVal) { - return nil - } - return fmt.Errorf("%w can't change %s from %s to %s with sender level %d", ErrInvalidPowerChange, path, stringifyForError(old), stringifyForError(new), maxVal) -} - -func stringifyForError(val gjson.Result) string { - if !val.Exists() { - return "null" - } - return val.Raw -} - -func findEvent(events []*pdu.PDU, evtType, stateKey string) *pdu.PDU { - for _, evt := range events { - if evt.Type == evtType && *evt.StateKey == stateKey { - return evt - } - } - return nil -} - -func findEventAndReadData[T any](events []*pdu.PDU, evtType, stateKey string, reader func(evt *pdu.PDU) T) T { - return reader(findEvent(events, evtType, stateKey)) -} - -func findEventAndReadString(events []*pdu.PDU, evtType, stateKey, fieldPath, defVal string) string { - return findEventAndReadData(events, evtType, stateKey, func(evt *pdu.PDU) string { - if evt == nil { - return defVal - } - res := gjson.GetBytes(evt.Content, fieldPath) - if res.Type != gjson.String { - return defVal - } - return res.Str - }) -} - -func getPowerLevels(roomVersion id.RoomVersion, authEvents []*pdu.PDU, createEvt *pdu.PDU) (*event.PowerLevelsEventContent, error) { - var err error - powerLevels := findEventAndReadData(authEvents, event.StatePowerLevels.Type, "", func(evt *pdu.PDU) *event.PowerLevelsEventContent { - if evt == nil { - return nil - } - content := evt.Content - out := &event.PowerLevelsEventContent{} - if !roomVersion.ValidatePowerLevelInts() { - safeParsePowerLevels(content, out) - } else { - err = json.Unmarshal(content, out) - } - return out - }) - if err != nil { - // This should never happen thanks to safeParsePowerLevels for v1-9 and strict validation in v10+ - return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) - } - if roomVersion.PrivilegedRoomCreators() { - if powerLevels == nil { - powerLevels = &event.PowerLevelsEventContent{} - } - powerLevels.CreateEvent, err = createEvt.ToClientEvent(roomVersion) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) - } - err = powerLevels.CreateEvent.Content.ParseRaw(powerLevels.CreateEvent.Type) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrFailedToParsePowerLevels, err) - } - } else if powerLevels == nil { - powerLevels = &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - createEvt.Sender: 100, - }, - } - } - return powerLevels, nil -} - -func parseIntWithVersion(roomVersion id.RoomVersion, val gjson.Result) *int { - if roomVersion.ValidatePowerLevelInts() { - if val.Type != gjson.Number { - return nil - } - return ptr.Ptr(int(val.Int())) - } - return parsePythonInt(val) -} - -func parsePythonInt(val gjson.Result) *int { - switch val.Type { - case gjson.True: - return ptr.Ptr(1) - case gjson.False: - return ptr.Ptr(0) - case gjson.Number: - return ptr.Ptr(int(val.Int())) - case gjson.String: - // strconv.Atoi accepts signs as well as leading zeroes, so we just need to trim spaces beforehand - num, err := strconv.Atoi(strings.TrimSpace(val.Str)) - if err != nil { - return nil - } - return &num - default: - // Python int() doesn't accept nulls, arrays or dicts - return nil - } -} - -func safeParsePowerLevels(content jsontext.Value, into *event.PowerLevelsEventContent) { - *into = event.PowerLevelsEventContent{ - Users: make(map[id.UserID]int), - UsersDefault: ptr.Val(parsePythonInt(gjson.GetBytes(content, "users_default"))), - Events: make(map[string]int), - EventsDefault: ptr.Val(parsePythonInt(gjson.GetBytes(content, "events_default"))), - Notifications: nil, // irrelevant for event auth - StateDefaultPtr: parsePythonInt(gjson.GetBytes(content, "state_default")), - InvitePtr: parsePythonInt(gjson.GetBytes(content, "invite")), - KickPtr: parsePythonInt(gjson.GetBytes(content, "kick")), - BanPtr: parsePythonInt(gjson.GetBytes(content, "ban")), - RedactPtr: parsePythonInt(gjson.GetBytes(content, "redact")), - } - gjson.GetBytes(content, "events").ForEach(func(key, value gjson.Result) bool { - if key.Type != gjson.String { - return false - } - val := parsePythonInt(value) - if val != nil { - into.Events[key.Str] = *val - } - return true - }) - gjson.GetBytes(content, "users").ForEach(func(key, value gjson.Result) bool { - if key.Type != gjson.String { - return false - } - val := parsePythonInt(value) - if val == nil { - return false - } - userID := id.UserID(key.Str) - if _, _, err := userID.Parse(); err != nil { - return false - } - into.Users[userID] = *val - return true - }) -} diff --git a/mautrix-patched/federation/eventauth/eventauth_internal_test.go b/mautrix-patched/federation/eventauth/eventauth_internal_test.go deleted file mode 100644 index d316f3c8..00000000 --- a/mautrix-patched/federation/eventauth/eventauth_internal_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package eventauth - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -type pythonIntTest struct { - Name string - Input string - Expected int64 -} - -var pythonIntTests = []pythonIntTest{ - {"True", `true`, 1}, - {"False", `false`, 0}, - {"SmallFloat", `3.1415`, 3}, - {"SmallFloatRoundDown", `10.999999999999999`, 10}, - {"SmallFloatRoundUp", `10.9999999999999999`, 11}, - {"BigFloatRoundDown", `1000000.9999999999`, 1000000}, - {"BigFloatRoundUp", `1000000.99999999999`, 1000001}, - {"BigFloatPrecisionError", `9007199254740993.0`, 9007199254740992}, - {"BigFloatPrecisionError2", `9007199254740993.123`, 9007199254740994}, - {"Int64", `9223372036854775807`, 9223372036854775807}, - {"Int64String", `"9223372036854775807"`, 9223372036854775807}, - {"String", `"123"`, 123}, - {"InvalidFloatInString", `"123.456"`, 0}, - {"StringWithPlusSign", `"+123"`, 123}, - {"StringWithMinusSign", `"-123"`, -123}, - {"StringWithSpaces", `" 123 "`, 123}, - {"StringWithSpacesAndSign", `" -123 "`, -123}, - //{"StringWithUnderscores", `"123_456"`, 123456}, - //{"StringWithUnderscores", `"123_456"`, 123456}, - {"InvalidStringWithTrailingUnderscore", `"123_456_"`, 0}, - {"InvalidStringWithMultipleUnderscores", `"123__456"`, 0}, - {"InvalidStringWithLeadingUnderscore", `"_123_456"`, 0}, - {"InvalidStringWithUnderscoreAfterSign", `"+_123_456"`, 0}, - {"InvalidStringWithUnderscoreAfterSpace", `" _123_456"`, 0}, - //{"StringWithUnderscoresAndSpaces", `" +1_2_3_4_5_6 "`, 123456}, -} - -func TestParsePythonInt(t *testing.T) { - for _, test := range pythonIntTests { - t.Run(test.Name, func(t *testing.T) { - output := parsePythonInt(gjson.Parse(test.Input)) - if strings.HasPrefix(test.Name, "Invalid") { - assert.Nil(t, output) - } else { - require.NotNil(t, output) - assert.Equal(t, int(test.Expected), *output) - } - }) - } -} diff --git a/mautrix-patched/federation/eventauth/eventauth_test.go b/mautrix-patched/federation/eventauth/eventauth_test.go deleted file mode 100644 index e3c5cd76..00000000 --- a/mautrix-patched/federation/eventauth/eventauth_test.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package eventauth_test - -import ( - "embed" - "encoding/json/jsontext" - "encoding/json/v2" - "errors" - "io" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/federation/eventauth" - "maunium.net/go/mautrix/federation/pdu" - "maunium.net/go/mautrix/id" -) - -//go:embed *.jsonl -var data embed.FS - -type eventMap map[id.EventID]*pdu.PDU - -func (em eventMap) Get(ids []id.EventID) ([]*pdu.PDU, error) { - output := make([]*pdu.PDU, len(ids)) - for i, evtID := range ids { - output[i] = em[evtID] - } - return output, nil -} - -func GetKey(serverName string, keyID id.KeyID, validUntilTS time.Time) (id.SigningKey, time.Time, error) { - return "", time.Time{}, nil -} - -func TestAuthorize(t *testing.T) { - files := exerrors.Must(data.ReadDir(".")) - for _, file := range files { - t.Run(file.Name(), func(t *testing.T) { - decoder := jsontext.NewDecoder(exerrors.Must(data.Open(file.Name()))) - events := make(eventMap) - var roomVersion *id.RoomVersion - for i := 1; ; i++ { - var evt *pdu.PDU - err := json.UnmarshalDecode(decoder, &evt) - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - if roomVersion == nil { - require.Equal(t, evt.Type, "m.room.create") - roomVersion = ptr.Ptr(id.RoomVersion(gjson.GetBytes(evt.Content, "room_version").Str)) - } - expectedEventID := gjson.GetBytes(evt.Unsigned, "event_id").Str - evtID, err := evt.GetEventID(*roomVersion) - require.NoError(t, err) - require.Equalf(t, id.EventID(expectedEventID), evtID, "Event ID mismatch for event #%d", i) - - // TODO allow redacted events - assert.True(t, evt.VerifyContentHash(), i) - - events[evtID] = evt - err = eventauth.Authorize(*roomVersion, evt, events.Get, GetKey) - if err != nil { - evt.InternalMeta.Rejected = true - } - // TODO allow testing intentionally rejected events - assert.NoErrorf(t, err, "Failed to authorize event #%d / %s of type %s", i, evtID, evt.Type) - } - }) - } - -} diff --git a/mautrix-patched/federation/eventauth/testroom-v12-success.jsonl b/mautrix-patched/federation/eventauth/testroom-v12-success.jsonl deleted file mode 100644 index 2b751de3..00000000 --- a/mautrix-patched/federation/eventauth/testroom-v12-success.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age_ts":1756071567186,"event_id":"$lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54"}} -{"auth_events":[],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":2,"hashes":{"sha256":"MXmgq0e4J9CdIP0IVKVvueFhOb+ndlsXpeyI+6l/2FI"},"origin_server_ts":1756071567259,"prev_events":["$lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"xMgRzyRg9VM9XCKpfFJA+MrYoI68b8PIddKpMTcxz/fDzmGSHEy6Ta2b59VxiX3NoJe2CigkDZ3+jVsQoZYIBA"}},"state_key":"@tulir:maunium.net","type":"m.room.member","unsigned":{"age_ts":1756071567259,"event_id":"$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"}} -{"auth_events":["$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":150},"events_default":0,"historical":100,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001},"users_default":0},"depth":3,"hashes":{"sha256":"/JzQNBNqJ/i8vwj6xESDaD5EDdOqB4l/LmKlvAVl5jY"},"origin_server_ts":1756071567319,"prev_events":["$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"W3N3X/enja+lumXw3uz66/wT9oczoxrmHbAD5/RF069cX4wkCtqtDd61VWPkSGmKxdV1jurgbCqSX6+Q9/t3AA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"age_ts":1756071567319,"event_id":"$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"}} -{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"join_rule":"invite"},"depth":4,"hashes":{"sha256":"GBu5AySj75ZXlOLd65mB03KueFKOHNgvtg2o/LUnLyI"},"origin_server_ts":1756071567320,"prev_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"XqWEnFREo2PhRnaebGjNzdHdtD691BtCQKkLnpKd8P3lVDewDt8OkCbDSk/Uzh9rDtzwWEsbsIoKSYuOm+G6CA"}},"state_key":"","type":"m.room.join_rules","unsigned":{"age_ts":1756071567320,"event_id":"$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"}} -{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"history_visibility":"shared"},"depth":5,"hashes":{"sha256":"niDi5vG2akQm0f5pm0aoCYXqmWjXRfmP1ulr/ZEPm/k"},"origin_server_ts":1756071567320,"prev_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"PTIrNke/fc9+ObKAl/K0PGZfmpe8dwREyoA5rXffOXWdRHSaBifn9UIiJUqd68Bzvrv4RcADTR/ci7lUquFBBw"}},"state_key":"","type":"m.room.history_visibility","unsigned":{"age_ts":1756071567320,"event_id":"$Wmy3G9yxl9ArVg5ZsdeIDPxBsNAdgseuvHoqHTZ2vug"}} -{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"guest_access":"can_join"},"depth":6,"hashes":{"sha256":"sZ9QqsId4oarFF724esTohXuRxDNnaXPl+QmTDG60dw"},"origin_server_ts":1756071567321,"prev_events":["$Wmy3G9yxl9ArVg5ZsdeIDPxBsNAdgseuvHoqHTZ2vug"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"Eh2P9/hl38wfZx2AQbeS5VCD4wldXPfeP2sQsJsLtfmdwFV74jrlGVBaKIkaYcXY4eA08iDp8HW5jqttZqKKDg"}},"state_key":"","type":"m.room.guest_access","unsigned":{"age_ts":1756071567321,"event_id":"$hYVRH7F4P5mB5IqvBDDU5aXY7pYGG0ApstrryiVPKmQ"}} -{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"name":"event auth test v12"},"depth":7,"hashes":{"sha256":"tjwPo38yR+23Was6SbxLvPMhNx44DaXLhF3rKgngepU"},"origin_server_ts":1756071567321,"prev_events":["$hYVRH7F4P5mB5IqvBDDU5aXY7pYGG0ApstrryiVPKmQ"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"q1rk0c5m8TJYE9tePsMaLeaigatNNbvaLRom0X8KiZY0EH+itujfA+/UnksvmPmMmThfAXWlFLx5u8tcuSVyCQ"}},"state_key":"","type":"m.room.name","unsigned":{"age_ts":1756071567321,"event_id":"$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"}} -{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"invite"},"depth":8,"hashes":{"sha256":"r5EBUZN/4LbVcMYwuffDcVV9G4OMHzAQuNbnjigL+OE"},"origin_server_ts":1756071567548,"prev_events":["$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"envs.net":{"ed25519:wuJyKT":"svB+uW4Tsj8/I+SYbLl+LPPjBlqxGNXE4wGyAxlP7vfyJtFf7Kn/19jx65wT9ebeCq5sTGlEDV4Fabwma9LhDA"},"maunium.net":{"ed25519:a_xxeS":"LBYMcdJVSNsLd6SmOgx5oOU/0xOeCl03o4g83VwJfHWlRuTT5l9+qlpNED28wY07uxoU9MgLgXXICJ0EezMBCg"}},"state_key":"@tulir:envs.net","type":"m.room.member","unsigned":{"age_ts":1756071567548,"event_id":"$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok","invite_room_state":[{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"},{"content":{"name":"event auth test v12"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.name"},{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age_ts":1756071567186}},{"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member"}]}} -{"auth_events":["$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$mmqm2KS4UExkNL65c6CIhKofn_L9fzF2OhghVqajksU"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":9,"hashes":{"sha256":"23rgMf7EGJcYt3Aj0qAFnmBWCxuU9Uk+ReidqtIJDKQ"},"origin_server_ts":1756071575986,"prev_events":["$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"p+Fm/uWO8VXJdCYvN/dVb8HF8W3t1sssNCBiOWbzAeuS3QqYjoMKHyixLuN1mOdnCyATv7SsHHmA4+cELRGdAA"}},"type":"m.room.message","unsigned":{"age_ts":1756071576002,"event_id":"$eZDCydRWSRnR5od0c7ahz2qSZQDHbl5g5PITT0OMC3E"}} -{"auth_events":["$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M"],"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"join"},"depth":10,"hashes":{"sha256":"2kJPx2UsysNzTH8QGYHUKTO/05yetxKRlI0nKFeGbts"},"origin_server_ts":1756071578631,"prev_events":["$eZDCydRWSRnR5od0c7ahz2qSZQDHbl5g5PITT0OMC3E"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"Wuzxkh8nEEX6mdJzph6Bt5ku+odFkEg2RIpFAAirOqxgcrwRaz42PsJni3YbfzH1qneF+iWQ/neA+up6jLXFBw"}},"state_key":"@tulir:envs.net","type":"m.room.member","unsigned":{"age":6,"event_id":"$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","replaces_state":"$qYZqSKiKMCNjzH6Trhr6nBSvbfuwr8Sh2bC4USSAxok"}} -{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"invite"},"depth":11,"hashes":{"sha256":"dRE11R2hBfFalQ5tIJdyaElUIiSE5aCKMddjek4wR3c"},"origin_server_ts":1756071591449,"prev_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"/Mi4kX40fbR+V3DCJJGI/9L3Uuf8y5Un8LHlCQv1T0O5gnFZGQ3qN6rRNaZ1Kdh3QJBU6H4NTfnd+SVj3wt3CQ"},"matrix.org":{"ed25519:a_RXGa":"ZeLm/oxP3/Cds/uCL2FaZpgjUp0vTDBlGG6YVFNl76yIVlyIKKQKR6BSVw2u5KC5Mu9M1f+0lDmLGQujR5NkBg"}},"state_key":"@tulir:matrix.org","type":"m.room.member","unsigned":{"event_id":"$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4","invite_room_state":[{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"},{"content":{"name":"event auth test v12"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.name"},{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age":11553}},{"content":{"avatar_url":"mxc://envs.net/000cf1510b7c61018f9c72ca4cc63668370782c81725865933316030464","displayname":"tulir[e]","membership":"join"},"sender":"@tulir:envs.net","state_key":"@tulir:envs.net","type":"m.room.member"}]}} -{"auth_events":["$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4","$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"join"},"depth":12,"hashes":{"sha256":"hR/fRIyFkxKnA1XNxIB+NKC0VR0vHs82EDgydhmmZXU"},"origin_server_ts":1756071609205,"prev_events":["$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"keWbZHm+LPW22XWxb14Att4Ae4GVc6XAKAnxFRr3hxhrgEhsnMcxUx7fjqlA1dk3As6kjLKdekcyCef+AQCXCA"}},"state_key":"@tulir:matrix.org","type":"m.room.member","unsigned":{"age":19,"event_id":"$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","replaces_state":"$g4eBtA9EFNGLkHOofvQ4U87GNt4W8NmfmNRyR0wOUO4"}} -{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":150},"events_default":0,"historical":100,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":13,"hashes":{"sha256":"30Wuw3xIbA8+eXQBa4nFDKcyHtMbKPBYhLW1zft9/fE"},"origin_server_ts":1756071643928,"prev_events":["$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"x6Y4uViq4nK8LVPqtMLdCuvNET2bnjxYTgiKuEe1JYfwB4jPBnPuqvrt1O9oaanMpcRWbnuiZjckq4bUlRZ7Cw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg","replaces_state":"$v3gylw64IK4PohOe0M8XO1PZthibpBCKVBI3x_8xiUU"}} -{"auth_events":["$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg","$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw"],"content":{"name":"event auth test v12!"},"depth":14,"hashes":{"sha256":"WT0gz7KYXvbdNruRavqIi9Hhul3rxCdZ+YY9yMGN+Fw"},"origin_server_ts":1756071656988,"prev_events":["$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"bSplmqtXVhO2Z3hJ8JMQ/u7G2Wmg6yt7SwhYXObRQJfthekddJN152ME4YJIwy7YD8WFq7EkyB/NMyQoliYyCg"}},"state_key":"","type":"m.room.name","unsigned":{"event_id":"$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI","replaces_state":"$fFDwIavLTEIfcnggWuryB6JwfS-L2KT6vP1ap3P6ctE"}} -{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":9001},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":15,"hashes":{"sha256":"FnGzbcXc8YOiB1TY33QunGA17Axoyuu3sdVOj5Z408o"},"origin_server_ts":1756071804931,"prev_events":["$p4xvOczrhzQMtRW3-Tf86LYUb5aqpGFIgjwHBuxWIcI"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"uyTUsPR+CzCtlevzB5+sNXvmfbPSp6u7RZC4E4TLVsj45+pjmMRswAvuHP9PT2+Tkl6Hu8ZPigsXgbKZtR35Aw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw","replaces_state":"$Qg1xRB8nL8lGykGvt9_agu_WCWq8Y3rl_p_LKa6D2Hg"}} -{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":16,"hashes":{"sha256":"KcivsiLesdnUnKX23Akk3OJEJFGRSY0g4H+p7XIThnw"},"origin_server_ts":1756071812688,"prev_events":["$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"cAK8dO2AVZklY9te5aVKbF1jR/eB5rzeNOXfYPjBLf+aSAS4Z6R2aMKW6hJB9PqRS4S+UZc24DTrjUjnvMzeBA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU","replaces_state":"$uZ4OOtkM8RcbEkhjNp-YlEH0zBqgsRx1eI8b2YP7ovw"}} -{"auth_events":["$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"body":"meow #2","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":17,"hashes":{"sha256":"SgH9fOXGdbdqpRfYmoz1t29+gX8Ze4ThSoj6klZs3Og"},"origin_server_ts":1756247476706,"prev_events":["$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"SMYK7zP3SaQOKhzZUKUBVCKwffYqi3PFAlPM34kRJtmfGU3KZXNBT0zi+veXDMmxkMunqhF2RTHBD6joa0kBAQ"}},"type":"m.room.message","unsigned":{"event_id":"$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"}} -{"auth_events":["$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:beeper.com":8999,"@tulir:envs.net":9001,"@tulir:matrix.org":9000},"users_default":0},"depth":18,"hashes":{"sha256":"l8Mw3VKn/Bvntg7bZ8uh5J8M2IBZM93Xg7hsdaSci8s"},"origin_server_ts":1758918656341,"prev_events":["$KFHLO0-ENYOGQXogp84C-ISSu1xtKUzIMaZ6LiBcR_w"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"matrix.org":{"ed25519:a_RXGa":"cg5LP0WuTnVB5jFhNERLLU5b+EhmyACiOq6cp3gKJnZsTAb1yajcgJybLWKrc8QQqxPa7hPnskRBgt4OBTFNAA"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0","replaces_state":"$iwqRXQc2cx8K4AclTjU1Se-BMJpUl4DxrLm3nfUgeQU"}} -{"auth_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$_gYjNODWJdo5-S1IN0bmAk3rzIeXzr5W5cmXZSmUsNw","$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"content":{"avatar_url":"mxc://beeper.com/eBdwbHbllONoAySQkXLjbfFM","displayname":"tulir[b]","membership":"invite"},"depth":19,"hashes":{"sha256":"KpmaRUQnJju8TIDMPzakitUIKOWJxTvULpFB3a1CGgc"},"origin_server_ts":1758918665952,"prev_events":["$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:matrix.org","signatures":{"beeper.com":{"ed25519:a_zgvp":"mzI9rPkQ1xHl2/G5Yrn0qmIRt5OyjPNqRwilPfH4jmr1tP+vv3vC0m4mph/MCOq8S1c/DQaCWSpdOX1uWfchBQ"},"matrix.org":{"ed25519:a_RXGa":"kEdfr8DjxC/bdvGYxnniFI/pxDWeyG73OjG/Gu1uoHLhjdtAT/vEQ6lotJJs214/KX5eAaQWobE9qtMvtPwMDw"}},"state_key":"@tulir:beeper.com","type":"m.room.member","unsigned":{"event_id":"$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro","invite_room_state":[{"auth_events":[],"content":{"room_version":"12"},"depth":1,"hashes":{"sha256":"qJYytb+EqWPiiZ0ogDODcLeA8XYw/2hVTaLHihcVBZQ"},"origin_server_ts":1756071567186,"prev_events":[],"sender":"@tulir:maunium.net","signatures":{"maunium.net":{"ed25519:a_xxeS":"/9pp+2tkLo6XcZ3opqLeIpa3D96fh3QLpR2PQrZ6Z6j7wyRAvBrcgCpAeMtuyDCzW8Wh1QFEPG4FSsGvVaEFBg"}},"state_key":"","type":"m.room.create","unsigned":{"age":11553}},{"content":{"avatar_url":"mxc://matrix.org/BDYVQFSLvZHMaKHDGiRkvhVg","displayname":"tulir[m]","membership":"join"},"sender":"@tulir:matrix.org","state_key":"@tulir:matrix.org","type":"m.room.member"},{"content":{"name":"event auth test v12!"},"sender":"@tulir:matrix.org","state_key":"","type":"m.room.name"},{"content":{"join_rule":"invite"},"sender":"@tulir:maunium.net","state_key":"","type":"m.room.join_rules"}]}} -{"auth_events":["$deNVGs6Ef7OKVrvewhtPv7DCCqSip112cEJYp-jkP6M","$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro","$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"],"content":{"avatar_url":"mxc://beeper.com/eBdwbHbllONoAySQkXLjbfFM","displayname":"tulir[b]","membership":"join"},"depth":20,"hashes":{"sha256":"bmaHSm4mYPNBNlUfFsauSTxLrUH4CUSAKYvr1v76qkk"},"origin_server_ts":1758918670276,"prev_events":["$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:beeper.com","signatures":{"beeper.com":{"ed25519:a_zgvp":"D3cz3m15m89a3G4c5yWOBCjhtSeI5IxBfQKt5XOr9a44QHyc3nwjjvIJaRrKNcS5tLUJwZ2IpVzjlrpbPHpxDA"}},"state_key":"@tulir:beeper.com","type":"m.room.member","unsigned":{"age":6,"event_id":"$_hayW1Y0HRWp3VEGZZbsMf0Ncg9x6n0ikveD0lbCwMw","replaces_state":"$PZJZoUwNySl0jY16DkHBHR0HyAppLdxc0rkSuYp5Mro"}} -{"auth_events":["$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0","$Bz2lxsbUYkeBDE7eMAsOm_TK_iuSuHNvQdrHnc-T1PE"],"content":{"ban":50,"events":{"m.room.avatar":50,"m.room.canonical_alias":50,"m.room.encryption":100,"m.room.history_visibility":100,"m.room.name":50,"m.room.power_levels":100,"m.room.server_acl":100,"m.room.tombstone":100},"events_default":0,"historical":12345,"invite":0,"kick":50,"redact":50,"state_default":50,"users":{"@tulir:beeper.com":9000,"@tulir:envs.net":9001,"@tulir:matrix.org":8999},"users_default":0},"depth":21,"hashes":{"sha256":"xCj9vszChHiXba9DaPzhtF79Tphek3pRViMp36DOurU"},"origin_server_ts":1758918689485,"prev_events":["$_hayW1Y0HRWp3VEGZZbsMf0Ncg9x6n0ikveD0lbCwMw"],"room_id":"!lVEL38waGAf4ggmWC3OVk_bbx8kZx-iOcTBKXTBnM54","sender":"@tulir:envs.net","signatures":{"envs.net":{"ed25519:wuJyKT":"odkrWD30+ObeYtagULtECB/QmGae7qNy66nmJMWYXiQMYUJw/GMzSmgAiLAWfVYlfD3aEvMb/CBdrhL07tfSBw"}},"state_key":"","type":"m.room.power_levels","unsigned":{"event_id":"$di6cI89-GxX8-Wbx-0T69l4wg6TUWITRkjWXzG7EBqo","replaces_state":"$x-CCUewbWOHQXqfcUsywOmbHvNnOSwNM1RyOu-c8SB0"}} diff --git a/mautrix-patched/federation/httpclient.go b/mautrix-patched/federation/httpclient.go deleted file mode 100644 index 8375aab9..00000000 --- a/mautrix-patched/federation/httpclient.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "sync" - "syscall" - - "go.mau.fi/util/exhttp" -) - -// ServerResolvingTransport is an http.RoundTripper that resolves Matrix server names before sending requests. -// It only allows requests using the "matrix-federation" scheme. -type ServerResolvingTransport struct { - ResolveOpts *ResolveServerNameOpts - Transport *http.Transport - DialFunc exhttp.DialerFunc - - cache ResolutionCache - - resolveLocks map[string]*sync.Mutex - resolveLocksLock sync.Mutex -} - -func NewServerResolvingTransport(cache ResolutionCache, dialer exhttp.DialerFunc, settings exhttp.ClientSettings) *ServerResolvingTransport { - if cache == nil { - cache = NewInMemoryCache() - } - srt := &ServerResolvingTransport{ - resolveLocks: make(map[string]*sync.Mutex), - cache: cache, - DialFunc: dialer, - } - srt.Transport = settings.WithDial(srt.DialContext).Configure(&http.Transport{}) - return srt -} - -var _ http.RoundTripper = (*ServerResolvingTransport)(nil) - -func (srt *ServerResolvingTransport) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { - addrs, ok := ctx.Value(contextKeyIPPort).([]string) - if !ok { - return nil, fmt.Errorf("no IP:port in context") - } - return srt.DialFunc(ctx, network, addrs[0]) -} - -func (srt *ServerResolvingTransport) RoundTrip(request *http.Request) (*http.Response, error) { - if request.URL.Scheme != "matrix-federation" { - return nil, fmt.Errorf("unsupported scheme: %s", request.URL.Scheme) - } - resolved, err := srt.resolve(request.Context(), request.URL.Host) - if err != nil { - return nil, fmt.Errorf("failed to resolve server name: %w", err) - } - request = request.WithContext(context.WithValue(request.Context(), contextKeyIPPort, resolved.IPPort)) - request.URL.Scheme = "https" - request.URL.Host = resolved.HostHeader - request.Host = resolved.HostHeader - return srt.Transport.RoundTrip(request) -} - -func (srt *ServerResolvingTransport) resolve(ctx context.Context, serverName string) (*ResolvedServerName, error) { - srt.resolveLocksLock.Lock() - lock, ok := srt.resolveLocks[serverName] - if !ok { - lock = &sync.Mutex{} - srt.resolveLocks[serverName] = lock - } - srt.resolveLocksLock.Unlock() - - lock.Lock() - defer lock.Unlock() - res, err := srt.cache.LoadResolution(serverName) - if err != nil { - return nil, fmt.Errorf("failed to read cache: %w", err) - } else if res != nil { - return res, nil - } else if res, err = ResolveServerName(ctx, serverName, srt.ResolveOpts); err != nil { - return nil, err - } else { - srt.cache.StoreResolution(res) - return res, nil - } -} - -var ErrIPFiltered = errors.New("refusing to connect") - -func (c *Client) controlConn(_ context.Context, network, address string, _ syscall.RawConn) error { - switch network { - case "tcp4", "tcp6": - // ok - default: - return fmt.Errorf("unsupported network: %s", network) - } - host, _, err := net.SplitHostPort(address) - if err != nil { - return fmt.Errorf("invalid address %q: %w", address, err) - } - ip := net.ParseIP(host) - if ip == nil { - return fmt.Errorf("invalid IP address %q", host) - } else if c.AllowIP != nil && !c.AllowIP(ip) { - return fmt.Errorf("%w to %s", ErrIPFiltered, host) - } - return nil -} diff --git a/mautrix-patched/federation/keyserver.go b/mautrix-patched/federation/keyserver.go deleted file mode 100644 index d32ba5cf..00000000 --- a/mautrix-patched/federation/keyserver.go +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "encoding/json" - "net/http" - "strconv" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exhttp" - "go.mau.fi/util/jsontime" - "go.mau.fi/util/ptr" - "go.mau.fi/util/requestlog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" -) - -type ServerVersion struct { - Name string `json:"name"` - Version string `json:"version"` -} - -// ServerKeyProvider is an interface that returns private server keys for server key requests. -type ServerKeyProvider interface { - Get(r *http.Request) (serverName string, key *SigningKey) -} - -// StaticServerKey is an implementation of [ServerKeyProvider] that always returns the same server name and key. -type StaticServerKey struct { - ServerName string - Key *SigningKey -} - -func (ssk *StaticServerKey) Get(r *http.Request) (serverName string, key *SigningKey) { - return ssk.ServerName, ssk.Key -} - -// KeyServer implements a basic Matrix key server that can serve its own keys, plus the federation version endpoint. -// -// It does not implement querying keys of other servers, nor any other federation endpoints. -type KeyServer struct { - KeyProvider ServerKeyProvider - Version ServerVersion - WellKnownTarget string - OtherKeys KeyCache -} - -// Register registers the key server endpoints to the given router. -func (ks *KeyServer) Register(r *http.ServeMux, log zerolog.Logger) { - r.HandleFunc("GET /.well-known/matrix/server", ks.GetWellKnown) - r.HandleFunc("GET /_matrix/federation/v1/version", ks.GetServerVersion) - keyRouter := http.NewServeMux() - keyRouter.HandleFunc("GET /v2/server", ks.GetServerKey) - keyRouter.HandleFunc("GET /v2/query/{serverName}", ks.GetQueryKeys) - keyRouter.HandleFunc("POST /v2/query", ks.PostQueryKeys) - errorBodies := exhttp.ErrorBodies{ - NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), - MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), - } - r.Handle("/_matrix/key/", exhttp.ApplyMiddleware( - keyRouter, - exhttp.StripPrefix("/_matrix/key"), - hlog.NewHandler(log), - hlog.RequestIDHandler("request_id", "Request-Id"), - requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), - exhttp.HandleErrors(errorBodies), - )) -} - -// RespWellKnown is the response body for the `GET /.well-known/matrix/server` endpoint. -type RespWellKnown struct { - Server string `json:"m.server"` -} - -// GetWellKnown implements the `GET /.well-known/matrix/server` endpoint -// -// https://spec.matrix.org/v1.9/server-server-api/#get_well-knownmatrixserver -func (ks *KeyServer) GetWellKnown(w http.ResponseWriter, r *http.Request) { - if ks.WellKnownTarget == "" { - mautrix.MNotFound.WithMessage("No well-known target set").Write(w) - } else { - exhttp.WriteJSONResponse(w, http.StatusOK, &RespWellKnown{Server: ks.WellKnownTarget}) - } -} - -// RespServerVersion is the response body for the `GET /_matrix/federation/v1/version` endpoint -type RespServerVersion struct { - Server ServerVersion `json:"server"` -} - -// GetServerVersion implements the `GET /_matrix/federation/v1/version` endpoint -// -// https://spec.matrix.org/v1.9/server-server-api/#get_matrixfederationv1version -func (ks *KeyServer) GetServerVersion(w http.ResponseWriter, r *http.Request) { - exhttp.WriteJSONResponse(w, http.StatusOK, &RespServerVersion{Server: ks.Version}) -} - -// GetServerKey implements the `GET /_matrix/key/v2/server` endpoint. -// -// https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2server -func (ks *KeyServer) GetServerKey(w http.ResponseWriter, r *http.Request) { - domain, key := ks.KeyProvider.Get(r) - if key == nil { - mautrix.MNotFound.WithMessage("No signing key found for %q", r.Host).Write(w) - } else { - exhttp.WriteJSONResponse(w, http.StatusOK, key.GenerateKeyResponse(domain, nil)) - } -} - -// ReqQueryKeys is the request body for the `POST /_matrix/key/v2/query` endpoint -type ReqQueryKeys struct { - ServerKeys map[string]map[id.KeyID]QueryKeysCriteria `json:"server_keys"` -} - -type QueryKeysCriteria struct { - MinimumValidUntilTS jsontime.UnixMilli `json:"minimum_valid_until_ts"` -} - -// PostQueryKeysResponse is the response body for the `POST /_matrix/key/v2/query` endpoint -type PostQueryKeysResponse struct { - ServerKeys map[string]*ServerKeyResponse `json:"server_keys"` -} - -// PostQueryKeys implements the `POST /_matrix/key/v2/query` endpoint -// -// https://spec.matrix.org/v1.9/server-server-api/#post_matrixkeyv2query -func (ks *KeyServer) PostQueryKeys(w http.ResponseWriter, r *http.Request) { - var req ReqQueryKeys - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - mautrix.MBadJSON.WithMessage("failed to parse request: %v", err).Write(w) - return - } - - resp := &PostQueryKeysResponse{ - ServerKeys: make(map[string]*ServerKeyResponse), - } - for serverName, keys := range req.ServerKeys { - domain, key := ks.KeyProvider.Get(r) - if domain != serverName { - continue - } - for keyID, criteria := range keys { - if key.ID == keyID && criteria.MinimumValidUntilTS.Before(time.Now().Add(24*time.Hour)) { - resp.ServerKeys[serverName] = key.GenerateKeyResponse(serverName, nil) - } - } - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} - -// GetQueryKeysResponse is the response body for the `GET /_matrix/key/v2/query/{serverName}` endpoint -type GetQueryKeysResponse struct { - ServerKeys []*ServerKeyResponse `json:"server_keys"` -} - -// GetQueryKeys implements the `GET /_matrix/key/v2/query/{serverName}` endpoint -// -// https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2queryservername -func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { - serverName := r.PathValue("serverName") - minimumValidUntilTSString := r.URL.Query().Get("minimum_valid_until_ts") - minimumValidUntilTS, err := strconv.ParseInt(minimumValidUntilTSString, 10, 64) - if err != nil && minimumValidUntilTSString != "" { - mautrix.MInvalidParam.WithMessage("failed to parse ?minimum_valid_until_ts: %v", err).Write(w) - return - } else if time.UnixMilli(minimumValidUntilTS).After(time.Now().Add(24 * time.Hour)) { - mautrix.MInvalidParam.WithMessage("minimum_valid_until_ts may not be more than 24 hours in the future").Write(w) - return - } - resp := &GetQueryKeysResponse{ - ServerKeys: []*ServerKeyResponse{}, - } - domain, key := ks.KeyProvider.Get(r) - if domain == serverName { - if key != nil { - resp.ServerKeys = append(resp.ServerKeys, key.GenerateKeyResponse(serverName, nil)) - } - } else if ks.OtherKeys != nil { - otherKey, err := ks.OtherKeys.LoadKeys(serverName) - if err != nil { - mautrix.MUnknown.WithMessage("Failed to load keys from cache").Write(w) - return - } - if key != nil && domain != "" { - signature, err := key.SignJSON(otherKey) - if err == nil { - otherKey.Signatures[domain] = map[id.KeyID]string{ - key.ID: signature, - } - } - } - resp.ServerKeys = append(resp.ServerKeys, otherKey) - } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) -} diff --git a/mautrix-patched/federation/media.go b/mautrix-patched/federation/media.go deleted file mode 100644 index 0f24dbb8..00000000 --- a/mautrix-patched/federation/media.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2026 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "cmp" - "context" - "encoding/json" - "fmt" - "io" - "mime" - "mime/multipart" - "net/http" - "strings" - - "maunium.net/go/mautrix" -) - -type FileMetadata struct { - // Header contains the multipart or HTTP response headers for the file. - // This is not parsed from the JSON metadata. - Header http.Header `json:"-"` - // JSON-parsed fields will be added once some are defined in the spec. -} - -type mediaPartReader struct { - respBody io.ReadCloser - part *multipart.Part -} - -func (mpr *mediaPartReader) Read(p []byte) (n int, err error) { - return mpr.part.Read(p) -} - -func (mpr *mediaPartReader) Close() error { - err1 := mpr.part.Close() - err2 := mpr.respBody.Close() - return cmp.Or(err1, err2) -} - -func (c *Client) DownloadMedia(ctx context.Context, serverName, mediaID string) (meta *FileMetadata, data io.ReadCloser, err error) { - _, resp, err := c.MakeFullRequest(ctx, RequestParams{ - ServerName: serverName, - Method: http.MethodGet, - Path: URLPath{"v1", "media", "download", mediaID}, - Authenticate: true, - DontReadBody: true, - }) - if err != nil { - return nil, nil, nil - } - defer func() { - if data == nil { - _ = resp.Body.Close() - } - }() - mimeType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) - if err != nil { - return nil, nil, fmt.Errorf("failed to parse content type: %w", err) - } else if mimeType != "multipart/mixed" { - return nil, nil, fmt.Errorf("unexpected content type: %s", mimeType) - } else if params["boundary"] == "" { - return nil, nil, fmt.Errorf("missing boundary parameter in content type") - } - mr := multipart.NewReader(resp.Body, params["boundary"]) - part, err := mr.NextPart() - if err != nil { - return nil, nil, fmt.Errorf("failed to read metadata chunk: %w", err) - } else if !strings.HasPrefix(part.Header.Get("Content-Type"), "application/json") { - _ = part.Close() - return nil, nil, fmt.Errorf("unexpected content type for metadata chunk: %s", part.Header.Get("Content-Type")) - } - mbr := http.MaxBytesReader(nil, part, 64*1024) - err = json.NewDecoder(mbr).Decode(&meta) - _ = mbr.Close() - if err != nil { - return nil, nil, fmt.Errorf("failed to parse metadata: %w", err) - } - part, err = mr.NextPart() - if err != nil { - return nil, nil, fmt.Errorf("failed to read data chunk: %w", err) - } - redir := part.Header.Get("Location") - if redir != "" { - _ = resp.Body.Close() - _ = part.Close() - data, meta.Header, err = c.downloadMediaRedirect(ctx, redir) - return - } - meta.Header = http.Header(part.Header) - return meta, &mediaPartReader{ - respBody: resp.Body, - part: part, - }, nil -} - -func (c *Client) downloadMediaRedirect(ctx context.Context, url string) (io.ReadCloser, http.Header, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, nil, fmt.Errorf("failed to prepare redirect request: %w", err) - } else if req.URL.Scheme != "https" { - return nil, nil, fmt.Errorf("non-https URL in redirect") - } - resp, err := c.ExtHTTP.Do(req) - if err != nil { - return nil, nil, mautrix.HTTPError{ - Request: req, - Response: resp, - - Message: "media redirect request error", - WrappedError: err, - } - } else if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - return nil, nil, fmt.Errorf("unexpected status code from redirect: %w", mautrix.HTTPError{ - Request: req, - Response: resp, - }) - } - return resp.Body, resp.Header, nil -} diff --git a/mautrix-patched/federation/pdu/auth.go b/mautrix-patched/federation/pdu/auth.go deleted file mode 100644 index 217dfadf..00000000 --- a/mautrix-patched/federation/pdu/auth.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu - -import ( - "slices" - - "github.com/tidwall/gjson" - "go.mau.fi/util/exgjson" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type StateKey struct { - Type string - StateKey string -} - -var thirdPartyInviteTokenPath = exgjson.Path("third_party_invite", "signed", "token") - -type AuthEventSelection []StateKey - -func (aes *AuthEventSelection) Add(evtType, stateKey string) { - key := StateKey{Type: evtType, StateKey: stateKey} - if !aes.Has(key) { - *aes = append(*aes, key) - } -} - -func (aes *AuthEventSelection) Has(key StateKey) bool { - return slices.Contains(*aes, key) -} - -func (pdu *PDU) AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSelection) { - if pdu.Type == event.StateCreate.Type && pdu.StateKey != nil { - return AuthEventSelection{} - } - keys = make(AuthEventSelection, 0, 3) - if !roomVersion.RoomIDIsCreateEventID() { - keys.Add(event.StateCreate.Type, "") - } - keys.Add(event.StatePowerLevels.Type, "") - keys.Add(event.StateMember.Type, pdu.Sender.String()) - if pdu.Type == event.StateMember.Type && pdu.StateKey != nil { - keys.Add(event.StateMember.Type, *pdu.StateKey) - membership := event.Membership(gjson.GetBytes(pdu.Content, "membership").Str) - if membership == event.MembershipJoin || membership == event.MembershipInvite || membership == event.MembershipKnock { - keys.Add(event.StateJoinRules.Type, "") - } - if membership == event.MembershipInvite { - thirdPartyInviteToken := gjson.GetBytes(pdu.Content, thirdPartyInviteTokenPath).Str - if thirdPartyInviteToken != "" { - keys.Add(event.StateThirdPartyInvite.Type, thirdPartyInviteToken) - } - } - if membership == event.MembershipJoin && roomVersion.RestrictedJoins() { - authorizedVia := gjson.GetBytes(pdu.Content, "join_authorised_via_users_server").Str - if authorizedVia != "" { - keys.Add(event.StateMember.Type, authorizedVia) - } - } - } - return -} diff --git a/mautrix-patched/federation/pdu/hash.go b/mautrix-patched/federation/pdu/hash.go deleted file mode 100644 index 67183825..00000000 --- a/mautrix-patched/federation/pdu/hash.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "fmt" - - "github.com/tidwall/gjson" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/id" -) - -func (pdu *PDU) CalculateContentHash() ([32]byte, error) { - if pdu == nil { - return [32]byte{}, ErrPDUIsNil - } - pduClone := pdu.Clone() - pduClone.Signatures = nil - pduClone.Unsigned = nil - pduClone.Hashes = nil - rawJSON, err := canonicaljson.Marshal(pduClone) - if err != nil { - return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) - } - return sha256.Sum256(rawJSON), nil -} - -func (pdu *PDU) FillContentHash() error { - if pdu == nil { - return ErrPDUIsNil - } else if pdu.Hashes != nil { - return nil - } else if hash, err := pdu.CalculateContentHash(); err != nil { - return err - } else { - pdu.Hashes = &Hashes{SHA256: hash[:]} - return nil - } -} - -func (pdu *PDU) VerifyContentHash() bool { - if pdu == nil || pdu.Hashes == nil { - return false - } - calculatedHash, err := pdu.CalculateContentHash() - if err != nil { - return false - } - return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) -} - -func (pdu *PDU) GetRoomID() (id.RoomID, error) { - if pdu == nil { - return "", ErrPDUIsNil - } else if pdu.Type != "m.room.create" { - return "", fmt.Errorf("room ID can only be calculated for m.room.create events") - } else if roomVersion := id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str); !roomVersion.RoomIDIsCreateEventID() { - return "", fmt.Errorf("room version %s does not use m.room.create event ID as room ID", roomVersion) - } else if evtID, err := pdu.calculateEventID(roomVersion, '!'); err != nil { - return "", fmt.Errorf("failed to calculate event ID: %w", err) - } else { - return id.RoomID(evtID), nil - } -} - -var UseInternalMetaForGetEventID = false - -func (pdu *PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { - if UseInternalMetaForGetEventID && pdu.InternalMeta.EventID != "" { - return pdu.InternalMeta.EventID, nil - } - return pdu.calculateEventID(roomVersion, '$') -} - -func (pdu *PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) { - if pdu == nil { - return [32]byte{}, ErrPDUIsNil - } - if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { - if err := pdu.FillContentHash(); err != nil { - return [32]byte{}, err - } - } - rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) - } - return sha256.Sum256(rawJSON), nil -} - -func (pdu *PDU) calculateEventID(roomVersion id.RoomVersion, prefix byte) (id.EventID, error) { - referenceHash, err := pdu.GetReferenceHash(roomVersion) - if err != nil { - return "", err - } - eventID := make([]byte, 44) - eventID[0] = prefix - switch roomVersion.EventIDFormat() { - case id.EventIDFormatCustom: - return "", fmt.Errorf("*pdu.PDU can only be used for room v3+") - case id.EventIDFormatBase64: - base64.RawStdEncoding.Encode(eventID[1:], referenceHash[:]) - case id.EventIDFormatURLSafeBase64: - base64.RawURLEncoding.Encode(eventID[1:], referenceHash[:]) - default: - return "", fmt.Errorf("unknown event ID format %v", roomVersion.EventIDFormat()) - } - return id.EventID(eventID), nil -} diff --git a/mautrix-patched/federation/pdu/hash_test.go b/mautrix-patched/federation/pdu/hash_test.go deleted file mode 100644 index 17417e12..00000000 --- a/mautrix-patched/federation/pdu/hash_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu_test - -import ( - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - "go.mau.fi/util/exerrors" -) - -func TestPDU_CalculateContentHash(t *testing.T) { - for _, test := range testPDUs { - if test.redacted { - continue - } - t.Run(test.name, func(t *testing.T) { - parsed := parsePDU(test.pdu) - contentHash := exerrors.Must(parsed.CalculateContentHash()) - assert.Equal( - t, - base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), - base64.RawStdEncoding.EncodeToString(contentHash[:]), - ) - }) - } -} - -func TestPDU_VerifyContentHash(t *testing.T) { - for _, test := range testPDUs { - if test.redacted { - continue - } - t.Run(test.name, func(t *testing.T) { - parsed := parsePDU(test.pdu) - assert.True(t, parsed.VerifyContentHash()) - }) - } -} - -func TestPDU_GetEventID(t *testing.T) { - for _, test := range testPDUs { - t.Run(test.name, func(t *testing.T) { - gotEventID := exerrors.Must(parsePDU(test.pdu).GetEventID(test.roomVersion)) - assert.Equal(t, test.eventID, gotEventID) - }) - } -} diff --git a/mautrix-patched/federation/pdu/pdu.go b/mautrix-patched/federation/pdu/pdu.go deleted file mode 100644 index c42b929a..00000000 --- a/mautrix-patched/federation/pdu/pdu.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu - -import ( - "crypto/ed25519" - "encoding/json/jsontext" - "encoding/json/v2" - "errors" - "fmt" - "strings" - "time" - - "github.com/tidwall/gjson" - "go.mau.fi/util/jsonbytes" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// GetKeyFunc is a callback for retrieving the key corresponding to a given key ID when verifying the signature of a PDU. -// -// The input time is the timestamp of the event. The function should attempt to fetch a key that is -// valid at or after this time, but if that is not possible, the latest available key should be -// returned without an error. The verify function will do its own validity checking based on the -// returned valid until timestamp. -type GetKeyFunc = func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) - -type AnyPDU interface { - GetRoomID() (id.RoomID, error) - GetEventID(roomVersion id.RoomVersion) (id.EventID, error) - GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) - CalculateContentHash() ([32]byte, error) - FillContentHash() error - VerifyContentHash() bool - Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error - VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error - ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) - AuthEventSelection(roomVersion id.RoomVersion) (keys AuthEventSelection) -} - -var ( - _ AnyPDU = (*PDU)(nil) - _ AnyPDU = (*RoomV1PDU)(nil) -) - -type InternalMeta struct { - EventID id.EventID `json:"event_id,omitempty"` - Rejected bool `json:"rejected,omitempty"` - Extra map[string]any `json:",unknown"` -} - -type PDU struct { - AuthEvents []id.EventID `json:"auth_events"` - Content jsontext.Value `json:"content"` - Depth int64 `json:"depth"` - Hashes *Hashes `json:"hashes,omitzero"` - OriginServerTS int64 `json:"origin_server_ts"` - PrevEvents []id.EventID `json:"prev_events"` - Redacts *id.EventID `json:"redacts,omitzero"` - RoomID id.RoomID `json:"room_id,omitzero"` // not present for room v12+ create events - Sender id.UserID `json:"sender"` - Signatures map[string]map[id.KeyID]string `json:"signatures,omitzero"` - StateKey *string `json:"state_key,omitzero"` - Type string `json:"type"` - Unsigned jsontext.Value `json:"unsigned,omitzero"` - InternalMeta InternalMeta `json:"-"` - - Unknown jsontext.Value `json:",unknown"` - - // Deprecated legacy fields - DeprecatedPrevState jsontext.Value `json:"prev_state,omitzero"` - DeprecatedOrigin jsontext.Value `json:"origin,omitzero"` - DeprecatedMembership jsontext.Value `json:"membership,omitzero"` -} - -var ErrPDUIsNil = errors.New("PDU is nil") - -type Hashes struct { - SHA256 jsonbytes.UnpaddedBytes `json:"sha256"` - - Unknown jsontext.Value `json:",unknown"` -} - -func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { - if pdu.Type == "m.room.create" && roomVersion == "" { - roomVersion = id.RoomVersion(gjson.GetBytes(pdu.Content, "room_version").Str) - } - evtType := event.Type{Type: pdu.Type, Class: event.MessageEventType} - if pdu.StateKey != nil { - evtType.Class = event.StateEventType - } - eventID, err := pdu.GetEventID(roomVersion) - if err != nil { - return nil, err - } - roomID := pdu.RoomID - if pdu.Type == "m.room.create" && roomVersion.RoomIDIsCreateEventID() { - roomID = id.RoomID(strings.Replace(string(eventID), "$", "!", 1)) - } - evt := &event.Event{ - StateKey: pdu.StateKey, - Sender: pdu.Sender, - Type: evtType, - Timestamp: pdu.OriginServerTS, - ID: eventID, - RoomID: roomID, - Redacts: ptr.Val(pdu.Redacts), - } - err = json.Unmarshal(pdu.Content, &evt.Content) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal content: %w", err) - } - if len(pdu.Unsigned) > 0 { - err = json.Unmarshal(pdu.Unsigned, &evt.Unsigned) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal unsigned content: %w", err) - } - } - return evt, nil -} - -func (pdu *PDU) AddSignature(serverName string, keyID id.KeyID, signature string) { - if signature == "" { - return - } - if pdu.Signatures == nil { - pdu.Signatures = make(map[string]map[id.KeyID]string) - } - if _, ok := pdu.Signatures[serverName]; !ok { - pdu.Signatures[serverName] = make(map[id.KeyID]string) - } - pdu.Signatures[serverName][keyID] = signature -} diff --git a/mautrix-patched/federation/pdu/pdu_test.go b/mautrix-patched/federation/pdu/pdu_test.go deleted file mode 100644 index 59d7c3a6..00000000 --- a/mautrix-patched/federation/pdu/pdu_test.go +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu_test - -import ( - "encoding/json/v2" - "time" - - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/federation/pdu" - "maunium.net/go/mautrix/id" -) - -type serverKey struct { - key id.SigningKey - validUntilTS time.Time -} - -type serverDetails struct { - serverName string - keys map[id.KeyID]serverKey -} - -func (sd serverDetails) getKey(serverName string, keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { - if serverName != sd.serverName { - return "", time.Time{}, nil - } - key, ok := sd.keys[keyID] - if ok { - return key.key, key.validUntilTS, nil - } - return "", time.Time{}, nil -} - -var mauniumNet = serverDetails{ - serverName: "maunium.net", - keys: map[id.KeyID]serverKey{ - "ed25519:a_xxeS": { - key: "lVt/CC3tv74OH6xTph2JrUmeRj/j+1q0HVa0Xf4QlCg", - validUntilTS: time.Now(), - }, - }, -} -var envsNet = serverDetails{ - serverName: "envs.net", - keys: map[id.KeyID]serverKey{ - "ed25519:a_zIqy": { - key: "vCUcZpt9hUn0aabfh/9GP/6sZvXcydww8DUstPHdJm0", - validUntilTS: time.UnixMilli(1722360538068), - }, - "ed25519:wuJyKT": { - key: "xbE1QssgomL4wCSlyMYF5/7KxVyM4HPwAbNa+nFFnx0", - validUntilTS: time.Now(), - }, - }, -} -var matrixOrg = serverDetails{ - serverName: "matrix.org", - keys: map[id.KeyID]serverKey{ - "ed25519:auto": { - key: "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw", - validUntilTS: time.UnixMilli(1576767829750), - }, - "ed25519:a_RXGa": { - key: "l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ", - validUntilTS: time.Now(), - }, - }, -} -var continuwuityOrg = serverDetails{ - serverName: "continuwuity.org", - keys: map[id.KeyID]serverKey{ - "ed25519:PwHlNsFu": { - key: "8eNx2s0zWW+heKAmOH5zKv/nCPkEpraDJfGHxDu6hFI", - validUntilTS: time.Now(), - }, - }, -} -var novaAstraltechOrg = serverDetails{ - serverName: "nova.astraltech.org", - keys: map[id.KeyID]serverKey{ - "ed25519:a_afpo": { - key: "O1Y9GWuKo9xkuzuQef6gROxtTgxxAbS3WPNghPYXF3o", - validUntilTS: time.Now(), - }, - }, -} - -type testPDU struct { - name string - pdu string - eventID id.EventID - roomVersion id.RoomVersion - redacted bool - serverDetails -} - -var roomV4MessageTestPDU = testPDU{ - name: "m.room.message in v4 room", - pdu: `{"auth_events":["$OB87jNemaIVDHAfu0-pa_cP7OPFXUXCbFpjYVi8gll4","$RaWbTF9wQfGQgUpe1S13wzICtGTB2PNKRHUNHu9IO1c","$ZmEWOXw6cC4Rd1wTdY5OzeLJVzjhrkxFPwwKE4gguGk"],"content":{"body":"the last one is saying it shouldn't have effects","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":13103,"hashes":{"sha256":"c2wb8qMlvzIPCP1Wd+eYZ4BRgnGYxS97dR1UlJjVMeg"},"origin_server_ts":1752875275263,"prev_events":["$-7_BMI3BXwj3ayoxiJvraJxYWTKwjiQ6sh7CW_Brvj0"],"room_id":"!JiiOHXrIUCtcOJsZCa:matrix.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"99TAqHpBkUEtgCraXsVXogmf/hnijPbgbG9eACtA+mbix3Y6gURI4QGQgcX/NhcE3pJQZ/YDjmbuvCnKvEccAA"}},"unsigned":{"age_ts":1752875275281}}`, - eventID: "$Jo_lmFR-e6lzrimzCA7DevIn2OwhuQYmd9xkcJBoqAA", - roomVersion: id.RoomV4, - serverDetails: mauniumNet, -} - -var roomV12MessageTestPDU = testPDU{ - name: "m.room.message in v12 room", - pdu: `{"auth_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA","$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":122,"hashes":{"sha256":"IQ0zlc+PXeEs6R3JvRkW3xTPV3zlGKSSd3x07KXGjzs"},"origin_server_ts":1755384351627,"prev_events":["$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir_test:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"0GDMddL2k7gF4V1VU8sL3wTfhAIzAu5iVH5jeavZ2VEg3J9/tHLWXAOn2tzkLaMRWl0/XpINT2YlH/rd2U21Ag"}},"unsigned":{"age_ts":1755384351627}}`, - eventID: "$xmP-wZfpannuHG-Akogi6c4YvqxChMtdyYbUMGOrMWc", - roomVersion: id.RoomV12, - serverDetails: mauniumNet, -} - -var testPDUs = []testPDU{roomV4MessageTestPDU, { - name: "m.room.message in v5 room", - pdu: `{"auth_events":["$hp0ImHqYgHTRbLeWKPeTeFmxdb5SdMJN9cfmTrTk7d0","$KAj7X7tnJbR9qYYMWJSw-1g414_KlPptbbkZm7_kUtg","$V-2ShOwZYhA_nxMijaf3lqFgIJgzE2UMeFPtOLnoBYM"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":2248,"hashes":{"sha256":"kV+JuLbWXJ2r6PjHT3wt8bFc/TfI1nTaSN3Lamg/xHs"},"origin_server_ts":1755422945654,"prev_events":["$49lFLem2Nk4dxHk9RDXxTdaq9InIJpmkHpzVnjKcYwg"],"room_id":"!vzBgJsjNzgHSdWsmki:mozilla.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"JIl60uVgfCLBZLPoSiE7wVkJ9U5cNEPVPuv1sCCYUOq5yOW56WD1adgpBUdX2UFpYkCHvkRnyQGxU0+6HBp5BA"}},"unsigned":{"age_ts":1755422945673}}`, - eventID: "$Qn4tHfuAe6PlnKXPZnygAU9wd6RXqMKtt_ZzstHTSgA", - roomVersion: id.RoomV5, - serverDetails: mauniumNet, -}, { - name: "m.room.message in v10 room", - pdu: `{"auth_events":["$--ilpwnsHaEdHrwiMrZNu5xHP6TthWG0FIXMHnlHCcs","$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ","$Z-qMWmiMvm-aIEffcfSO6lN7TyjyTOsIcHIymfzoo20"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":100885,"hashes":{"sha256":"jc9272JPpPIVreJC3UEAm3BNVnLX8sm3U/TZs23wsHo"},"origin_server_ts":1755422792518,"prev_events":["$HDtbzpSys36Hk-F2NsiXfp9slsGXBH0b58qyddj_q5E"],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"sAMLo9jPtNB0Jq67IQm06siEBx82qZa2edu56IDQ4tDylEV4Mq7iFO23gCghqXA7B/MqBsjXotGBxv6AvlJ2Dw"}},"unsigned":{"age_ts":1755422792540}}`, - eventID: "$4ZFr_ypfp4DyZQP4zyxM_cvuOMFkl07doJmwi106YFY", - roomVersion: id.RoomV10, - serverDetails: mauniumNet, -}, { - name: "m.room.message in v11 room", - pdu: `{"auth_events":["$L8Ak6A939llTRIsZrytMlLDXQhI4uLEjx-wb1zSg-Bw","$QJmr7mmGeXGD4Tof0ZYSPW2oRGklseyHTKtZXnF-YNM","$7bkKK_Z-cGQ6Ae4HXWGBwXyZi3YjC6rIcQzGfVyl3Eo"],"content":{"body":"meow","com.beeper.linkpreviews":[],"m.mentions":{},"msgtype":"m.text"},"depth":3212,"hashes":{"sha256":"K549YdTnv62Jn84Y7sS5ZN3+AdmhleZHbenbhUpR2R8"},"origin_server_ts":1754242687127,"prev_events":["$DAhJg4jVsqk5FRatE2hbT1dSA8D2ASy5DbjEHIMSHwY"],"room_id":"!offtopic-2:continuwuity.org","sender":"@tulir:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"SkzZdZ+rH22kzCBBIAErTdB0Vg6vkFmzvwjlOarGul72EnufgtE/tJcd3a8szAdK7f1ZovRyQxDgVm/Ib2u0Aw"}},"unsigned":{"age_ts":1754242687146}}`, - eventID: `$qkWfTL7_l3oRZO2CItW8-Q0yAmi_l_1ua629ZDqponE`, - roomVersion: id.RoomV11, - serverDetails: mauniumNet, -}, roomV12MessageTestPDU, { - name: "m.room.create in v4 room", - pdu: `{"auth_events": [], "prev_events": [], "type": "m.room.create", "room_id": "!jxlRxnrZCsjpjDubDX:matrix.org", "sender": "@neilj:matrix.org", "content": {"room_version": "4", "predecessor": {"room_id": "!DYgXKezaHgMbiPMzjX:matrix.org", "event_id": "$156171636353XwPJT:matrix.org"}, "creator": "@neilj:matrix.org"}, "depth": 1, "prev_state": [], "state_key": "", "origin": "matrix.org", "origin_server_ts": 1561716363993, "hashes": {"sha256": "9tj8GpXjTAJvdNAbnuKLemZZk+Tjv2LAbGodSX6nJAo"}, "signatures": {"matrix.org": {"ed25519:auto": "2+sNt8uJUhzU4GPxnFVYtU2ZRgFdtVLT1vEZGUdJYN40zBpwYEGJy+kyb5matA+8/yLeYD9gu1O98lhleH0aCA"}}, "unsigned": {"age": 104769}}`, - eventID: "$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY", - roomVersion: id.RoomV4, - serverDetails: matrixOrg, -}, { - name: "m.room.create in v10 room", - pdu: `{"auth_events":[],"content":{"creator":"@creme:envs.net","predecessor":{"event_id":"$BxYNisKcyBDhPLiVC06t18qhv7wsT72MzMCqn5vRhfY","room_id":"!tEyFYiMHhwJlDXTxwf:envs.net"},"room_version":"10"},"depth":1,"hashes":{"sha256":"us3TrsIjBWpwbm+k3F9fUVnz9GIuhnb+LcaY47fWwUI"},"origin":"envs.net","origin_server_ts":1664394769527,"prev_events":[],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@creme:envs.net","state_key":"","type":"m.room.create","signatures":{"envs.net":{"ed25519:a_zIqy":"0g3FDaD1e5BekJYW2sR7dgxuKoZshrf8P067c9+jmH6frsWr2Ua86Ax08CFa/n46L8uvV2SGofP8iiVYgXCRBg"}},"unsigned":{"age":2060}}`, - eventID: "$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ", - roomVersion: id.RoomV10, - serverDetails: envsNet, -}, { - name: "m.room.create in v12 room", - pdu: `{"auth_events":[],"content":{"fi.mau.randomness":"AAXZ6aIc","predecessor":{"room_id":"!#test/room\nversion 11, with @\ud83d\udc08\ufe0f:maunium.net"},"room_version":"12"},"depth":1,"hashes":{"sha256":"d3L1M3KUdyIKWcShyW6grUoJ8GOjCdSIEvQrDVHSpE8"},"origin_server_ts":1754940000000,"prev_events":[],"sender":"@tulir:maunium.net","state_key":"","type":"m.room.create","signatures":{"maunium.net":{"ed25519:a_xxeS":"ebjIRpzToc82cjb/RGY+VUzZic0yeRZrjctgx0SUTJxkprXn3/i1KdiYULfl/aD0cUJ5eL8gLakOSk2glm+sBw"}},"unsigned":{"age_ts":1754939139045}}`, - eventID: "$mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", - roomVersion: id.RoomV12, - serverDetails: mauniumNet, -}, { - name: "m.room.member in v4 room", - pdu: `{"auth_events":["$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY","$jg2AgCfnwnjR-osoyM0lVYS21QrtfmZxhGO90PRkmO4","$wMGMP4Ucij2_d4h_fVDgIT2xooLZAgMcBruT9oo3Jio","$yyDgV8w0_e8qslmn0nh9OeSq_fO0zjpjTjSEdKFxDso"],"prev_events":["$zSjNuTXhUe3Rq6NpKD3sNyl8a_asMnBhGC5IbacHlJ4"],"type":"m.room.member","room_id":"!jxlRxnrZCsjpjDubDX:matrix.org","sender":"@tulir:maunium.net","content":{"membership":"join","displayname":"tulir","avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","clicked \"send membership event with no changes\"":true},"depth":14370,"prev_state":[],"state_key":"@tulir:maunium.net","origin":"maunium.net","origin_server_ts":1600871136259,"hashes":{"sha256":"Ga6bG9Mk0887ruzM9TAAfa1O3DbNssb+qSFtE9oeRL4"},"signatures":{"maunium.net":{"ed25519:a_xxeS":"fzOyDG3G3pEzixtWPttkRA1DfnHETiKbiG8SEBQe2qycQbZWPky7xX8WujSrUJH/+bxTABpQwEH49d+RakxtBw"}},"unsigned":{"age_ts":1600871136259,"replaces_state":"$jg2AgCfnwnjR-osoyM0lVYS21QrtfmZxhGO90PRkmO4"}}`, - eventID: "$VtuCNOfAWGow-cxy0ajeK3fvONcC8QzF2yWa43g0Gwo", - roomVersion: id.RoomV4, - serverDetails: mauniumNet, -}, { - name: "m.room.member in v10 room", - pdu: `{"auth_events":["$HQC4hWaioLKVbMH94qKbfb3UnL4ocql2vi-VdUYI48I","$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs","$kEPF8Aj87EzRmFPriu2zdyEY0rY15XSqywTYVLUUlCA","$tn1FZUI_YUpfTr_a3Y_r8kC3inliIZZratzg0UsNdCQ"],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":182,"hashes":{"sha256":"0HscBc921QV2dxK2qY7qrnyoAgfxBM7kKvqAXlEk+GE"},"origin":"maunium.net","origin_server_ts":1665402609039,"prev_events":["$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs"],"room_id":"!UzZHbJYcgggctGnlzr:envs.net","sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member","signatures":{"maunium.net":{"ed25519:a_xxeS":"lkOW0FSJ8MJ0wZpdwLH1Uf6FSl2q9/u6KthRIlM0CwHDJG4sIZ9DrMA8BdU8L/PWoDS/CoDUlLanDh99SplgBw"}},"unsigned":{"age_ts":1665402609039,"replaces_state":"$R9FUDgNAp9ms7b6ASunZOIkpqmsIRq_ROrNEznu62fs"}}`, - eventID: "$--ilpwnsHaEdHrwiMrZNu5xHP6TthWG0FIXMHnlHCcs", - roomVersion: id.RoomV10, - serverDetails: mauniumNet, -}, { - name: "m.room.member of creator in v12 room", - pdu: `{"auth_events":[],"content":{"avatar_url":"mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO","displayname":"tulir","membership":"join"},"depth":2,"hashes":{"sha256":"IebdOBYaaWYIx2zq/lkVCnjWIXTLk1g+vgFpJMgd2/E"},"origin_server_ts":1754939139117,"prev_events":["$mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ"],"room_id":"!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ","sender":"@tulir:maunium.net","state_key":"@tulir:maunium.net","type":"m.room.member","signatures":{"maunium.net":{"ed25519:a_xxeS":"rFCgF2hmavdm6+P6/f7rmuOdoSOmELFaH3JdWjgBLZXS2z51Ma7fa2v2+BkAH1FvBo9FLhvEoFVM4WbNQLXtAA"}},"unsigned":{"age_ts":1754939139117}}`, - eventID: "$accqGxfvhBvMP4Sf6P7t3WgnaJK6UbonO2ZmwqSE5Sg", - roomVersion: id.RoomV12, - serverDetails: mauniumNet, -}, { - name: "custom message event in v4 room", - pdu: `{"auth_events":["$VtuCNOfAWGow-cxy0ajeK3fvONcC8QzF2yWa43g0Gwo","$ay_9_nPilrTpb3UxIwHHBBfFjTJb6hBAE_JzQwSjqeY","$Gau_XwziYsr-rt3SouhbKN14twgmbKjcZZc_hz-nOgU"],"content":{"\ud83d\udc08\ufe0f":true,"\ud83d\udc15\ufe0f":false},"depth":69645,"hashes":{"sha256":"VHtWyCt+15ZesNnStU3FOkxrjzHJYZfd3JUgO9JWe0s"},"origin_server_ts":1755423939146,"prev_events":["$exmp4cj0OKOFSxuqBYiOYwQi5j_0XRc78d6EavAkhy0"],"room_id":"!jxlRxnrZCsjpjDubDX:matrix.org","sender":"@tulir:maunium.net","type":"\ud83d\udc08\ufe0f","signatures":{"maunium.net":{"ed25519:a_xxeS":"wfmP1XN4JBkKVkqrQnwysyEUslXt8hQRFwN9NC9vJaIeDMd0OJ6uqCas75808DuG71p23fzqbzhRnHckst6FCQ"}},"unsigned":{"age_ts":1755423939164}}`, - eventID: "$kAagtZAIEeZaLVCUSl74tAxQbdKbE22GU7FM-iAJBc0", - roomVersion: id.RoomV4, - serverDetails: mauniumNet, -}, { - name: "redacted m.room.member event in v11 room with 2 signatures", - pdu: `{"auth_events":["$9f12-_stoY07BOTmyguE1QlqvghLBh9Rk6PWRLoZn_M","$IP8hyjBkIDREVadyv0fPCGAW9IXGNllaZyxqQwiY_tA","$7dN5J8EveliaPkX6_QSejl4GQtem4oieavgALMeWZyE"],"content":{"membership":"join"},"depth":96978,"hashes":{"sha256":"APYA/aj3u+P0EwNaEofuSIlfqY3cK3lBz6RkwHX+Zak"},"origin_server_ts":1755664164485,"prev_events":["$XBN9W5Ll8VEH3eYqJaemxCBTDdy0hZB0sWpmyoUp93c"],"room_id":"!main-1:continuwuity.org","sender":"@6a19abdd4766:nova.astraltech.org","state_key":"@6a19abdd4766:nova.astraltech.org","type":"m.room.member","signatures":{"continuwuity.org":{"ed25519:PwHlNsFu":"+b/Fp2vWnC+Z2lI3GnCu7ZHdo3iWNDZ2AJqMoU9owMtLBPMxs4dVIsJXvaFq0ryawsgwDwKZ7f4xaFUNARJSDg"},"nova.astraltech.org":{"ed25519:a_afpo":"pXIngyxKukCPR7WOIIy8FTZxQ5L2dLiou5Oc8XS4WyY4YzJuckQzOaToigLLZxamfbN/jXbO+XUizpRpYccDAA"}},"unsigned":{}}`, - eventID: "$r6d9m125YWG28-Tln47bWtm6Jlv4mcSUWJTHijBlXLQ", - roomVersion: id.RoomV11, - serverDetails: novaAstraltechOrg, - redacted: true, -}} - -func parsePDU(pdu string) (out *pdu.PDU) { - exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) - return -} diff --git a/mautrix-patched/federation/pdu/redact.go b/mautrix-patched/federation/pdu/redact.go deleted file mode 100644 index d7ee0c15..00000000 --- a/mautrix-patched/federation/pdu/redact.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu - -import ( - "encoding/json/jsontext" - - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - "go.mau.fi/util/exgjson" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/id" -) - -func filteredObject(object jsontext.Value, allowedPaths ...string) jsontext.Value { - filtered := jsontext.Value("{}") - var err error - for _, path := range allowedPaths { - res := gjson.GetBytes(object, path) - if res.Exists() { - var raw jsontext.Value - if res.Index > 0 { - raw = object[res.Index : res.Index+len(res.Raw)] - } else { - raw = jsontext.Value(res.Raw) - } - filtered, err = sjson.SetRawBytes(filtered, path, raw) - if err != nil { - panic(err) - } - } - } - return filtered -} - -func (pdu *PDU) Clone() *PDU { - return ptr.Clone(pdu) -} - -func (pdu *PDU) RedactForSignature(roomVersion id.RoomVersion) *PDU { - pdu.Signatures = nil - return pdu.Redact(roomVersion) -} - -var emptyObject = jsontext.Value("{}") - -func RedactContent(eventType string, content jsontext.Value, roomVersion id.RoomVersion) jsontext.Value { - switch eventType { - case "m.room.member": - allowedPaths := []string{"membership"} - if roomVersion.RestrictedJoinsFix() { - allowedPaths = append(allowedPaths, "join_authorised_via_users_server") - } - if roomVersion.UpdatedRedactionRules() { - allowedPaths = append(allowedPaths, exgjson.Path("third_party_invite", "signed")) - } - return filteredObject(content, allowedPaths...) - case "m.room.create": - if !roomVersion.UpdatedRedactionRules() { - return filteredObject(content, "creator") - } - return content - case "m.room.join_rules": - if roomVersion.RestrictedJoins() { - return filteredObject(content, "join_rule", "allow") - } - return filteredObject(content, "join_rule") - case "m.room.power_levels": - allowedKeys := []string{"ban", "events", "events_default", "kick", "redact", "state_default", "users", "users_default"} - if roomVersion.UpdatedRedactionRules() { - allowedKeys = append(allowedKeys, "invite") - } - return filteredObject(content, allowedKeys...) - case "m.room.history_visibility": - return filteredObject(content, "history_visibility") - case "m.room.redaction": - if roomVersion.RedactsInContent() { - return filteredObject(content, "redacts") - } - return emptyObject - case "m.room.aliases": - if roomVersion.SpecialCasedAliasesAuth() { - return filteredObject(content, "aliases") - } - return emptyObject - default: - return emptyObject - } -} - -func (pdu *PDU) Redact(roomVersion id.RoomVersion) *PDU { - pdu.Unknown = nil - pdu.Unsigned = nil - if roomVersion.UpdatedRedactionRules() { - pdu.DeprecatedPrevState = nil - pdu.DeprecatedOrigin = nil - pdu.DeprecatedMembership = nil - } - if pdu.Type != "m.room.redaction" || roomVersion.RedactsInContent() { - pdu.Redacts = nil - } - pdu.Content = RedactContent(pdu.Type, pdu.Content, roomVersion) - return pdu -} diff --git a/mautrix-patched/federation/pdu/signature.go b/mautrix-patched/federation/pdu/signature.go deleted file mode 100644 index 109281bc..00000000 --- a/mautrix-patched/federation/pdu/signature.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu - -import ( - "crypto/ed25519" - "encoding/base64" - "errors" - "fmt" - "time" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/federation/signutil" - "maunium.net/go/mautrix/id" -) - -func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { - err := pdu.FillContentHash() - if err != nil { - return err - } - rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) - } - signature := ed25519.Sign(privateKey, rawJSON) - pdu.AddSignature(serverName, keyID, base64.RawStdEncoding.EncodeToString(signature)) - return nil -} - -func (pdu *PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error { - sigs := pdu.Signatures[serverName] - if len(sigs) == 0 { - return fmt.Errorf("no signatures found for server %s", serverName) - } - rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) - } - verified := false - var errorList []error - for keyID, sig := range sigs { - originServerTS := time.UnixMilli(pdu.OriginServerTS) - key, validUntil, err := getKey(serverName, keyID, originServerTS) - if err != nil { - return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) - } else if key == "" { - errorList = append(errorList, fmt.Errorf("key %s not found for %s", keyID, serverName)) - } else if validUntil.Before(originServerTS) && roomVersion.EnforceSigningKeyValidity() { - errorList = append(errorList, fmt.Errorf("key %s for %s is only valid until %s, but event is from %s", keyID, serverName, validUntil, originServerTS)) - } else if err = signutil.VerifyJSONCanonical(key, sig, rawJSON); err != nil { - return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) - } else { - verified = true - } - } - if !verified { - return fmt.Errorf("no verifiable signatures found for server %s: %w", serverName, errors.Join(errorList...)) - } - return nil -} diff --git a/mautrix-patched/federation/pdu/signature_test.go b/mautrix-patched/federation/pdu/signature_test.go deleted file mode 100644 index 01df5076..00000000 --- a/mautrix-patched/federation/pdu/signature_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu_test - -import ( - "crypto/ed25519" - "encoding/base64" - "encoding/json/jsontext" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/federation/pdu" - "maunium.net/go/mautrix/id" -) - -func TestPDU_VerifySignature(t *testing.T) { - for _, test := range testPDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, test.getKey) - assert.NoError(t, err) - }) - } -} - -func TestPDU_VerifySignature_Fail_NoKey(t *testing.T) { - test := roomV12MessageTestPDU - parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { - return - }) - assert.Error(t, err) -} - -func TestPDU_VerifySignature_V4ExpiredKey(t *testing.T) { - test := roomV4MessageTestPDU - parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { - key = test.keys[keyID].key - validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - return - }) - assert.NoError(t, err) -} - -func TestPDU_VerifySignature_V12ExpiredKey(t *testing.T) { - test := roomV12MessageTestPDU - parsed := parsePDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { - key = test.keys[keyID].key - validUntil = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - return - }) - assert.Error(t, err) -} - -func TestPDU_VerifySignature_V12InvalidSignature(t *testing.T) { - test := roomV12MessageTestPDU - parsed := parsePDU(test.pdu) - for _, sigs := range parsed.Signatures { - for key := range sigs { - sigs[key] = sigs[key][:len(sigs[key])-3] + "ABC" - } - } - err := parsed.VerifySignature(test.roomVersion, test.serverName, test.getKey) - assert.Error(t, err) -} - -func TestPDU_Sign(t *testing.T) { - pubKey, privKey := exerrors.Must2(ed25519.GenerateKey(nil)) - evt := &pdu.PDU{ - AuthEvents: []id.EventID{"$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA", "$hyeL_nU_L3tsZ2dtZZpAHk0Skv-PqFQIipuII_By584"}, - Content: jsontext.Value(`{"msgtype":"m.text","body":"Hello, world!"}`), - Depth: 123, - OriginServerTS: 1755384351627, - PrevEvents: []id.EventID{"$gCzdJUVV93Qory0x7p_PLG5UUiDjPJNe1H12qbHTuFA"}, - RoomID: "!mauT12AzsoqxV7Abvy_ApA-HNPK1LcT4GbP70_AOPyQ", - Sender: "@tulir:example.com", - Type: "m.room.message", - } - err := evt.Sign(id.RoomV12, "example.com", "ed25519:rand", privKey) - require.NoError(t, err) - err = evt.VerifySignature(id.RoomV11, "example.com", func(serverName string, keyID id.KeyID, minValidUntil time.Time) (key id.SigningKey, validUntil time.Time, err error) { - if serverName == "example.com" && keyID == "ed25519:rand" { - key = id.SigningKey(base64.RawStdEncoding.EncodeToString(pubKey)) - validUntil = time.Now() - } - return - }) - require.NoError(t, err) - -} diff --git a/mautrix-patched/federation/pdu/v1.go b/mautrix-patched/federation/pdu/v1.go deleted file mode 100644 index 8760d081..00000000 --- a/mautrix-patched/federation/pdu/v1.go +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu - -import ( - "crypto/ed25519" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json/jsontext" - "encoding/json/v2" - "fmt" - "time" - - "github.com/tidwall/gjson" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/federation/signutil" - "maunium.net/go/mautrix/id" -) - -type V1EventReference struct { - ID id.EventID - Hashes Hashes -} - -var ( - _ json.UnmarshalerFrom = (*V1EventReference)(nil) - _ json.MarshalerTo = (*V1EventReference)(nil) -) - -func (er *V1EventReference) MarshalJSONTo(enc *jsontext.Encoder) error { - return json.MarshalEncode(enc, []any{er.ID, er.Hashes}) -} - -func (er *V1EventReference) UnmarshalJSONFrom(dec *jsontext.Decoder) error { - var ref V1EventReference - var data []jsontext.Value - if err := json.UnmarshalDecode(dec, &data); err != nil { - return err - } else if len(data) != 2 { - return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: expected array with 2 elements, got %d", len(data)) - } else if err = json.Unmarshal(data[0], &ref.ID); err != nil { - return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: failed to unmarshal event ID: %w", err) - } else if err = json.Unmarshal(data[1], &ref.Hashes); err != nil { - return fmt.Errorf("V1EventReference.UnmarshalJSONFrom: failed to unmarshal hashes: %w", err) - } - *er = ref - return nil -} - -type RoomV1PDU struct { - AuthEvents []V1EventReference `json:"auth_events"` - Content jsontext.Value `json:"content"` - Depth int64 `json:"depth"` - EventID id.EventID `json:"event_id"` - Hashes *Hashes `json:"hashes,omitzero"` - OriginServerTS int64 `json:"origin_server_ts"` - PrevEvents []V1EventReference `json:"prev_events"` - Redacts *id.EventID `json:"redacts,omitzero"` - RoomID id.RoomID `json:"room_id"` - Sender id.UserID `json:"sender"` - Signatures map[string]map[id.KeyID]string `json:"signatures,omitzero"` - StateKey *string `json:"state_key,omitzero"` - Type string `json:"type"` - Unsigned jsontext.Value `json:"unsigned,omitzero"` - - Unknown jsontext.Value `json:",unknown"` - - // Deprecated legacy fields - DeprecatedPrevState jsontext.Value `json:"prev_state,omitzero"` - DeprecatedOrigin jsontext.Value `json:"origin,omitzero"` - DeprecatedMembership jsontext.Value `json:"membership,omitzero"` -} - -func (pdu *RoomV1PDU) GetRoomID() (id.RoomID, error) { - return pdu.RoomID, nil -} - -func (pdu *RoomV1PDU) GetEventID(roomVersion id.RoomVersion) (id.EventID, error) { - if !pdu.SupportsRoomVersion(roomVersion) { - return "", fmt.Errorf("RoomV1PDU.GetEventID: unsupported room version %s", roomVersion) - } - return pdu.EventID, nil -} - -func (pdu *RoomV1PDU) RedactForSignature(roomVersion id.RoomVersion) *RoomV1PDU { - pdu.Signatures = nil - return pdu.Redact(roomVersion) -} - -func (pdu *RoomV1PDU) Redact(roomVersion id.RoomVersion) *RoomV1PDU { - pdu.Unknown = nil - pdu.Unsigned = nil - if pdu.Type != "m.room.redaction" { - pdu.Redacts = nil - } - pdu.Content = RedactContent(pdu.Type, pdu.Content, roomVersion) - return pdu -} - -func (pdu *RoomV1PDU) GetReferenceHash(roomVersion id.RoomVersion) ([32]byte, error) { - if !pdu.SupportsRoomVersion(roomVersion) { - return [32]byte{}, fmt.Errorf("RoomV1PDU.GetReferenceHash: unsupported room version %s", roomVersion) - } - if pdu == nil { - return [32]byte{}, ErrPDUIsNil - } - if pdu.Hashes == nil || pdu.Hashes.SHA256 == nil { - if err := pdu.FillContentHash(); err != nil { - return [32]byte{}, err - } - } - rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return [32]byte{}, fmt.Errorf("failed to marshal redacted PDU to calculate event ID: %w", err) - } - return sha256.Sum256(rawJSON), nil -} - -func (pdu *RoomV1PDU) CalculateContentHash() ([32]byte, error) { - if pdu == nil { - return [32]byte{}, ErrPDUIsNil - } - pduClone := pdu.Clone() - pduClone.Signatures = nil - pduClone.Unsigned = nil - pduClone.Hashes = nil - rawJSON, err := canonicaljson.Marshal(pduClone) - if err != nil { - return [32]byte{}, fmt.Errorf("failed to marshal PDU to calculate content hash: %w", err) - } - return sha256.Sum256(rawJSON), nil -} - -func (pdu *RoomV1PDU) FillContentHash() error { - if pdu == nil { - return ErrPDUIsNil - } else if pdu.Hashes != nil { - return nil - } else if hash, err := pdu.CalculateContentHash(); err != nil { - return err - } else { - pdu.Hashes = &Hashes{SHA256: hash[:]} - return nil - } -} - -func (pdu *RoomV1PDU) VerifyContentHash() bool { - if pdu == nil || pdu.Hashes == nil { - return false - } - calculatedHash, err := pdu.CalculateContentHash() - if err != nil { - return false - } - return hmac.Equal(calculatedHash[:], pdu.Hashes.SHA256) -} - -func (pdu *RoomV1PDU) Clone() *RoomV1PDU { - return ptr.Clone(pdu) -} - -func (pdu *RoomV1PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.KeyID, privateKey ed25519.PrivateKey) error { - if !pdu.SupportsRoomVersion(roomVersion) { - return fmt.Errorf("RoomV1PDU.Sign: unsupported room version %s", roomVersion) - } - err := pdu.FillContentHash() - if err != nil { - return err - } - rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err) - } - signature := ed25519.Sign(privateKey, rawJSON) - if pdu.Signatures == nil { - pdu.Signatures = make(map[string]map[id.KeyID]string) - } - if _, ok := pdu.Signatures[serverName]; !ok { - pdu.Signatures[serverName] = make(map[id.KeyID]string) - } - pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature) - return nil -} - -func (pdu *RoomV1PDU) VerifySignature(roomVersion id.RoomVersion, serverName string, getKey GetKeyFunc) error { - if !pdu.SupportsRoomVersion(roomVersion) { - return fmt.Errorf("RoomV1PDU.VerifySignature: unsupported room version %s", roomVersion) - } - rawJSON, err := canonicaljson.Marshal(pdu.Clone().RedactForSignature(roomVersion)) - if err != nil { - return fmt.Errorf("failed to marshal redacted PDU to verify signature: %w", err) - } - verified := false - for keyID, sig := range pdu.Signatures[serverName] { - originServerTS := time.UnixMilli(pdu.OriginServerTS) - key, _, err := getKey(serverName, keyID, originServerTS) - if err != nil { - return fmt.Errorf("failed to get key %s for %s: %w", keyID, serverName, err) - } else if key == "" { - return fmt.Errorf("key %s not found for %s", keyID, serverName) - } else if err = signutil.VerifyJSONCanonical(key, sig, rawJSON); err != nil { - return fmt.Errorf("failed to verify signature from key %s: %w", keyID, err) - } else { - verified = true - } - } - if !verified { - return fmt.Errorf("no verifiable signatures found for server %s", serverName) - } - return nil -} - -func (pdu *RoomV1PDU) SupportsRoomVersion(roomVersion id.RoomVersion) bool { - switch roomVersion { - case id.RoomV0, id.RoomV1, id.RoomV2: - return true - default: - return false - } -} - -func (pdu *RoomV1PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error) { - if !pdu.SupportsRoomVersion(roomVersion) { - return nil, fmt.Errorf("RoomV1PDU.ToClientEvent: unsupported room version %s", roomVersion) - } - evtType := event.Type{Type: pdu.Type, Class: event.MessageEventType} - if pdu.StateKey != nil { - evtType.Class = event.StateEventType - } - evt := &event.Event{ - StateKey: pdu.StateKey, - Sender: pdu.Sender, - Type: evtType, - Timestamp: pdu.OriginServerTS, - ID: pdu.EventID, - RoomID: pdu.RoomID, - Redacts: ptr.Val(pdu.Redacts), - } - err := json.Unmarshal(pdu.Content, &evt.Content) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal content: %w", err) - } - return evt, nil -} - -func (pdu *RoomV1PDU) AuthEventSelection(_ id.RoomVersion) (keys AuthEventSelection) { - if pdu.Type == event.StateCreate.Type && pdu.StateKey != nil { - return AuthEventSelection{} - } - keys = make(AuthEventSelection, 0, 3) - keys.Add(event.StateCreate.Type, "") - keys.Add(event.StatePowerLevels.Type, "") - keys.Add(event.StateMember.Type, pdu.Sender.String()) - if pdu.Type == event.StateMember.Type && pdu.StateKey != nil { - keys.Add(event.StateMember.Type, *pdu.StateKey) - membership := event.Membership(gjson.GetBytes(pdu.Content, "membership").Str) - if membership == event.MembershipJoin || membership == event.MembershipInvite || membership == event.MembershipKnock { - keys.Add(event.StateJoinRules.Type, "") - } - if membership == event.MembershipInvite { - thirdPartyInviteToken := gjson.GetBytes(pdu.Content, thirdPartyInviteTokenPath).Str - if thirdPartyInviteToken != "" { - keys.Add(event.StateThirdPartyInvite.Type, thirdPartyInviteToken) - } - } - } - return -} diff --git a/mautrix-patched/federation/pdu/v1_test.go b/mautrix-patched/federation/pdu/v1_test.go deleted file mode 100644 index ecf2dbd2..00000000 --- a/mautrix-patched/federation/pdu/v1_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -//go:build goexperiment.jsonv2 - -package pdu_test - -import ( - "encoding/base64" - "encoding/json/v2" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/federation/pdu" - "maunium.net/go/mautrix/id" -) - -var testV1PDUs = []testPDU{{ - name: "m.room.message in v1 room", - pdu: `{"auth_events":[["$159234730483190eXavq:matrix.org",{"sha256":"VprZrhMqOQyKbfF3UE26JXE8D27ih4R/FGGc8GZ0Whs"}],["$143454825711DhCxH:matrix.org",{"sha256":"3sJh/5GOB094OKuhbjL634Gt69YIcge9GD55ciJa9ok"}],["$156837651426789wiPdh:maunium.net",{"sha256":"FGyR3sxJ/VxYabDkO/5qtwrPR3hLwGknJ0KX0w3GUHE"}]],"content":{"body":"photo-1526336024174-e58f5cdd8e13.jpg","info":{"h":1620,"mimetype":"image/jpeg","size":208053,"w":1080},"msgtype":"m.image","url":"mxc://maunium.net/aEqEghIjFPAerIhCxJCYpQeC"},"depth":16669,"event_id":"$16738169022163bokdi:maunium.net","hashes":{"sha256":"XYB47Gf2vAci3BTguIJaC75ZYGMuVY65jcvoUVgpcLA"},"origin":"maunium.net","origin_server_ts":1673816902100,"prev_events":[["$1673816901121325UMCjA:matrix.org",{"sha256":"t7e0IYHLI3ydIPoIU8a8E/pIWXH9cNLlQBEtGyGtHwc"}]],"room_id":"!jhpZBTbckszblMYjMK:matrix.org","sender":"@cat:maunium.net","type":"m.room.message","signatures":{"maunium.net":{"ed25519:a_xxeS":"uRZbEm+P+Y1ZVgwBn5I6SlaUZdzlH1bB4nv81yt5EIQ0b1fZ8YgM4UWMijrrXp3+NmqRFl0cakSM3MneJOtFCw"}},"unsigned":{"age_ts":1673816902100}}`, - eventID: "$16738169022163bokdi:maunium.net", - roomVersion: id.RoomV1, - serverDetails: mauniumNet, -}, { - name: "m.room.create in v1 room", - pdu: `{"origin": "matrix.org", "signatures": {"matrix.org": {"ed25519:auto": "XTejpXn5REoHrZWgCpJglGX7MfOWS2zUjYwJRLrwW2PQPbFdqtL+JnprBXwIP2C1NmgWSKG+am1QdApu0KoHCQ"}}, "origin_server_ts": 1434548257426, "sender": "@appservice-irc:matrix.org", "event_id": "$143454825711DhCxH:matrix.org", "prev_events": [], "unsigned": {"age": 12872287834}, "state_key": "", "content": {"creator": "@appservice-irc:matrix.org"}, "depth": 1, "prev_state": [], "room_id": "!jhpZBTbckszblMYjMK:matrix.org", "auth_events": [], "hashes": {"sha256": "+SSdmeeoKI/6yK6sY4XAFljWFiugSlCiXQf0QMCZjTs"}, "type": "m.room.create"}`, - eventID: "$143454825711DhCxH:matrix.org", - roomVersion: id.RoomV1, - serverDetails: matrixOrg, -}, { - name: "m.room.member in v1 room", - pdu: `{"auth_events": [["$1536447669931522zlyWe:matrix.org", {"sha256": "UkzPGd7cPAGvC0FVx3Yy2/Q0GZhA2kcgj8MGp5pjYV8"}], ["$143454825711DhCxH:matrix.org", {"sha256": "3sJh/5GOB094OKuhbjL634Gt69YIcge9GD55ciJa9ok"}], ["$143454825714nUEqZ:matrix.org", {"sha256": "NjuZXu8EDMfIfejPcNlC/IdnKQAGpPIcQjHaf0BZaHk"}]], "prev_events": [["$15660585503271JRRMm:maunium.net", {"sha256": "/Sm7uSLkYMHapp6I3NuEVJlk2JucW2HqjsQy9vzhciA"}]], "type": "m.room.member", "room_id": "!jhpZBTbckszblMYjMK:matrix.org", "sender": "@tulir:maunium.net", "content": {"membership": "join", "avatar_url": "mxc://maunium.net/jdlSfvudiMSmcRrleeiYjjFO", "displayname": "tulir"}, "depth": 10485, "prev_state": [], "state_key": "@tulir:maunium.net", "event_id": "$15660585693272iEryv:maunium.net", "origin": "maunium.net", "origin_server_ts": 1566058569201, "hashes": {"sha256": "1D6fdDzKsMGCxSqlXPA7I9wGQNTutVuJke1enGHoWK8"}, "signatures": {"maunium.net": {"ed25519:a_xxeS": "Lj/zDK6ozr4vgsxyL8jY56wTGWoA4jnlvkTs5paCX1w3nNKHnQnSMi+wuaqI6yv5vYh9usGWco2LLMuMzYXcBg"}}, "unsigned": {"age_ts": 1566058569201, "replaces_state": "$15660585383268liyBc:maunium.net"}}`, - eventID: "$15660585693272iEryv:maunium.net", - roomVersion: id.RoomV1, - serverDetails: mauniumNet, -}} - -func parseV1PDU(pdu string) (out *pdu.RoomV1PDU) { - exerrors.PanicIfNotNil(json.Unmarshal([]byte(pdu), &out)) - return -} - -func TestRoomV1PDU_CalculateContentHash(t *testing.T) { - for _, test := range testV1PDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parseV1PDU(test.pdu) - contentHash := exerrors.Must(parsed.CalculateContentHash()) - assert.Equal( - t, - base64.RawStdEncoding.EncodeToString(parsed.Hashes.SHA256), - base64.RawStdEncoding.EncodeToString(contentHash[:]), - ) - }) - } -} - -func TestRoomV1PDU_VerifyContentHash(t *testing.T) { - for _, test := range testV1PDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parseV1PDU(test.pdu) - assert.True(t, parsed.VerifyContentHash()) - }) - } -} - -func TestRoomV1PDU_VerifySignature(t *testing.T) { - for _, test := range testV1PDUs { - t.Run(test.name, func(t *testing.T) { - parsed := parseV1PDU(test.pdu) - err := parsed.VerifySignature(test.roomVersion, test.serverName, func(serverName string, keyID id.KeyID, _ time.Time) (id.SigningKey, time.Time, error) { - key, ok := test.keys[keyID] - if ok { - return key.key, key.validUntilTS, nil - } - return "", time.Time{}, nil - }) - assert.NoError(t, err) - }) - } -} diff --git a/mautrix-patched/federation/resolution.go b/mautrix-patched/federation/resolution.go deleted file mode 100644 index a3188266..00000000 --- a/mautrix-patched/federation/resolution.go +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/rs/zerolog" - - "maunium.net/go/mautrix" -) - -type ResolvedServerName struct { - ServerName string `json:"server_name"` - HostHeader string `json:"host_header"` - IPPort []string `json:"ip_port"` - Expires time.Time `json:"expires"` -} - -type ResolveServerNameOpts struct { - HTTPClient *http.Client - DNSClient *net.Resolver -} - -var ( - ErrInvalidServerName = errors.New("invalid server name") -) - -// ResolveServerName implements the full server discovery algorithm as specified in https://spec.matrix.org/v1.11/server-server-api/#resolving-server-names -func ResolveServerName(ctx context.Context, serverName string, opts ...*ResolveServerNameOpts) (*ResolvedServerName, error) { - var opt ResolveServerNameOpts - if len(opts) > 0 && opts[0] != nil { - opt = *opts[0] - } - if opt.HTTPClient == nil { - opt.HTTPClient = http.DefaultClient - } - if opt.DNSClient == nil { - opt.DNSClient = net.DefaultResolver - } - output := ResolvedServerName{ - ServerName: serverName, - HostHeader: serverName, - IPPort: []string{serverName}, - Expires: time.Now().Add(24 * time.Hour), - } - hostname, port, ok := ParseServerName(serverName) - if !ok { - return nil, ErrInvalidServerName - } - // Steps 1 and 2: handle IP literals and hostnames with port - if net.ParseIP(hostname) != nil || port != 0 { - if port == 0 { - port = 8448 - } - output.IPPort = []string{net.JoinHostPort(hostname, strconv.Itoa(int(port)))} - return &output, nil - } - // Step 3: resolve .well-known - wellKnown, expiry, err := RequestWellKnown(ctx, opt.HTTPClient, hostname) - if err != nil { - zerolog.Ctx(ctx).Trace(). - Str("server_name", serverName). - Err(err). - Msg("Failed to get well-known data") - } else if wellKnown != nil { - output.Expires = expiry - output.HostHeader = wellKnown.Server - wkHost, wkPort, ok := ParseServerName(wellKnown.Server) - if ok { - hostname, port = wkHost, wkPort - } - // Step 3.1 and 3.2: IP literals and hostnames with port inside .well-known - if net.ParseIP(hostname) != nil || port != 0 { - if port == 0 { - port = 8448 - } - output.IPPort = []string{net.JoinHostPort(hostname, strconv.Itoa(int(port)))} - return &output, nil - } - } - // Step 3.3, 3.4, 4 and 5: resolve SRV records - srv, err := RequestSRV(ctx, opt.DNSClient, hostname) - if err != nil { - // TODO log more noisily for abnormal errors? - zerolog.Ctx(ctx).Trace(). - Str("server_name", serverName). - Str("hostname", hostname). - Err(err). - Msg("Failed to get SRV record") - } else if len(srv) > 0 { - output.IPPort = make([]string, len(srv)) - for i, record := range srv { - output.IPPort[i] = net.JoinHostPort(strings.TrimRight(record.Target, "."), strconv.Itoa(int(record.Port))) - } - return &output, nil - } - // Step 6 or 3.5: no SRV records were found, so default to port 8448 - output.IPPort = []string{net.JoinHostPort(hostname, "8448")} - return &output, nil -} - -// RequestSRV resolves the `_matrix-fed._tcp` SRV record for the given hostname. -// If the new matrix-fed record is not found, it falls back to the old `_matrix._tcp` record. -func RequestSRV(ctx context.Context, cli *net.Resolver, hostname string) ([]*net.SRV, error) { - _, target, err := cli.LookupSRV(ctx, "matrix-fed", "tcp", hostname) - var dnsErr *net.DNSError - if err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound { - _, target, err = cli.LookupSRV(ctx, "matrix", "tcp", hostname) - } - return target, err -} - -func parseCacheControl(resp *http.Response) time.Duration { - cc := resp.Header.Get("Cache-Control") - if cc == "" { - return 0 - } - parts := strings.Split(cc, ",") - for _, part := range parts { - kv := strings.SplitN(strings.TrimSpace(part), "=", 1) - switch kv[0] { - case "no-cache", "no-store": - return 0 - case "max-age": - if len(kv) < 2 { - continue - } - maxAge, err := strconv.Atoi(kv[1]) - if err != nil || maxAge < 0 { - continue - } - age, _ := strconv.Atoi(resp.Header.Get("Age")) - return time.Duration(maxAge-age) * time.Second - } - } - return 0 -} - -const ( - MinCacheDuration = 1 * time.Hour - MaxCacheDuration = 72 * time.Hour - DefaultCacheDuration = 24 * time.Hour -) - -// RequestWellKnown sends a request to the well-known endpoint of a server and returns the response, -// plus the time when the cache should expire. -func RequestWellKnown(ctx context.Context, cli *http.Client, hostname string) (*RespWellKnown, time.Time, error) { - wellKnownURL := url.URL{ - Scheme: "https", - Host: hostname, - Path: "/.well-known/matrix/server", - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL.String(), nil) - if err != nil { - return nil, time.Time{}, fmt.Errorf("failed to prepare request: %w", err) - } - resp, err := cli.Do(req) - if err != nil { - return nil, time.Time{}, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, time.Time{}, fmt.Errorf("unexpected status code %d", resp.StatusCode) - } else if resp.ContentLength > mautrix.WellKnownMaxSize { - return nil, time.Time{}, fmt.Errorf("response too large: %d bytes", resp.ContentLength) - } - var respData RespWellKnown - err = json.NewDecoder(io.LimitReader(resp.Body, mautrix.WellKnownMaxSize)).Decode(&respData) - if err != nil { - return nil, time.Time{}, fmt.Errorf("failed to decode response: %w", err) - } else if respData.Server == "" { - return nil, time.Time{}, errors.New("server name not found in response") - } - cacheDuration := parseCacheControl(resp) - if cacheDuration <= 0 { - cacheDuration = DefaultCacheDuration - } else if cacheDuration < MinCacheDuration { - cacheDuration = MinCacheDuration - } else if cacheDuration > MaxCacheDuration { - cacheDuration = MaxCacheDuration - } - return &respData, time.Now().Add(24 * time.Hour), nil -} diff --git a/mautrix-patched/federation/resolution_test.go b/mautrix-patched/federation/resolution_test.go deleted file mode 100644 index 62200454..00000000 --- a/mautrix-patched/federation/resolution_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation_test - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/federation" -) - -type resolveTestCase struct { - name string - serverName string - expected federation.ResolvedServerName -} - -func TestResolveServerName(t *testing.T) { - // See https://t2bot.io/docs/resolvematrix/ for more info on the RM test cases - testCases := []resolveTestCase{{ - "maunium", - "maunium.net", - federation.ResolvedServerName{ - HostHeader: "federation.mau.chat", - IPPort: []string{"meow.host.mau.fi:443"}, - }, - }, { - "IP literal", - "135.181.208.158", - federation.ResolvedServerName{ - HostHeader: "135.181.208.158", - IPPort: []string{"135.181.208.158:8448"}, - }, - }, { - "IP literal with port", - "135.181.208.158:8447", - federation.ResolvedServerName{ - HostHeader: "135.181.208.158:8447", - IPPort: []string{"135.181.208.158:8447"}, - }, - }, { - "RM Step 2", - "2.s.resolvematrix.dev:7652", - federation.ResolvedServerName{ - HostHeader: "2.s.resolvematrix.dev:7652", - IPPort: []string{"2.s.resolvematrix.dev:7652"}, - }, - }, { - "RM Step 3B", - "3b.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "wk.3b.s.resolvematrix.dev:7753", - IPPort: []string{"wk.3b.s.resolvematrix.dev:7753"}, - }, - }, { - "RM Step 3C", - "3c.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "wk.3c.s.resolvematrix.dev", - IPPort: []string{"srv.wk.3c.s.resolvematrix.dev:7754"}, - }, - }, { - "RM Step 3C MSC4040", - "3c.msc4040.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "wk.3c.msc4040.s.resolvematrix.dev", - IPPort: []string{"srv.wk.3c.msc4040.s.resolvematrix.dev:7053"}, - }, - }, { - "RM Step 3D", - "3d.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "wk.3d.s.resolvematrix.dev", - IPPort: []string{"wk.3d.s.resolvematrix.dev:8448"}, - }, - }, { - "RM Step 4", - "4.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "4.s.resolvematrix.dev", - IPPort: []string{"srv.4.s.resolvematrix.dev:7855"}, - }, - }, { - "RM Step 4 MSC4040", - "4.msc4040.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "4.msc4040.s.resolvematrix.dev", - IPPort: []string{"srv.4.msc4040.s.resolvematrix.dev:7054"}, - }, - }, { - "RM Step 5", - "5.s.resolvematrix.dev", - federation.ResolvedServerName{ - HostHeader: "5.s.resolvematrix.dev", - IPPort: []string{"5.s.resolvematrix.dev:8448"}, - }, - }} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - tc.expected.ServerName = tc.serverName - resp, err := federation.ResolveServerName(context.TODO(), tc.serverName) - require.NoError(t, err) - resp.Expires = time.Time{} - assert.Equal(t, tc.expected, *resp) - }) - } -} diff --git a/mautrix-patched/federation/serverauth.go b/mautrix-patched/federation/serverauth.go deleted file mode 100644 index cd300341..00000000 --- a/mautrix-patched/federation/serverauth.go +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "maps" - "net/http" - "slices" - "strings" - "sync" - - "github.com/rs/zerolog" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" -) - -type ServerAuth struct { - Keys KeyCache - Client *Client - GetDestination func(XMatrixAuth) string - MaxBodySize int64 - - keyFetchLocks map[string]*sync.Mutex - keyFetchLocksLock sync.Mutex -} - -func NewServerAuth(client *Client, keyCache KeyCache, getDestination func(auth XMatrixAuth) string) *ServerAuth { - return &ServerAuth{ - Keys: keyCache, - Client: client, - GetDestination: getDestination, - MaxBodySize: 50 * 1024 * 1024, - keyFetchLocks: make(map[string]*sync.Mutex), - } -} - -var MUnauthorized = mautrix.RespError{ErrCode: "M_UNAUTHORIZED", StatusCode: http.StatusUnauthorized} - -var ( - errMissingAuthHeader = MUnauthorized.WithMessage("Missing Authorization header") - errInvalidAuthHeader = MUnauthorized.WithMessage("Authorization header does not start with X-Matrix") - errMalformedAuthHeader = MUnauthorized.WithMessage("X-Matrix value is missing required components") - errInvalidDestination = MUnauthorized.WithMessage("Invalid destination in X-Matrix header") - errFailedToQueryKeys = MUnauthorized.WithMessage("Failed to query server keys") - errInvalidSelfSignatures = MUnauthorized.WithMessage("Server keys don't have valid self-signatures") - errRequestBodyTooLarge = mautrix.MTooLarge.WithMessage("Request body too large") - errInvalidJSONBody = mautrix.MBadJSON.WithMessage("Request body is not valid JSON") - errBodyReadFailed = mautrix.MUnknown.WithMessage("Failed to read request body") - errInvalidRequestSignature = MUnauthorized.WithMessage("Failed to verify request signature") -) - -type XMatrixAuth struct { - Origin string - Destination string - KeyID id.KeyID - Signature string -} - -func (xma XMatrixAuth) String() string { - return fmt.Sprintf( - `X-Matrix origin="%s",destination="%s",key="%s",sig="%s"`, - xma.Origin, - xma.Destination, - xma.KeyID, - xma.Signature, - ) -} - -func ParseXMatrixAuth(auth string) (xma XMatrixAuth) { - auth = strings.TrimPrefix(auth, "X-Matrix ") - // TODO upgrade to strings.SplitSeq after Go 1.24 is the minimum - for _, part := range strings.Split(auth, ",") { - part = strings.TrimSpace(part) - eqIdx := strings.Index(part, "=") - if eqIdx == -1 || strings.Count(part, "=") > 1 { - continue - } - val := strings.Trim(part[eqIdx+1:], "\"") - switch strings.ToLower(part[:eqIdx]) { - case "origin": - xma.Origin = val - case "destination": - xma.Destination = val - case "key": - xma.KeyID = id.KeyID(val) - case "sig": - xma.Signature = val - } - } - return -} - -func (sa *ServerAuth) GetKeysWithCache(ctx context.Context, serverName string, keyID id.KeyID) (*ServerKeyResponse, error) { - res, err := sa.Keys.LoadKeys(serverName) - if err != nil { - return nil, fmt.Errorf("failed to read cache: %w", err) - } else if res.HasKey(keyID) { - return res, nil - } - - sa.keyFetchLocksLock.Lock() - lock, ok := sa.keyFetchLocks[serverName] - if !ok { - lock = &sync.Mutex{} - sa.keyFetchLocks[serverName] = lock - } - sa.keyFetchLocksLock.Unlock() - - lock.Lock() - defer lock.Unlock() - res, err = sa.Keys.LoadKeys(serverName) - if err != nil { - return nil, fmt.Errorf("failed to read cache: %w", err) - } else if res != nil { - if res.HasKey(keyID) { - return res, nil - } else if !sa.Keys.ShouldReQuery(serverName) { - zerolog.Ctx(ctx).Trace(). - Str("server_name", serverName). - Stringer("key_id", keyID). - Msg("Not sending key request for missing key ID, last query was too recent") - return res, nil - } - } - res, err = sa.Client.ServerKeys(ctx, serverName) - if err != nil { - sa.Keys.StoreFetchError(serverName, err) - return nil, err - } - sa.Keys.StoreKeys(res) - return res, nil -} - -type fixedLimitedReader struct { - R io.Reader - N int64 - Err error -} - -func (l *fixedLimitedReader) Read(p []byte) (n int, err error) { - if l.N <= 0 { - return 0, l.Err - } - if int64(len(p)) > l.N { - p = p[0:l.N] - } - n, err = l.R.Read(p) - l.N -= int64(n) - return -} - -func (sa *ServerAuth) Authenticate(r *http.Request) (*http.Request, *mautrix.RespError) { - defer func() { - _ = r.Body.Close() - }() - log := zerolog.Ctx(r.Context()) - if r.ContentLength > sa.MaxBodySize { - return nil, &errRequestBodyTooLarge - } - auth := r.Header.Get("Authorization") - if auth == "" { - return nil, &errMissingAuthHeader - } else if !strings.HasPrefix(auth, "X-Matrix ") { - return nil, &errInvalidAuthHeader - } - parsed := ParseXMatrixAuth(auth) - if parsed.Origin == "" || parsed.KeyID == "" || parsed.Signature == "" { - log.Trace().Str("auth_header", auth).Msg("Malformed X-Matrix header") - return nil, &errMalformedAuthHeader - } - destination := sa.GetDestination(parsed) - if destination == "" || (parsed.Destination != "" && parsed.Destination != destination) { - log.Trace(). - Str("got_destination", parsed.Destination). - Str("expected_destination", destination). - Msg("Invalid destination in X-Matrix header") - return nil, &errInvalidDestination - } - resp, err := sa.GetKeysWithCache(r.Context(), parsed.Origin, parsed.KeyID) - if err != nil { - if !errors.Is(err, ErrRecentKeyQueryFailed) { - log.Err(err). - Str("server_name", parsed.Origin). - Msg("Failed to query keys to authenticate request") - } else { - log.Trace().Err(err). - Str("server_name", parsed.Origin). - Msg("Failed to query keys to authenticate request (cached error)") - } - return nil, &errFailedToQueryKeys - } else if err := resp.VerifySelfSignature(); err != nil { - log.Trace().Err(err). - Str("server_name", parsed.Origin). - Msg("Failed to validate self-signatures of server keys") - return nil, &errInvalidSelfSignatures - } - key, ok := resp.VerifyKeys[parsed.KeyID] - if !ok { - keys := slices.Collect(maps.Keys(resp.VerifyKeys)) - log.Trace(). - Stringer("expected_key_id", parsed.KeyID). - Any("found_key_ids", keys). - Msg("Didn't find expected key ID to verify request") - return nil, ptr.Ptr(MUnauthorized.WithMessage("Key ID %q not found (got %v)", parsed.KeyID, keys)) - } - var reqBody []byte - if r.ContentLength != 0 && r.Method != http.MethodGet && r.Method != http.MethodHead { - reqBody, err = io.ReadAll(&fixedLimitedReader{R: r.Body, N: sa.MaxBodySize, Err: errRequestBodyTooLarge}) - if errors.Is(err, errRequestBodyTooLarge) { - return nil, &errRequestBodyTooLarge - } else if err != nil { - log.Err(err). - Str("server_name", parsed.Origin). - Msg("Failed to read request body to authenticate") - return nil, &errBodyReadFailed - } else if !json.Valid(reqBody) { - return nil, &errInvalidJSONBody - } - } - err = (&signableRequest{ - Method: r.Method, - URI: r.URL.RequestURI(), - Origin: parsed.Origin, - Destination: destination, - Content: reqBody, - }).Verify(key.Key, parsed.Signature) - if err != nil { - log.Trace().Err(err).Msg("Request has invalid signature") - return nil, &errInvalidRequestSignature - } - ctx := context.WithValue(r.Context(), contextKeyDestinationServer, destination) - ctx = context.WithValue(ctx, contextKeyOriginServer, parsed.Origin) - ctx = log.With(). - Str("origin_server_name", parsed.Origin). - Str("destination_server_name", destination). - Logger().WithContext(ctx) - modifiedReq := r.WithContext(ctx) - if reqBody != nil { - modifiedReq.Body = io.NopCloser(bytes.NewReader(reqBody)) - } - return modifiedReq, nil -} - -func (sa *ServerAuth) AuthenticateMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if modifiedReq, err := sa.Authenticate(r); err != nil { - err.Write(w) - } else { - next.ServeHTTP(w, modifiedReq) - } - }) -} diff --git a/mautrix-patched/federation/serverauth_test.go b/mautrix-patched/federation/serverauth_test.go deleted file mode 100644 index 07c92ada..00000000 --- a/mautrix-patched/federation/serverauth_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation_test - -import ( - "context" - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mau.fi/util/exhttp" - - "maunium.net/go/mautrix/federation" -) - -func TestServerKeyResponse_VerifySelfSignature(t *testing.T) { - cli := federation.NewClient("", nil, nil, exhttp.SensibleClientSettings) - cli.AllowIP = nil - ctx := context.Background() - for _, name := range []string{"matrix.org", "maunium.net", "cd.mau.dev", "uwu.mau.dev"} { - t.Run(name, func(t *testing.T) { - resp, err := cli.ServerKeys(ctx, name) - require.NoError(t, err) - assert.NoError(t, resp.VerifySelfSignature()) - }) - } -} - -func TestServerKeyResponse_FailWithFilter(t *testing.T) { - cli := federation.NewClient("", nil, nil, exhttp.SensibleClientSettings) - cli.AllowIP = func(ip net.IP) bool { - return false - } - ctx := context.Background() - _, err := cli.ServerKeys(ctx, "matrix.org") - assert.ErrorIs(t, err, federation.ErrIPFiltered) -} diff --git a/mautrix-patched/federation/servername.go b/mautrix-patched/federation/servername.go deleted file mode 100644 index 33590712..00000000 --- a/mautrix-patched/federation/servername.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "net" - "strconv" - "strings" -) - -func isSpecCompliantIPv6(host string) bool { - // IPv6address = 2*45IPv6char - // IPv6char = DIGIT / %x41-46 / %x61-66 / ":" / "." - // ; 0-9, A-F, a-f, :, . - if len(host) < 2 || len(host) > 45 { - return false - } - for _, ch := range host { - if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') && ch != ':' && ch != '.' { - return false - } - } - return true -} - -func isValidIPv4Chunk(str string) bool { - if len(str) == 0 || len(str) > 3 { - return false - } - for _, ch := range str { - if ch < '0' || ch > '9' { - return false - } - } - return true - -} - -func isSpecCompliantIPv4(host string) bool { - // IPv4address = 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT - if len(host) < 7 || len(host) > 15 { - return false - } - parts := strings.Split(host, ".") - return len(parts) == 4 && - isValidIPv4Chunk(parts[0]) && - isValidIPv4Chunk(parts[1]) && - isValidIPv4Chunk(parts[2]) && - isValidIPv4Chunk(parts[3]) -} - -func isSpecCompliantDNSName(host string) bool { - // dns-name = 1*255dns-char - // dns-char = DIGIT / ALPHA / "-" / "." - if len(host) == 0 || len(host) > 255 { - return false - } - for _, ch := range host { - if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && ch != '-' && ch != '.' { - return false - } - } - return true -} - -// ParseServerName parses the port and hostname from a Matrix server name and validates that -// it matches the grammar specified in https://spec.matrix.org/v1.11/appendices/#server-name -func ParseServerName(serverName string) (host string, port uint16, ok bool) { - if len(serverName) == 0 || len(serverName) > 255 { - return - } - colonIdx := strings.LastIndexByte(serverName, ':') - if colonIdx > 0 { - u64Port, err := strconv.ParseUint(serverName[colonIdx+1:], 10, 16) - if err == nil { - port = uint16(u64Port) - serverName = serverName[:colonIdx] - } - } - if serverName[0] == '[' { - if serverName[len(serverName)-1] != ']' { - return - } - host = serverName[1 : len(serverName)-1] - ok = isSpecCompliantIPv6(host) && net.ParseIP(host) != nil - } else { - host = serverName - ok = isSpecCompliantDNSName(host) || isSpecCompliantIPv4(host) - } - return -} diff --git a/mautrix-patched/federation/servername_test.go b/mautrix-patched/federation/servername_test.go deleted file mode 100644 index 156d692f..00000000 --- a/mautrix-patched/federation/servername_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/federation" -) - -type parseTestCase struct { - name string - serverName string - hostname string - port uint16 -} - -func TestParseServerName(t *testing.T) { - testCases := []parseTestCase{{ - "Domain", - "matrix.org", - "matrix.org", - 0, - }, { - "Domain with port", - "matrix.org:8448", - "matrix.org", - 8448, - }, { - "IPv4 literal", - "1.2.3.4", - "1.2.3.4", - 0, - }, { - "IPv4 literal with port", - "1.2.3.4:8448", - "1.2.3.4", - 8448, - }, { - "IPv6 literal", - "[1234:5678::abcd]", - "1234:5678::abcd", - 0, - }, { - "IPv6 literal with port", - "[1234:5678::abcd]:8448", - "1234:5678::abcd", - 8448, - }} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - hostname, port, ok := federation.ParseServerName(tc.serverName) - assert.True(t, ok) - assert.Equal(t, tc.hostname, hostname) - assert.Equal(t, tc.port, port) - }) - } -} diff --git a/mautrix-patched/federation/signingkey.go b/mautrix-patched/federation/signingkey.go deleted file mode 100644 index fe21ad27..00000000 --- a/mautrix-patched/federation/signingkey.go +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package federation - -import ( - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/tidwall/sjson" - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/federation/signutil" - "maunium.net/go/mautrix/id" -) - -// SigningKey is a Matrix federation signing key pair. -type SigningKey struct { - ID id.KeyID - Pub id.SigningKey - Priv ed25519.PrivateKey -} - -// SynapseString returns a string representation of the private key compatible with Synapse's .signing.key file format. -// -// The output of this function can be parsed back into a [SigningKey] using the [ParseSynapseKey] function. -func (sk *SigningKey) SynapseString() string { - alg, keyID := sk.ID.Parse() - return fmt.Sprintf("%s %s %s", alg, keyID, base64.RawStdEncoding.EncodeToString(sk.Priv.Seed())) -} - -// ParseSynapseKey parses a Synapse-compatible private key string into a SigningKey. -func ParseSynapseKey(key string) (*SigningKey, error) { - parts := strings.Split(key, " ") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid key format (expected 3 space-separated parts, got %d)", len(parts)) - } else if parts[0] != string(id.KeyAlgorithmEd25519) { - return nil, fmt.Errorf("unsupported key algorithm %s (only ed25519 is supported)", parts[0]) - } - seed, err := base64.RawStdEncoding.DecodeString(parts[2]) - if err != nil { - return nil, fmt.Errorf("invalid private key: %w", err) - } - priv := ed25519.NewKeyFromSeed(seed) - pub := base64.RawStdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)) - return &SigningKey{ - ID: id.NewKeyID(id.KeyAlgorithmEd25519, parts[1]), - Pub: id.SigningKey(pub), - Priv: priv, - }, nil -} - -// GenerateSigningKey generates a new random signing key. -func GenerateSigningKey() *SigningKey { - pub, priv, err := ed25519.GenerateKey(nil) - if err != nil { - panic(err) - } - return &SigningKey{ - ID: id.NewKeyID(id.KeyAlgorithmEd25519, base64.RawURLEncoding.EncodeToString(pub[:4])), - Pub: id.SigningKey(base64.RawStdEncoding.EncodeToString(pub)), - Priv: priv, - } -} - -// ServerKeyResponse is the response body for the `GET /_matrix/key/v2/server` endpoint. -// It's also used inside the query endpoint response structs. -type ServerKeyResponse struct { - ServerName string `json:"server_name"` - VerifyKeys map[id.KeyID]ServerVerifyKey `json:"verify_keys"` - OldVerifyKeys map[id.KeyID]OldVerifyKey `json:"old_verify_keys,omitempty"` - Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` - ValidUntilTS jsontime.UnixMilli `json:"valid_until_ts"` - - Raw json.RawMessage `json:"-"` -} - -type QueryKeysResponse struct { - ServerKeys []*ServerKeyResponse `json:"server_keys"` -} - -func (skr *ServerKeyResponse) HasKey(keyID id.KeyID) bool { - if skr == nil { - return false - } else if _, ok := skr.VerifyKeys[keyID]; ok { - return true - } - return false -} - -func (skr *ServerKeyResponse) VerifySelfSignature() error { - for keyID, key := range skr.VerifyKeys { - if err := signutil.VerifyJSON(skr.ServerName, keyID, key.Key, skr.Raw); err != nil { - return fmt.Errorf("failed to verify self signature for key %s: %w", keyID, err) - } - } - return nil -} - -type marshalableSKR ServerKeyResponse - -func (skr *ServerKeyResponse) UnmarshalJSON(data []byte) error { - skr.Raw = data - return json.Unmarshal(data, (*marshalableSKR)(skr)) -} - -type ServerVerifyKey struct { - Key id.SigningKey `json:"key"` -} - -func (svk *ServerVerifyKey) Decode() (ed25519.PublicKey, error) { - return base64.RawStdEncoding.DecodeString(string(svk.Key)) -} - -type OldVerifyKey struct { - Key id.SigningKey `json:"key"` - ExpiredTS jsontime.UnixMilli `json:"expired_ts"` -} - -func (sk *SigningKey) SignJSON(data any) (string, error) { - marshaled, err := canonicaljson.Marshal(data) - if err != nil { - return "", err - } - marshaled, err = sjson.DeleteBytes(marshaled, "signatures") - if err != nil { - return "", err - } - err = canonicaljson.Canonicalize(&marshaled) - if err != nil { - return "", fmt.Errorf("failed to canonicalize JSON after deleting signatures: %w", err) - } - return base64.RawStdEncoding.EncodeToString(sk.SignCanonicalJSON(marshaled)), err -} - -func (sk *SigningKey) SignCanonicalJSON(data json.RawMessage) []byte { - return ed25519.Sign(sk.Priv, data) -} - -// GenerateKeyResponse generates a key response signed by this key with the given server name and optionally some old verify keys. -func (sk *SigningKey) GenerateKeyResponse(serverName string, oldVerifyKeys map[id.KeyID]OldVerifyKey) *ServerKeyResponse { - skr := &ServerKeyResponse{ - ServerName: serverName, - OldVerifyKeys: oldVerifyKeys, - ValidUntilTS: jsontime.UM(time.Now().Add(24 * time.Hour)), - VerifyKeys: map[id.KeyID]ServerVerifyKey{ - sk.ID: {Key: sk.Pub}, - }, - } - signature, err := sk.SignJSON(skr) - if err != nil { - panic(err) - } - skr.Signatures = map[string]map[id.KeyID]string{ - serverName: { - sk.ID: signature, - }, - } - return skr -} diff --git a/mautrix-patched/federation/signutil/verify.go b/mautrix-patched/federation/signutil/verify.go deleted file mode 100644 index d8d80a8f..00000000 --- a/mautrix-patched/federation/signutil/verify.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package signutil - -import ( - "bytes" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - "go.mau.fi/util/exgjson" - - "maunium.net/go/mautrix/crypto/canonicaljson" - "maunium.net/go/mautrix/id" -) - -var ErrSignatureNotFound = errors.New("signature not found") -var ErrInvalidSignature = errors.New("invalid signature") - -func VerifyJSON(serverName string, keyID id.KeyID, key id.SigningKey, data any) error { - var err error - message, ok := data.(json.RawMessage) - if !ok { - message, err = canonicaljson.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal data: %w", err) - } - } else { - // Canonicalize later may mutate the data, so clone the input - message = bytes.Clone(message) - } - sigVal := gjson.GetBytes(message, exgjson.Path("signatures", serverName, string(keyID))) - if sigVal.Type != gjson.String { - return ErrSignatureNotFound - } - message, err = sjson.DeleteBytes(message, "signatures") - if err != nil { - return fmt.Errorf("failed to delete signatures: %w", err) - } - message, err = sjson.DeleteBytes(message, "unsigned") - if err != nil { - return fmt.Errorf("failed to delete unsigned: %w", err) - } - err = canonicaljson.Canonicalize(&message) - if err != nil { - return fmt.Errorf("failed to canonicalize JSON after deleting signatures and unsigned: %w", err) - } - return VerifyJSONCanonical(key, sigVal.Str, message) -} - -func VerifyJSONAny(key id.SigningKey, data any) error { - var err error - message, ok := data.(json.RawMessage) - if !ok { - message, err = canonicaljson.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal data: %w", err) - } - } else { - // Canonicalize later may mutate the data, so clone the input - message = bytes.Clone(message) - } - sigs := gjson.GetBytes(message, "signatures") - if !sigs.IsObject() { - return ErrSignatureNotFound - } - message, err = sjson.DeleteBytes(message, "signatures") - if err != nil { - return fmt.Errorf("failed to delete signatures: %w", err) - } - message, err = sjson.DeleteBytes(message, "unsigned") - if err != nil { - return fmt.Errorf("failed to delete unsigned: %w", err) - } - err = canonicaljson.Canonicalize(&message) - if err != nil { - return fmt.Errorf("failed to canonicalize JSON after deleting signatures and unsigned: %w", err) - } - var validated bool - sigs.ForEach(func(_, value gjson.Result) bool { - if !value.IsObject() { - return true - } - value.ForEach(func(_, value gjson.Result) bool { - if value.Type != gjson.String { - return true - } - validated = VerifyJSONCanonical(key, value.Str, message) == nil - return !validated - }) - return !validated - }) - if !validated { - return ErrInvalidSignature - } - return nil -} - -func VerifyJSONCanonical(key id.SigningKey, sig string, message json.RawMessage) error { - sigBytes, err := base64.RawStdEncoding.DecodeString(sig) - if err != nil { - return fmt.Errorf("failed to decode signature: %w", err) - } - keyBytes, err := base64.RawStdEncoding.DecodeString(string(key)) - if err != nil { - return fmt.Errorf("failed to decode key: %w", err) - } - if !ed25519.Verify(keyBytes, message, sigBytes) { - return ErrInvalidSignature - } - return nil -} diff --git a/mautrix-patched/filter.go b/mautrix-patched/filter.go deleted file mode 100644 index 54973dab..00000000 --- a/mautrix-patched/filter.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2017 Jan Christian Grünhage - -package mautrix - -import ( - "errors" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type EventFormat string - -const ( - EventFormatClient EventFormat = "client" - EventFormatFederation EventFormat = "federation" -) - -// Filter is used by clients to specify how the server should filter responses to e.g. sync requests -// Specified by: https://spec.matrix.org/v1.2/client-server-api/#filtering -type Filter struct { - AccountData *FilterPart `json:"account_data,omitempty"` - EventFields []string `json:"event_fields,omitempty"` - EventFormat EventFormat `json:"event_format,omitempty"` - Presence *FilterPart `json:"presence,omitempty"` - Room *RoomFilter `json:"room,omitempty"` - - BeeperToDevice *FilterPart `json:"com.beeper.to_device,omitempty"` -} - -// RoomFilter is used to define filtering rules for room events -type RoomFilter struct { - AccountData *FilterPart `json:"account_data,omitempty"` - Ephemeral *FilterPart `json:"ephemeral,omitempty"` - IncludeLeave bool `json:"include_leave,omitempty"` - NotRooms []id.RoomID `json:"not_rooms,omitempty"` - Rooms []id.RoomID `json:"rooms,omitempty"` - State *FilterPart `json:"state,omitempty"` - Timeline *FilterPart `json:"timeline,omitempty"` -} - -// FilterPart is used to define filtering rules for specific categories of events -type FilterPart struct { - NotRooms []id.RoomID `json:"not_rooms,omitempty"` - Rooms []id.RoomID `json:"rooms,omitempty"` - Limit int `json:"limit,omitempty"` - NotSenders []id.UserID `json:"not_senders,omitempty"` - NotTypes []event.Type `json:"not_types,omitempty"` - Senders []id.UserID `json:"senders,omitempty"` - Types []event.Type `json:"types,omitempty"` - ContainsURL *bool `json:"contains_url,omitempty"` - LazyLoadMembers bool `json:"lazy_load_members,omitempty"` - IncludeRedundantMembers bool `json:"include_redundant_members,omitempty"` - UnreadThreadNotifications bool `json:"unread_thread_notifications,omitempty"` -} - -// Validate checks if the filter contains valid property values -func (filter *Filter) Validate() error { - if filter.EventFormat != EventFormatClient && filter.EventFormat != EventFormatFederation { - return errors.New("bad event_format value") - } - return nil -} - -// DefaultFilter returns the default filter used by the Matrix server if no filter is provided in the request -func DefaultFilter() Filter { - return Filter{ - AccountData: DefaultFilterPart(), - EventFields: nil, - EventFormat: "client", - Presence: DefaultFilterPart(), - Room: &RoomFilter{ - AccountData: DefaultFilterPart(), - Ephemeral: DefaultFilterPart(), - IncludeLeave: false, - NotRooms: nil, - Rooms: nil, - State: DefaultFilterPart(), - Timeline: DefaultFilterPart(), - }, - } -} - -// DefaultFilterPart returns the default filter part used by the Matrix server if no filter is provided in the request -func DefaultFilterPart() *FilterPart { - return &FilterPart{ - NotRooms: nil, - Rooms: nil, - Limit: 20, - NotSenders: nil, - NotTypes: nil, - Senders: nil, - Types: nil, - } -} diff --git a/mautrix-patched/format/doc.go b/mautrix-patched/format/doc.go deleted file mode 100644 index 84a10b60..00000000 --- a/mautrix-patched/format/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package format contains utilities for working with Matrix HTML, specifically -// methods to parse Markdown into HTML and to parse Matrix HTML into text or markdown. -// -// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes -package format diff --git a/mautrix-patched/format/htmlparser.go b/mautrix-patched/format/htmlparser.go deleted file mode 100644 index a504fa3a..00000000 --- a/mautrix-patched/format/htmlparser.go +++ /dev/null @@ -1,520 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package format - -import ( - "context" - "fmt" - "math" - "strconv" - "strings" - - "go.mau.fi/util/exstrings" - "golang.org/x/net/html" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type TagStack []string - -func (ts TagStack) Index(tag string) int { - for i := len(ts) - 1; i >= 0; i-- { - if ts[i] == tag { - return i - } - } - return -1 -} - -func (ts TagStack) Has(tag string) bool { - return ts.Index(tag) >= 0 -} - -type Context struct { - Ctx context.Context - ReturnData map[string]any - TagStack TagStack - - PreserveWhitespace bool -} - -func NewContext(ctx context.Context) Context { - return Context{ - Ctx: ctx, - ReturnData: map[string]any{}, - TagStack: make(TagStack, 0, 4), - } -} - -func (ctx Context) WithTag(tag string) Context { - ctx.TagStack = append(ctx.TagStack, tag) - return ctx -} - -func (ctx Context) WithWhitespace() Context { - ctx.PreserveWhitespace = true - return ctx -} - -type TextConverter func(string, Context) string -type SpoilerConverter func(text, reason string, ctx Context) string -type LinkConverter func(text, href string, ctx Context) string -type ColorConverter func(text, fg, bg string, ctx Context) string -type CodeBlockConverter func(code, language string, ctx Context) string -type PillConverter func(displayname, mxid, eventID string, ctx Context) string -type ImageConverter func(src, alt, title, width, height string, isEmoji bool) string - -const ContextKeyMentions = "_mentions" - -func DefaultPillConverter(displayname, mxid, eventID string, ctx Context) string { - switch { - case len(mxid) == 0, mxid[0] == '@': - existingMentions, _ := ctx.ReturnData[ContextKeyMentions].([]id.UserID) - ctx.ReturnData[ContextKeyMentions] = append(existingMentions, id.UserID(mxid)) - // User link, always just show the displayname - return displayname - case len(eventID) > 0: - // Event ID link, always just show the link - return fmt.Sprintf("https://matrix.to/#/%s/%s", mxid, eventID) - case mxid[0] == '!' && displayname == mxid: - // Room ID link with no separate display text, just show the link - return fmt.Sprintf("https://matrix.to/#/%s", mxid) - case mxid[0] == '#': - // Room alias link, just show the alias - return mxid - default: - // Other link (e.g. room ID link with display text), show text and link - return fmt.Sprintf("%s (https://matrix.to/#/%s)", displayname, mxid) - } -} - -func onlyBacktickCount(line string) (count int) { - for i := 0; i < len(line); i++ { - if line[i] != '`' { - return -1 - } - count++ - } - return -} - -func DefaultMonospaceBlockConverter(code, language string, ctx Context) string { - if len(code) == 0 || code[len(code)-1] != '\n' { - code += "\n" - } - fence := "```" - for line := range strings.SplitSeq(code, "\n") { - count := onlyBacktickCount(strings.TrimSpace(line)) - if count >= len(fence) { - fence = strings.Repeat("`", count+1) - } - } - return fmt.Sprintf("%s%s\n%s%s", fence, language, code, fence) -} - -// HTMLParser is a somewhat customizable Matrix HTML parser. -type HTMLParser struct { - PillConverter PillConverter - TabsToSpaces int - Newline string - HorizontalLine string - BoldConverter TextConverter - ItalicConverter TextConverter - StrikethroughConverter TextConverter - UnderlineConverter TextConverter - MathConverter TextConverter - MathBlockConverter TextConverter - LinkConverter LinkConverter - SpoilerConverter SpoilerConverter - ColorConverter ColorConverter - MonospaceBlockConverter CodeBlockConverter - MonospaceConverter TextConverter - TextConverter TextConverter - ImageConverter ImageConverter -} - -// TaggedString is a string that also contains a HTML tag. -type TaggedString struct { - string - tag string -} - -func (parser *HTMLParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) { - for _, attr := range node.Attr { - if attr.Key == attribute { - return attr.Val, true - } - } - return "", false -} - -func (parser *HTMLParser) getAttribute(node *html.Node, attribute string) string { - val, _ := parser.maybeGetAttribute(node, attribute) - return val -} - -// Digits counts the number of digits (and the sign, if negative) in an integer. -func Digits(num int) int { - if num == 0 { - return 1 - } else if num < 0 { - return Digits(-num) + 1 - } - return int(math.Floor(math.Log10(float64(num))) + 1) -} - -func (parser *HTMLParser) listToString(node *html.Node, ctx Context) string { - ordered := node.Data == "ol" - taggedChildren := parser.nodeToTaggedStrings(node.FirstChild, ctx) - counter := 1 - indentLength := 0 - if ordered { - start := parser.getAttribute(node, "start") - if len(start) > 0 { - counter, _ = strconv.Atoi(start) - } - - longestIndex := (counter - 1) + len(taggedChildren) - indentLength = Digits(longestIndex) - } - indent := strings.Repeat(" ", indentLength+2) - var children []string - for _, child := range taggedChildren { - if child.tag != "li" { - continue - } - var prefix string - // TODO make bullets and numbering configurable - if ordered { - indexPadding := indentLength - Digits(counter) - if indexPadding < 0 { - // This will happen on negative start indexes where longestIndex is usually wrong, otherwise shouldn't happen - indexPadding = 0 - } - prefix = fmt.Sprintf("%d. %s", counter, strings.Repeat(" ", indexPadding)) - } else { - prefix = "* " - } - str := prefix + child.string - counter++ - parts := strings.Split(str, "\n") - for i, part := range parts[1:] { - parts[i+1] = indent + part - } - str = strings.Join(parts, "\n") - children = append(children, str) - } - return strings.Join(children, "\n") -} - -func (parser *HTMLParser) basicFormatToString(node *html.Node, ctx Context) string { - str := parser.nodeToTagAwareString(node.FirstChild, ctx) - switch node.Data { - case "b", "strong": - if parser.BoldConverter != nil { - return parser.BoldConverter(str, ctx) - } - return fmt.Sprintf("**%s**", str) - case "i", "em": - if parser.ItalicConverter != nil { - return parser.ItalicConverter(str, ctx) - } - return fmt.Sprintf("_%s_", str) - case "s", "del", "strike": - if parser.StrikethroughConverter != nil { - return parser.StrikethroughConverter(str, ctx) - } - return fmt.Sprintf("~~%s~~", str) - case "u", "ins": - if parser.UnderlineConverter != nil { - return parser.UnderlineConverter(str, ctx) - } - case "tt", "code": - if parser.MonospaceConverter != nil { - return parser.MonospaceConverter(str, ctx) - } - return SafeMarkdownCode(str) - } - return str -} - -func (parser *HTMLParser) spanToString(node *html.Node, ctx Context) string { - str := parser.nodeToTagAwareString(node.FirstChild, ctx) - if node.Data == "span" || node.Data == "div" { - math, _ := parser.maybeGetAttribute(node, "data-mx-maths") - if math != "" && parser.MathConverter != nil { - if node.Data == "div" && parser.MathBlockConverter != nil { - str = parser.MathBlockConverter(math, ctx) - } else { - str = parser.MathConverter(math, ctx) - } - } - } - if node.Data == "span" { - reason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler") - if isSpoiler { - if parser.SpoilerConverter != nil { - str = parser.SpoilerConverter(str, reason, ctx) - } else if len(reason) > 0 { - str = fmt.Sprintf("||%s|%s||", reason, str) - } else { - str = fmt.Sprintf("||%s||", str) - } - } - } - if parser.ColorConverter != nil && (node.Data == "span" || node.Data == "font") { - fg := parser.getAttribute(node, "data-mx-color") - if len(fg) == 0 && node.Data == "font" { - fg = parser.getAttribute(node, "color") - } - bg := parser.getAttribute(node, "data-mx-bg-color") - if len(bg) > 0 || len(fg) > 0 { - str = parser.ColorConverter(str, fg, bg, ctx) - } - } - return str -} - -func (parser *HTMLParser) headerToString(node *html.Node, ctx Context) string { - children := parser.nodeToStrings(node.FirstChild, ctx) - length := int(node.Data[1] - '0') - prefix := strings.Repeat("#", length) + " " - return prefix + strings.Join(children, "") -} - -func (parser *HTMLParser) blockquoteToString(node *html.Node, ctx Context) string { - str := parser.nodeToTagAwareString(node.FirstChild, ctx) - childrenArr := strings.Split(strings.TrimSpace(str), "\n") - // TODO make blockquote prefix configurable - for index, child := range childrenArr { - childrenArr[index] = "> " + child - } - return strings.Join(childrenArr, "\n") -} - -func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) string { - str := parser.nodeToTagAwareString(node.FirstChild, ctx) - href := parser.getAttribute(node, "href") - if len(href) == 0 { - return str - } - if parser.PillConverter != nil { - parsedMatrix, err := id.ParseMatrixURIOrMatrixToURL(href) - if err == nil && parsedMatrix != nil { - return parser.PillConverter(str, parsedMatrix.PrimaryIdentifier(), parsedMatrix.SecondaryIdentifier(), ctx) - } - } - if parser.LinkConverter != nil { - return parser.LinkConverter(str, href, ctx) - } else if str == href || - str == strings.TrimPrefix(href, "mailto:") || - str == strings.TrimPrefix(href, "http://") || - str == strings.TrimPrefix(href, "https://") { - return str - } - return fmt.Sprintf("%s (%s)", str, href) -} - -func (parser *HTMLParser) imgToString(node *html.Node, ctx Context) string { - src := parser.getAttribute(node, "src") - alt := parser.getAttribute(node, "alt") - title := parser.getAttribute(node, "title") - width := parser.getAttribute(node, "width") - height := parser.getAttribute(node, "height") - _, isEmoji := parser.maybeGetAttribute(node, "data-mx-emoticon") - if parser.ImageConverter != nil { - return parser.ImageConverter(src, alt, title, width, height, isEmoji) - } - return alt -} - -func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) string { - ctx = ctx.WithTag(node.Data) - switch node.Data { - case "blockquote": - return parser.blockquoteToString(node, ctx) - case "ol", "ul": - return parser.listToString(node, ctx) - case "h1", "h2", "h3", "h4", "h5", "h6": - return parser.headerToString(node, ctx) - case "br": - return parser.Newline - case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tt", "code": - return parser.basicFormatToString(node, ctx) - case "span", "div", "font": - return parser.spanToString(node, ctx) - case "a": - return parser.linkToString(node, ctx) - case "p": - return parser.nodeToTagAwareString(node.FirstChild, ctx) - case "img": - return parser.imgToString(node, ctx) - case "hr": - return parser.HorizontalLine - case "input": - return parser.inputToString(node, ctx) - case "pre": - var preStr, language string - if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { - class := parser.getAttribute(node.FirstChild, "class") - if strings.HasPrefix(class, "language-") { - language = class[len("language-"):] - } - preStr = parser.nodeToString(node.FirstChild.FirstChild, ctx.WithWhitespace()) - } else { - preStr = parser.nodeToString(node.FirstChild, ctx.WithWhitespace()) - } - if parser.MonospaceBlockConverter != nil { - return parser.MonospaceBlockConverter(preStr, language, ctx) - } - return DefaultMonospaceBlockConverter(preStr, language, ctx) - default: - return parser.nodeToTagAwareString(node.FirstChild, ctx) - } -} - -func (parser *HTMLParser) inputToString(node *html.Node, ctx Context) string { - if len(ctx.TagStack) > 1 && ctx.TagStack[len(ctx.TagStack)-2] == "li" { - _, checked := parser.maybeGetAttribute(node, "checked") - if checked { - return "[x]" - } - return "[ ]" - } - return parser.nodeToTagAwareString(node.FirstChild, ctx) -} - -func (parser *HTMLParser) singleNodeToString(node *html.Node, ctx Context) TaggedString { - switch node.Type { - case html.TextNode: - if !ctx.PreserveWhitespace { - node.Data = exstrings.CollapseSpaces(strings.ReplaceAll(node.Data, "\n", "")) - } - if parser.TextConverter != nil { - node.Data = parser.TextConverter(node.Data, ctx) - } - return TaggedString{node.Data, "text"} - case html.ElementNode: - return TaggedString{parser.tagToString(node, ctx), node.Data} - case html.DocumentNode: - return TaggedString{parser.nodeToTagAwareString(node.FirstChild, ctx), "html"} - default: - return TaggedString{"", "unknown"} - } -} - -func (parser *HTMLParser) nodeToTaggedStrings(node *html.Node, ctx Context) (strs []TaggedString) { - for ; node != nil; node = node.NextSibling { - strs = append(strs, parser.singleNodeToString(node, ctx)) - } - return -} - -var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "pre", "blockquote", "div", "hr", "table"} - -func (parser *HTMLParser) isBlockTag(tag string) bool { - for _, blockTag := range BlockTags { - if tag == blockTag { - return true - } - } - return false -} - -func (parser *HTMLParser) nodeToTagAwareString(node *html.Node, ctx Context) string { - strs := parser.nodeToTaggedStrings(node, ctx) - var output strings.Builder - for _, str := range strs { - tstr := str.string - if parser.isBlockTag(str.tag) { - tstr = fmt.Sprintf("\n%s\n", tstr) - } - output.WriteString(tstr) - } - return strings.TrimSpace(output.String()) -} - -func (parser *HTMLParser) nodeToStrings(node *html.Node, ctx Context) (strs []string) { - for ; node != nil; node = node.NextSibling { - strs = append(strs, parser.singleNodeToString(node, ctx).string) - } - return -} - -func (parser *HTMLParser) nodeToString(node *html.Node, ctx Context) string { - return strings.Join(parser.nodeToStrings(node, ctx), "") -} - -// Parse converts Matrix HTML into text using the settings in this parser. -func (parser *HTMLParser) Parse(htmlData string, ctx Context) string { - if parser.TabsToSpaces >= 0 { - htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", parser.TabsToSpaces), -1) - } - node, _ := html.Parse(strings.NewReader(htmlData)) - return parser.nodeToTagAwareString(node, ctx) -} - -var TextHTMLParser = &HTMLParser{ - TabsToSpaces: 4, - Newline: "\n", - HorizontalLine: "\n---\n", - PillConverter: DefaultPillConverter, -} - -var MarkdownHTMLParser = &HTMLParser{ - TabsToSpaces: 4, - Newline: "\n", - HorizontalLine: "\n---\n", - PillConverter: DefaultPillConverter, - LinkConverter: func(text, href string, ctx Context) string { - if text == href { - return fmt.Sprintf("<%s>", href) - } - return fmt.Sprintf("[%s](%s)", text, href) - }, - MathConverter: func(s string, c Context) string { - return fmt.Sprintf("$%s$", s) - }, - MathBlockConverter: func(s string, c Context) string { - return fmt.Sprintf("$$\n%s\n$$", s) - }, - UnderlineConverter: func(s string, c Context) string { - return fmt.Sprintf("%s", s) - }, -} - -// HTMLToText converts Matrix HTML into text with the default settings. -func HTMLToText(html string) string { - return (&HTMLParser{ - TabsToSpaces: 4, - Newline: "\n", - HorizontalLine: "\n---\n", - PillConverter: DefaultPillConverter, - }).Parse(html, NewContext(context.TODO())) -} - -func HTMLToMarkdownFull(parser *HTMLParser, html string) (parsed string, mentions *event.Mentions) { - if parser == nil { - parser = MarkdownHTMLParser - } - ctx := NewContext(context.TODO()) - parsed = parser.Parse(html, ctx) - mentionList, _ := ctx.ReturnData[ContextKeyMentions].([]id.UserID) - mentions = &event.Mentions{ - UserIDs: mentionList, - } - return -} - -// HTMLToMarkdown converts Matrix HTML into markdown with the default settings. -// -// Currently, the only difference to HTMLToText is how links are formatted. -func HTMLToMarkdown(html string) string { - parsed, _ := HTMLToMarkdownFull(nil, html) - return parsed -} diff --git a/mautrix-patched/format/markdown.go b/mautrix-patched/format/markdown.go deleted file mode 100644 index 77ced0dc..00000000 --- a/mautrix-patched/format/markdown.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package format - -import ( - "fmt" - "regexp" - "strings" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/renderer/html" - "go.mau.fi/util/exstrings" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format/mdext" - "maunium.net/go/mautrix/id" -) - -const paragraphStart = "

          " -const paragraphEnd = "

          " - -var Extensions = goldmark.WithExtensions(extension.Strikethrough, extension.Table, mdext.Spoiler) -var HTMLOptions = goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()) - -var withHTML = goldmark.New(Extensions, HTMLOptions) -var noHTML = goldmark.New(Extensions, HTMLOptions, goldmark.WithExtensions(mdext.EscapeHTML)) - -// UnwrapSingleParagraph removes paragraph tags surrounding a string if the string only contains a single paragraph. -func UnwrapSingleParagraph(html string) string { - html = strings.TrimRight(html, "\n") - if strings.HasPrefix(html, paragraphStart) && strings.HasSuffix(html, paragraphEnd) { - htmlBodyWithoutP := html[len(paragraphStart) : len(html)-len(paragraphEnd)] - if !strings.Contains(htmlBodyWithoutP, paragraphStart) { - return htmlBodyWithoutP - } - } - return html -} - -var mdEscapeRegex = regexp.MustCompile("([\\\\`*_[\\]()])") - -func EscapeMarkdown(text string) string { - text = mdEscapeRegex.ReplaceAllString(text, "\\$1") - text = strings.ReplaceAll(text, ">", ">") - text = strings.ReplaceAll(text, "<", "<") - return text -} - -type uriAble interface { - String() string - URI() *id.MatrixURI -} - -func MarkdownMention(id uriAble) string { - return MarkdownMentionWithName(id.String(), id) -} - -func MarkdownMentionWithName(name string, id uriAble) string { - return MarkdownLink(name, id.URI().MatrixToURL()) -} - -func MarkdownMentionRoomID(name string, id id.RoomID, via ...string) string { - if name == "" { - name = id.String() - } - return MarkdownLink(name, id.URI(via...).MatrixToURL()) -} - -func MarkdownLink(name string, url string) string { - return fmt.Sprintf("[%s](%s)", EscapeMarkdown(name), EscapeMarkdown(url)) -} - -func SafeMarkdownCode[T ~string](textInput T) string { - if textInput == "" { - return "` `" - } - text := strings.ReplaceAll(string(textInput), "\n", " ") - backtickCount := exstrings.LongestSequenceOf(text, '`') - if backtickCount == 0 { - return fmt.Sprintf("`%s`", text) - } - quotes := strings.Repeat("`", backtickCount+1) - if text[0] == '`' || text[len(text)-1] == '`' { - return fmt.Sprintf("%s %s %s", quotes, text, quotes) - } - return fmt.Sprintf("%s%s%s", quotes, text, quotes) -} - -func RenderMarkdownCustom(text string, renderer goldmark.Markdown) event.MessageEventContent { - var buf strings.Builder - err := renderer.Convert([]byte(text), &buf) - if err != nil { - panic(fmt.Errorf("markdown parser errored: %w", err)) - } - htmlBody := UnwrapSingleParagraph(buf.String()) - return HTMLToContent(htmlBody) -} - -func TextToContent(text string) event.MessageEventContent { - return event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - Mentions: &event.Mentions{}, - } -} - -func HTMLToContentFull(renderer *HTMLParser, html string) event.MessageEventContent { - text, mentions := HTMLToMarkdownFull(renderer, html) - if html != text { - return event.MessageEventContent{ - FormattedBody: html, - Format: event.FormatHTML, - MsgType: event.MsgText, - Body: text, - Mentions: mentions, - } - } - return TextToContent(text) -} - -func HTMLToContent(html string) event.MessageEventContent { - return HTMLToContentFull(nil, html) -} - -func RenderMarkdown(text string, allowMarkdown, allowHTML bool) event.MessageEventContent { - var htmlBody string - - if allowMarkdown { - rndr := withHTML - if !allowHTML { - rndr = noHTML - } - return RenderMarkdownCustom(text, rndr) - } else if allowHTML { - htmlBody = strings.Replace(text, "\n", "
          ", -1) - return HTMLToContent(htmlBody) - } else { - return TextToContent(text) - } -} diff --git a/mautrix-patched/format/markdown_test.go b/mautrix-patched/format/markdown_test.go deleted file mode 100644 index 46ea4886..00000000 --- a/mautrix-patched/format/markdown_test.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package format_test - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/format/mdext" - "maunium.net/go/mautrix/id" -) - -func TestRenderMarkdown_PlainText(t *testing.T) { - content := format.RenderMarkdown("hello world", true, true) - assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) - content = format.RenderMarkdown("hello world", true, false) - assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) - content = format.RenderMarkdown("hello world", false, true) - assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) - content = format.RenderMarkdown("hello world", false, false) - assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "hello world", Mentions: &event.Mentions{}}, content) - content = format.RenderMarkdown(`
          mention`, false, false) - assert.Equal(t, event.MessageEventContent{MsgType: event.MsgText, Body: "mention", Mentions: &event.Mentions{}}, content) -} - -func TestRenderMarkdown_EscapeHTML(t *testing.T) { - content := format.RenderMarkdown("hello world", true, false) - assert.Equal(t, event.MessageEventContent{ - MsgType: event.MsgText, - Body: "hello world", - Format: event.FormatHTML, - FormattedBody: "<b>hello world</b>", - Mentions: &event.Mentions{}, - }, content) -} - -func TestRenderMarkdown_HTML(t *testing.T) { - content := format.RenderMarkdown("hello world", false, true) - assert.Equal(t, event.MessageEventContent{ - MsgType: event.MsgText, - Body: "**hello world**", - Format: event.FormatHTML, - FormattedBody: "hello world", - Mentions: &event.Mentions{}, - }, content) - - content = format.RenderMarkdown("hello world", true, true) - assert.Equal(t, event.MessageEventContent{ - MsgType: event.MsgText, - Body: "**hello world**", - Format: event.FormatHTML, - FormattedBody: "hello world", - Mentions: &event.Mentions{}, - }, content) - - content = format.RenderMarkdown(`[mention](https://matrix.to/#/@user:example.com)`, true, false) - assert.Equal(t, event.MessageEventContent{ - MsgType: event.MsgText, - Body: "mention", - Format: event.FormatHTML, - FormattedBody: `mention`, - Mentions: &event.Mentions{ - UserIDs: []id.UserID{"@user:example.com"}, - }, - }, content) -} - -var spoilerTests = map[string]string{ - "test ||bar||": "test bar", - "test ||reason|**bar**||": `test bar`, - - "test ||reason|[bar](https://example.com)||": `test bar`, - "test [||reason|foo||](https://example.com)": `test foo`, - "test [||foo||](https://example.com)": `test foo`, - "test [||_foo_||](https://example.com)": `test foo`, - // FIXME wrapping spoilers in italic/bold/strikethrough doesn't work for some reason - //"test **[||foo||](https://example.com)**": `test foo`, - //"test **||foo||**": `test foo`, - - "* ||foo||": `
          • foo
          `, - "> ||foo||": "

          foo

          ", -} - -func TestRenderMarkdown_Spoiler(t *testing.T) { - for markdown, html := range spoilerTests { - rendered := format.RenderMarkdown(markdown, true, false) - assert.Equal(t, markdown, rendered.Body) - assert.Equal(t, html, strings.ReplaceAll(rendered.FormattedBody, "\n", "")) - } -} - -var simpleSpoilerTests = map[string]string{ - "test ||bar||": "test bar", - "test [||foo||](https://example.com)": `test foo`, - "test [||*foo*||](https://example.com)": `test foo`, - "* ||foo||": `
          • foo
          `, - "> ||foo||": "

          foo

          ", - - // Simple spoiler renderer supports wrapping fully already - "test **[||foo||](https://example.com)**": `test foo`, - "test **||foo||**": `test foo`, - "test **||*foo*||**": `test foo`, - "test ~~**||*foo*||**~~": `test foo`, - "> ||~~***foo***~~||": "

          foo

          ", - - "a \\||test||": "a ||test||", - "\\~~test~~": "~~test~~", - "\\*test* hmm": "*test* hmm", -} - -func render(renderer goldmark.Markdown, text string) string { - var buf strings.Builder - err := renderer.Convert([]byte(text), &buf) - if err != nil { - panic(err) - } - return buf.String() -} - -func TestRenderMarkdown_SimpleSpoiler(t *testing.T) { - renderer := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.EscapeHTML), format.HTMLOptions) - for markdown, html := range simpleSpoilerTests { - rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) - assert.Equal(t, html, strings.ReplaceAll(rendered, "\n", "")) - } -} - -var discordUnderlineTests = map[string]string{ - "**test**": "test", - "*test*": "test", - "_test_": "test", - "__test__": "test", - "__*test*__": "test", - "___test___": "test", - "____test____": "test", - "**__test__**": "test", - "__***test***__": "test", - "__~~***test***~~__": "test", - - //"\\__test__": "__test__", - //"\\**test**": "**test**", -} - -func TestRenderMarkdown_DiscordUnderline(t *testing.T) { - renderer := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, mdext.DiscordUnderline, mdext.EscapeHTML), format.HTMLOptions) - for markdown, html := range discordUnderlineTests { - rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) - assert.Equal(t, html, strings.ReplaceAll(rendered, "\n", "")) - } -} - -var mathTests = map[string]string{ - "$foo$": `foo`, - "hello $foo$ world": `hello foo world`, - "$$\nfoo\nbar\n$$": `
          foo
          bar
          `, - "`$foo$`": `$foo$`, - "```\n$foo$\n```": `
          $foo$\n
          `, - "~~meow $foo$ asd~~": `meow foo asd`, - "$5 or $10": `$5 or $10`, - "5$ or 10$": `5$ or 10$`, - "$5 or 10$": `5 or 10`, - "$*500*$": `*500*`, - "$$\n*500*\n$$": `
          *500*
          `, - - // TODO: This doesn't work :( - // Maybe same reason as the spoiler wrapping not working? - //"~~$foo$~~": `foo`, -} - -func TestRenderMarkdown_Math(t *testing.T) { - renderer := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, mdext.Math, mdext.EscapeHTML), format.HTMLOptions) - for markdown, html := range mathTests { - rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) - assert.Equal(t, html, strings.ReplaceAll(rendered, "\n", "\\n"), "with input %q", markdown) - } -} - -var customEmojiTests = map[string]string{ - `![:meow:](mxc://example.com/emoji.png "Emoji: meow")`: `:meow:`, -} - -func TestRenderMarkdown_CustomEmoji(t *testing.T) { - renderer := goldmark.New(goldmark.WithExtensions(mdext.CustomEmoji), format.HTMLOptions) - for markdown, html := range customEmojiTests { - rendered := format.UnwrapSingleParagraph(render(renderer, markdown)) - assert.Equal(t, html, rendered, "with input %q", markdown) - } -} - -var codeTests = map[string]string{ - "meow": "`meow`", - "me`ow": "``me`ow``", - "`me`ow": "`` `me`ow ``", - "me`ow`": "`` me`ow` ``", - "`meow`": "`` `meow` ``", - "`````````": "`````````` ````````` ``````````", -} - -func TestSafeMarkdownCode(t *testing.T) { - for input, expected := range codeTests { - assert.Equal(t, expected, format.SafeMarkdownCode(input), "with input %q", input) - } -} diff --git a/mautrix-patched/format/mdext/customemoji.go b/mautrix-patched/format/mdext/customemoji.go deleted file mode 100644 index a8adad37..00000000 --- a/mautrix-patched/format/mdext/customemoji.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "bytes" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/util" -) - -type extCustomEmoji struct{} -type customEmojiRenderer struct { - funcs functionCapturer -} - -// CustomEmoji is an extension that converts certain markdown images into Matrix custom emojis. -var CustomEmoji = &extCustomEmoji{} - -type functionCapturer struct { - renderImage renderer.NodeRendererFunc - renderText renderer.NodeRendererFunc - renderString renderer.NodeRendererFunc -} - -func (fc *functionCapturer) Register(kind ast.NodeKind, rendererFunc renderer.NodeRendererFunc) { - switch kind { - case ast.KindImage: - fc.renderImage = rendererFunc - case ast.KindText: - fc.renderText = rendererFunc - case ast.KindString: - fc.renderString = rendererFunc - } -} - -var ( - _ renderer.NodeRendererFuncRegisterer = (*functionCapturer)(nil) - _ renderer.Option = (*functionCapturer)(nil) -) - -func (fc *functionCapturer) SetConfig(cfg *renderer.Config) { - cfg.NodeRenderers[0].Value.(renderer.NodeRenderer).RegisterFuncs(fc) -} - -func (eeh *extCustomEmoji) Extend(m goldmark.Markdown) { - var fc functionCapturer - m.Renderer().AddOptions(&fc) - m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(&customEmojiRenderer{fc}, 0))) -} - -func (cer *customEmojiRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(ast.KindImage, cer.renderImage) -} - -var emojiPrefix = []byte("Emoji: ") -var uriSchemeSeparator = []byte("://") - -func (cer *customEmojiRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - n, ok := node.(*ast.Image) - if ok && entering && bytes.HasPrefix(n.Title, emojiPrefix) && bytes.Contains(n.Destination, uriSchemeSeparator) { - n.Title = bytes.TrimPrefix(n.Title, emojiPrefix) - n.SetAttributeString("data-mx-emoticon", nil) - n.SetAttributeString("height", "32") - } - return cer.funcs.renderImage(w, source, node, entering) -} diff --git a/mautrix-patched/format/mdext/discordunderline.go b/mautrix-patched/format/mdext/discordunderline.go deleted file mode 100644 index c52e86d9..00000000 --- a/mautrix-patched/format/mdext/discordunderline.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -type astDiscordUnderline struct { - ast.BaseInline -} - -func (n *astDiscordUnderline) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -var astKindDiscordUnderline = ast.NewNodeKind("DiscordUnderline") - -func (n *astDiscordUnderline) Kind() ast.NodeKind { - return astKindDiscordUnderline -} - -type discordUnderlineDelimiterProcessor struct{} - -func (p *discordUnderlineDelimiterProcessor) IsDelimiter(b byte) bool { - return b == '_' -} - -func (p *discordUnderlineDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { - return opener.Char == closer.Char -} - -func (p *discordUnderlineDelimiterProcessor) OnMatch(consumes int) ast.Node { - if consumes == 1 { - // Slightly hacky hack: if the delimiter parser tries to give us text wrapped with a single underline, - // send it over to the emphasis area instead of returning an underline node. - return ast.NewEmphasis(consumes) - } - return &astDiscordUnderline{} -} - -var defaultDiscordUnderlineDelimiterProcessor = &discordUnderlineDelimiterProcessor{} - -type discordUnderlineParser struct{} - -var defaultDiscordUnderlineParser = &discordUnderlineParser{} - -// NewDiscordUnderlineParser return a new InlineParser that parses -// Discord underline expressions. -func NewDiscordUnderlineParser() parser.InlineParser { - return defaultDiscordUnderlineParser -} - -func (s *discordUnderlineParser) Trigger() []byte { - return []byte{'_'} -} - -func (s *discordUnderlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - before := block.PrecendingCharacter() - line, segment := block.PeekLine() - node := parser.ScanDelimiter(line, before, 2, defaultDiscordUnderlineDelimiterProcessor) - if node == nil { - return nil - } - node.Segment = segment.WithStop(segment.Start + node.OriginalLength) - block.Advance(node.OriginalLength) - pc.PushDelimiter(node) - return node -} - -func (s *discordUnderlineParser) CloseBlock(parent ast.Node, pc parser.Context) { - // nothing to do -} - -// discordUnderlineHTMLRenderer is a renderer.NodeRenderer implementation that -// renders discord underline nodes. -type discordUnderlineHTMLRenderer struct { - html.Config -} - -// NewDiscordUnderlineHTMLRenderer returns a new discordUnderlineHTMLRenderer. -func NewDiscordUnderlineHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { - r := &discordUnderlineHTMLRenderer{ - Config: html.NewConfig(), - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - return r -} - -func (r *discordUnderlineHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(astKindDiscordUnderline, r.renderDiscordUnderline) -} - -var DiscordUnderlineAttributeFilter = html.GlobalAttributeFilter - -func (r *discordUnderlineHTMLRenderer) renderDiscordUnderline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - if n.Attributes() != nil { - _, _ = w.WriteString("') - } else { - _, _ = w.WriteString("") - } - } else { - _, _ = w.WriteString("") - } - return ast.WalkContinue, nil -} - -type discordUnderline struct{} - -// DiscordUnderline is an extension that allow you to use underline expression like '__text__' . -var DiscordUnderline = &discordUnderline{} - -func (e *discordUnderline) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - // This must be a higher priority (= lower priority number) than the emphasis parser - // in https://github.com/yuin/goldmark/blob/v1.4.12/parser/parser.go#L601 - util.Prioritized(NewDiscordUnderlineParser(), 450), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(NewDiscordUnderlineHTMLRenderer(), 500), - )) -} diff --git a/mautrix-patched/format/mdext/filteredparser.go b/mautrix-patched/format/mdext/filteredparser.go deleted file mode 100644 index 3b6a4153..00000000 --- a/mautrix-patched/format/mdext/filteredparser.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "reflect" - - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/util" -) - -func filterParsers(list []util.PrioritizedValue, forbidden map[reflect.Type]struct{}) []util.PrioritizedValue { - n := 0 - for _, item := range list { - if _, isForbidden := forbidden[reflect.TypeOf(item.Value)]; isForbidden { - continue - } - list[n] = item - n++ - } - return list[:n] -} - -// ParserWithoutFeatures returns a Goldmark parser with the provided default features removed. -// -// e.g. to disable lists, use -// -// markdown := goldmark.New(goldmark.WithParser( -// mdext.ParserWithoutFeatures(goldmark.NewListParser(), goldmark.NewListItemParser()) -// )) -func ParserWithoutFeatures(features ...any) parser.Parser { - forbiddenTypes := make(map[reflect.Type]struct{}, len(features)) - for _, feature := range features { - forbiddenTypes[reflect.TypeOf(feature)] = struct{}{} - } - filteredBlockParsers := filterParsers(parser.DefaultBlockParsers(), forbiddenTypes) - filteredInlineParsers := filterParsers(parser.DefaultInlineParsers(), forbiddenTypes) - filteredParagraphTransformers := filterParsers(parser.DefaultParagraphTransformers(), forbiddenTypes) - return parser.NewParser( - parser.WithBlockParsers(filteredBlockParsers...), - parser.WithInlineParsers(filteredInlineParsers...), - parser.WithParagraphTransformers(filteredParagraphTransformers...), - ) -} diff --git a/mautrix-patched/format/mdext/indentableparagraph.go b/mautrix-patched/format/mdext/indentableparagraph.go deleted file mode 100644 index a6ebd6c0..00000000 --- a/mautrix-patched/format/mdext/indentableparagraph.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/util" -) - -// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine. -// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear). -type indentableParagraphParser struct { - parser.BlockParser -} - -var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()} - -func (b *indentableParagraphParser) CanAcceptIndentedLine() bool { - return true -} - -// FixIndentedParagraphs is a goldmark option which fixes indented paragraphs when disabling CodeBlockParser. -var FixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500))) diff --git a/mautrix-patched/format/mdext/math.go b/mautrix-patched/format/mdext/math.go deleted file mode 100644 index e6a6ecc5..00000000 --- a/mautrix-patched/format/mdext/math.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "bytes" - "fmt" - stdhtml "html" - "regexp" - "strings" - "unicode" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -var astKindMath = ast.NewNodeKind("Math") - -type astMath struct { - ast.BaseInline - value []byte -} - -func (n *astMath) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -func (n *astMath) Kind() ast.NodeKind { - return astKindMath -} - -type astMathBlock struct { - ast.BaseBlock -} - -func (n *astMathBlock) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -func (n *astMathBlock) Kind() ast.NodeKind { - return astKindMath -} - -type inlineMathParser struct{} - -var defaultInlineMathParser = &inlineMathParser{} - -func NewInlineMathParser() parser.InlineParser { - return defaultInlineMathParser -} - -const mathDelimiter = '$' - -func (s *inlineMathParser) Trigger() []byte { - return []byte{mathDelimiter} -} - -// This ignores lines where there's no space after the closing $ to avoid false positives -var latexInlineRegexp = regexp.MustCompile(`^(\$[^$]*\$)(?:$|\s)`) - -func (s *inlineMathParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - before := block.PrecendingCharacter() - // Ignore lines where the opening $ comes after a letter or number to avoid false positives - if unicode.IsLetter(before) || unicode.IsNumber(before) { - return nil - } - line, segment := block.PeekLine() - idx := latexInlineRegexp.FindSubmatchIndex(line) - if idx == nil { - return nil - } - block.Advance(idx[3]) - return &astMath{ - value: block.Value(text.NewSegment(segment.Start+1, segment.Start+idx[3]-1)), - } -} - -func (s *inlineMathParser) CloseBlock(parent ast.Node, pc parser.Context) { - // nothing to do -} - -type blockMathParser struct{} - -var defaultBlockMathParser = &blockMathParser{} - -func NewBlockMathParser() parser.BlockParser { - return defaultBlockMathParser -} - -var mathBlockInfoKey = parser.NewContextKey() - -type mathBlockData struct { - indent int - length int - node ast.Node -} - -func (b *blockMathParser) Trigger() []byte { - return []byte{'$'} -} - -func (b *blockMathParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { - line, _ := reader.PeekLine() - pos := pc.BlockOffset() - if pos < 0 || (line[pos] != mathDelimiter) { - return nil, parser.NoChildren - } - findent := pos - i := pos - for ; i < len(line) && line[i] == mathDelimiter; i++ { - } - oFenceLength := i - pos - if oFenceLength < 2 { - return nil, parser.NoChildren - } - if i < len(line)-1 { - rest := line[i:] - left := util.TrimLeftSpaceLength(rest) - right := util.TrimRightSpaceLength(rest) - if left < len(rest)-right { - value := rest[left : len(rest)-right] - if bytes.IndexByte(value, mathDelimiter) > -1 { - return nil, parser.NoChildren - } - } - } - node := &astMathBlock{} - pc.Set(mathBlockInfoKey, &mathBlockData{findent, oFenceLength, node}) - return node, parser.NoChildren - -} - -func (b *blockMathParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { - line, segment := reader.PeekLine() - fdata := pc.Get(mathBlockInfoKey).(*mathBlockData) - - w, pos := util.IndentWidth(line, reader.LineOffset()) - if w < 4 { - i := pos - for ; i < len(line) && line[i] == mathDelimiter; i++ { - } - length := i - pos - if length >= fdata.length && util.IsBlank(line[i:]) { - newline := 1 - if line[len(line)-1] != '\n' { - newline = 0 - } - reader.Advance(segment.Stop - segment.Start - newline + segment.Padding) - return parser.Close - } - } - pos, padding := util.IndentPositionPadding(line, reader.LineOffset(), segment.Padding, fdata.indent) - if pos < 0 { - pos = util.FirstNonSpacePosition(line) - if pos < 0 { - pos = 0 - } - padding = 0 - } - seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding) - seg.ForceNewline = true // EOF as newline - node.Lines().Append(seg) - reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding) - return parser.Continue | parser.NoChildren -} - -func (b *blockMathParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { - fdata := pc.Get(mathBlockInfoKey).(*mathBlockData) - if fdata.node == node { - pc.Set(mathBlockInfoKey, nil) - } -} - -func (b *blockMathParser) CanInterruptParagraph() bool { - return true -} - -func (b *blockMathParser) CanAcceptIndentedLine() bool { - return false -} - -type mathHTMLRenderer struct { - html.Config -} - -func NewMathHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { - r := &mathHTMLRenderer{ - Config: html.NewConfig(), - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - return r -} - -func (r *mathHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(astKindMath, r.renderMath) -} - -func (r *mathHTMLRenderer) renderMath(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - tag := "span" - var tex string - switch typed := n.(type) { - case *astMathBlock: - tag = "div" - tex = string(n.Lines().Value(source)) - case *astMath: - tex = string(typed.value) - } - tex = stdhtml.EscapeString(strings.TrimSpace(tex)) - _, _ = fmt.Fprintf(w, `<%s data-mx-maths="%s">%s`, tag, tex, strings.ReplaceAll(tex, "\n", "
          "), tag) - } - return ast.WalkSkipChildren, nil -} - -type math struct{} - -// Math is an extension that allow you to use math like '$$text$$'. -var Math = &math{} - -func (e *math) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(NewInlineMathParser(), 500), - ), parser.WithBlockParsers( - util.Prioritized(NewBlockMathParser(), 850), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(NewMathHTMLRenderer(), 500), - )) -} diff --git a/mautrix-patched/format/mdext/nohtml.go b/mautrix-patched/format/mdext/nohtml.go deleted file mode 100644 index bee69828..00000000 --- a/mautrix-patched/format/mdext/nohtml.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/util" -) - -type extEscapeHTML struct{} -type escapingHTMLRenderer struct{} - -// EscapeHTML is an extension that escapes HTML in the input markdown instead of passing it through as-is. -var EscapeHTML = &extEscapeHTML{} -var defaultEHR = &escapingHTMLRenderer{} - -func (eeh *extEscapeHTML) Extend(m goldmark.Markdown) { - m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultEHR, 0))) -} - -func (ehr *escapingHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(ast.KindHTMLBlock, ehr.renderHTMLBlock) - reg.Register(ast.KindRawHTML, ehr.renderRawHTML) -} - -func (ehr *escapingHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkSkipChildren, nil - } - n := node.(*ast.RawHTML) - l := n.Segments.Len() - for i := 0; i < l; i++ { - segment := n.Segments.At(i) - html.DefaultWriter.RawWrite(w, segment.Value(source)) - } - return ast.WalkSkipChildren, nil -} - -func (ehr *escapingHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - n := node.(*ast.HTMLBlock) - if entering { - l := n.Lines().Len() - for i := 0; i < l; i++ { - line := n.Lines().At(i) - html.DefaultWriter.RawWrite(w, line.Value(source)) - } - } else { - if n.HasClosure() { - closure := n.ClosureLine - html.DefaultWriter.RawWrite(w, closure.Value(source)) - } - } - return ast.WalkContinue, nil -} diff --git a/mautrix-patched/format/mdext/shortemphasis.go b/mautrix-patched/format/mdext/shortemphasis.go deleted file mode 100644 index 62190326..00000000 --- a/mautrix-patched/format/mdext/shortemphasis.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -var ShortEmphasis goldmark.Extender = &shortEmphasisExtender{} - -type shortEmphasisExtender struct{} - -func (s *shortEmphasisExtender) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(&italicsParser{}, 500), - util.Prioritized(&boldParser{}, 500), - )) -} - -type italicsDelimiterProcessor struct{} - -func (p *italicsDelimiterProcessor) IsDelimiter(b byte) bool { - return b == '_' -} - -func (p *italicsDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { - return opener.Char == closer.Char -} - -func (p *italicsDelimiterProcessor) OnMatch(consumes int) ast.Node { - return ast.NewEmphasis(1) -} - -var defaultItalicsDelimiterProcessor = &italicsDelimiterProcessor{} - -type italicsParser struct{} - -func (s *italicsParser) Trigger() []byte { - return []byte{'_'} -} - -func (s *italicsParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - before := block.PrecendingCharacter() - line, segment := block.PeekLine() - node := parser.ScanDelimiter(line, before, 1, defaultItalicsDelimiterProcessor) - if node == nil || node.OriginalLength > 1 || before == '_' { - return nil - } - node.Segment = segment.WithStop(segment.Start + node.OriginalLength) - block.Advance(node.OriginalLength) - pc.PushDelimiter(node) - return node -} - -type boldDelimiterProcessor struct{} - -func (p *boldDelimiterProcessor) IsDelimiter(b byte) bool { - return b == '*' -} - -func (p *boldDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { - return opener.Char == closer.Char -} - -func (p *boldDelimiterProcessor) OnMatch(consumes int) ast.Node { - return ast.NewEmphasis(2) -} - -var defaultBoldDelimiterProcessor = &boldDelimiterProcessor{} - -type boldParser struct{} - -func (s *boldParser) Trigger() []byte { - return []byte{'*'} -} - -func (s *boldParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - before := block.PrecendingCharacter() - line, segment := block.PeekLine() - node := parser.ScanDelimiter(line, before, 1, defaultBoldDelimiterProcessor) - if node == nil || node.OriginalLength > 1 || before == '*' { - return nil - } - node.Segment = segment.WithStop(segment.Start + node.OriginalLength) - block.Advance(node.OriginalLength) - pc.PushDelimiter(node) - return node -} diff --git a/mautrix-patched/format/mdext/shortstrike.go b/mautrix-patched/format/mdext/shortstrike.go deleted file mode 100644 index 00328f22..00000000 --- a/mautrix-patched/format/mdext/shortstrike.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "github.com/yuin/goldmark" - gast "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/extension/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -var ShortStrike goldmark.Extender = &shortStrikeExtender{length: 1} -var LongStrike goldmark.Extender = &shortStrikeExtender{length: 2} - -type shortStrikeExtender struct { - length int -} - -func (s *shortStrikeExtender) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(&strikethroughParser{length: s.length}, 500), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(extension.NewStrikethroughHTMLRenderer(), 500), - )) -} - -type strikethroughDelimiterProcessor struct{} - -func (p *strikethroughDelimiterProcessor) IsDelimiter(b byte) bool { - return b == '~' -} - -func (p *strikethroughDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { - return opener.Char == closer.Char -} - -func (p *strikethroughDelimiterProcessor) OnMatch(consumes int) gast.Node { - return ast.NewStrikethrough() -} - -var defaultStrikethroughDelimiterProcessor = &strikethroughDelimiterProcessor{} - -type strikethroughParser struct { - length int -} - -func (s *strikethroughParser) Trigger() []byte { - return []byte{'~'} -} - -func (s *strikethroughParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { - before := block.PrecendingCharacter() - line, segment := block.PeekLine() - node := parser.ScanDelimiter(line, before, 1, defaultStrikethroughDelimiterProcessor) - if node == nil || node.OriginalLength != s.length || before == '~' { - return nil - } - - node.Segment = segment.WithStop(segment.Start + node.OriginalLength) - block.Advance(node.OriginalLength) - pc.PushDelimiter(node) - return node -} - -func (s *strikethroughParser) CloseBlock(parent gast.Node, pc parser.Context) { - // nothing to do -} diff --git a/mautrix-patched/format/mdext/simplespoiler.go b/mautrix-patched/format/mdext/simplespoiler.go deleted file mode 100644 index a72ad8cd..00000000 --- a/mautrix-patched/format/mdext/simplespoiler.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -type simpleSpoilerParser struct{} - -var defaultSimpleSpoilerParser = &simpleSpoilerParser{} - -func NewSimpleSpoilerParser() parser.InlineParser { - return defaultSimpleSpoilerParser -} - -func (s *simpleSpoilerParser) Trigger() []byte { - return []byte{'|'} -} - -func (s *simpleSpoilerParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - // This is basically copied from https://github.com/yuin/goldmark/blob/master/extension/strikethrough.go - before := block.PrecendingCharacter() - line, segment := block.PeekLine() - node := parser.ScanDelimiter(line, before, 2, defaultSpoilerDelimiterProcessor) - if node == nil { - return nil - } - node.Segment = segment.WithStop(segment.Start + node.OriginalLength) - block.Advance(node.OriginalLength) - pc.PushDelimiter(node) - return node -} - -func (s *simpleSpoilerParser) CloseBlock(parent ast.Node, pc parser.Context) { - // nothing to do -} - -type simpleSpoiler struct{} - -// SimpleSpoiler is an extension that allow you to use simple spoiler expression like '||text||' . -// -// For spoilers with reasons ('||reason|text||'), use the Spoiler extension. -var SimpleSpoiler = &simpleSpoiler{} - -func (e *simpleSpoiler) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(NewSimpleSpoilerParser(), 500), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(NewSpoilerHTMLRenderer(), 500), - )) -} diff --git a/mautrix-patched/format/mdext/spoiler.go b/mautrix-patched/format/mdext/spoiler.go deleted file mode 100644 index bd2f6d2e..00000000 --- a/mautrix-patched/format/mdext/spoiler.go +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mdext - -import ( - "bytes" - "fmt" - stdhtml "html" - "regexp" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -var astKindSpoiler = ast.NewNodeKind("Spoiler") - -type astSpoiler struct { - ast.BaseInline - reason string -} - -func (n *astSpoiler) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -func (n *astSpoiler) Kind() ast.NodeKind { - return astKindSpoiler -} - -type spoilerDelimiterProcessor struct{} - -var defaultSpoilerDelimiterProcessor = &spoilerDelimiterProcessor{} - -func (p *spoilerDelimiterProcessor) IsDelimiter(b byte) bool { - return b == '|' -} - -func (p *spoilerDelimiterProcessor) CanOpenCloser(opener, closer *parser.Delimiter) bool { - return opener.Char == closer.Char -} - -func (p *spoilerDelimiterProcessor) OnMatch(consumes int) ast.Node { - return &astSpoiler{} -} - -type spoilerParser struct{} - -var defaultSpoilerParser = &spoilerParser{} - -func NewSpoilerParser() parser.InlineParser { - return defaultSpoilerParser -} - -func (s *spoilerParser) Trigger() []byte { - return []byte{'|'} -} - -var spoilerRegex = regexp.MustCompile(`^\|\|(?:([^|]+?)\|[^|])?`) -var spoilerContextKey = parser.NewContextKey() - -type spoilerContext struct { - reason string - segment text.Segment - bottom *parser.Delimiter -} - -func (s *spoilerParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - line, segment := block.PeekLine() - if spoiler, ok := pc.Get(spoilerContextKey).(spoilerContext); ok { - if !bytes.HasPrefix(line, []byte("||")) { - return nil - } - block.Advance(2) - pc.Set(spoilerContextKey, nil) - n := &astSpoiler{ - BaseInline: ast.BaseInline{}, - reason: spoiler.reason, - } - parser.ProcessDelimiters(spoiler.bottom, pc) - var c ast.Node = spoiler.bottom - for c != nil { - next := c.NextSibling() - parent.RemoveChild(parent, c) - n.AppendChild(n, c) - c = next - } - return n - } - match := spoilerRegex.FindSubmatch(line) - if match == nil { - return nil - } - length := 2 - reason := string(match[1]) - if len(reason) > 0 { - length += len(match[1]) + 1 - } - block.Advance(length) - delim := parser.NewDelimiter(true, false, length, '|', defaultSpoilerDelimiterProcessor) - pc.Set(spoilerContextKey, spoilerContext{ - reason: reason, - segment: segment, - bottom: delim, - }) - return delim -} - -func (s *spoilerParser) CloseBlock(parent ast.Node, pc parser.Context) { - // nothing to do -} - -type spoilerHTMLRenderer struct { - html.Config -} - -func NewSpoilerHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { - r := &spoilerHTMLRenderer{ - Config: html.NewConfig(), - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - return r -} - -func (r *spoilerHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(astKindSpoiler, r.renderSpoiler) -} - -func (r *spoilerHTMLRenderer) renderSpoiler(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - node := n.(*astSpoiler) - if len(node.reason) == 0 { - _, _ = w.WriteString("") - } else { - _, _ = fmt.Fprintf(w, ``, stdhtml.EscapeString(node.reason)) - } - } else { - _, _ = w.WriteString("") - } - return ast.WalkContinue, nil -} - -type extSpoiler struct{} - -// Spoiler is an extension that allow you to use spoiler expression like '||text||' or ||reason|text|| . -// -// There are some types of nested formatting that aren't supported with advanced spoilers. -// The SimpleSpoiler extension that doesn't support reasons can be used to work around those. -var Spoiler = &extSpoiler{} - -func (e *extSpoiler) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(NewSpoilerParser(), 500), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(NewSpoilerHTMLRenderer(), 500), - )) -} diff --git a/mautrix-patched/go.mod b/mautrix-patched/go.mod deleted file mode 100644 index 757d9d9a..00000000 --- a/mautrix-patched/go.mod +++ /dev/null @@ -1,42 +0,0 @@ -module maunium.net/go/mautrix - -go 1.25.0 - -toolchain go1.26.4 - -require ( - filippo.io/edwards25519 v1.2.0 - github.com/chzyer/readline v1.5.1 - github.com/coder/websocket v1.8.15 - github.com/lib/pq v1.12.3 - github.com/mattn/go-sqlite3 v1.14.45 - github.com/rs/xid v1.6.0 - github.com/rs/zerolog v1.35.1 - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/stretchr/testify v1.11.1 - github.com/tidwall/gjson v1.19.0 - github.com/tidwall/sjson v1.2.5 - github.com/yuin/goldmark v1.8.2 - go.mau.fi/util v0.9.10 - go.mau.fi/zeroconfig v0.2.0 - golang.org/x/crypto v0.53.0 - golang.org/x/exp v0.0.0-20260611194520-c48552f49976 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mauflag v1.0.0 -) - -require ( - github.com/coreos/go-systemd/v22 v22.7.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect -) diff --git a/mautrix-patched/go.sum b/mautrix-patched/go.sum deleted file mode 100644 index 3f8cd0a7..00000000 --- a/mautrix-patched/go.sum +++ /dev/null @@ -1,74 +0,0 @@ -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= -github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= -github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= -github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= -github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= -github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= -github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= -github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= -github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= -github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= -go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= -go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= -go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= -maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= diff --git a/mautrix-patched/id/contenturi.go b/mautrix-patched/id/contenturi.go deleted file mode 100644 index 67127b6c..00000000 --- a/mautrix-patched/id/contenturi.go +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "bytes" - "database/sql/driver" - "encoding/json" - "errors" - "fmt" - "regexp" - "strings" -) - -var ( - ErrInvalidContentURI = errors.New("invalid Matrix content URI") - ErrInputNotJSONString = errors.New("input doesn't look like a JSON string") -) - -// Deprecated: use variables prefixed with Err -var ( - InvalidContentURI = ErrInvalidContentURI - InputNotJSONString = ErrInputNotJSONString -) - -// ContentURIString is a string that's expected to be a Matrix content URI. -// It's useful for delaying the parsing of the content URI to move errors from the event content -// JSON parsing step to a later step where more appropriate errors can be produced. -type ContentURIString string - -func (uriString ContentURIString) Parse() (ContentURI, error) { - return ParseContentURI(string(uriString)) -} - -func (uriString ContentURIString) ParseOrIgnore() ContentURI { - parsed, _ := ParseContentURI(string(uriString)) - return parsed -} - -// ContentURI represents a Matrix content URI. -// https://spec.matrix.org/v1.2/client-server-api/#matrix-content-mxc-uris -type ContentURI struct { - Homeserver string - FileID string -} - -func MustParseContentURI(uri string) ContentURI { - parsed, err := ParseContentURI(uri) - if err != nil { - panic(err) - } - return parsed -} - -// ParseContentURI parses a Matrix content URI. -func ParseContentURI(uri string) (parsed ContentURI, err error) { - if len(uri) == 0 { - return - } else if !strings.HasPrefix(uri, "mxc://") { - err = ErrInvalidContentURI - } else if index := strings.IndexRune(uri[6:], '/'); index == -1 || index == len(uri)-7 { - err = ErrInvalidContentURI - } else { - parsed.Homeserver = uri[6 : 6+index] - parsed.FileID = uri[6+index+1:] - } - return -} - -var mxcBytes = []byte("mxc://") - -func ParseContentURIBytes(uri []byte) (parsed ContentURI, err error) { - if len(uri) == 0 { - return - } else if !bytes.HasPrefix(uri, mxcBytes) { - err = ErrInvalidContentURI - } else if index := bytes.IndexRune(uri[6:], '/'); index == -1 || index == len(uri)-7 { - err = ErrInvalidContentURI - } else { - parsed.Homeserver = string(uri[6 : 6+index]) - parsed.FileID = string(uri[6+index+1:]) - } - return -} - -func (uri *ContentURI) UnmarshalJSON(raw []byte) (err error) { - if string(raw) == "null" { - *uri = ContentURI{} - return nil - } else if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { - return fmt.Errorf("ContentURI: %w", ErrInputNotJSONString) - } - parsed, err := ParseContentURIBytes(raw[1 : len(raw)-1]) - if err != nil { - return err - } - *uri = parsed - return nil -} - -func (uri *ContentURI) MarshalJSON() ([]byte, error) { - if uri == nil || uri.IsEmpty() { - return []byte("null"), nil - } - return json.Marshal(uri.String()) -} - -func (uri *ContentURI) UnmarshalText(raw []byte) (err error) { - parsed, err := ParseContentURIBytes(raw) - if err != nil { - return err - } - *uri = parsed - return nil -} - -func (uri ContentURI) MarshalText() ([]byte, error) { - return []byte(uri.String()), nil -} - -func (uri *ContentURI) Scan(i interface{}) error { - var parsed ContentURI - var err error - switch value := i.(type) { - case nil: - // don't do anything, set uri to empty - case []byte: - parsed, err = ParseContentURIBytes(value) - case string: - parsed, err = ParseContentURI(value) - default: - return fmt.Errorf("invalid type %T for ContentURI.Scan", i) - } - if err != nil { - return err - } - *uri = parsed - return nil -} - -func (uri *ContentURI) Value() (driver.Value, error) { - if uri == nil { - return nil, nil - } - return uri.String(), nil -} - -func (uri ContentURI) String() string { - if uri.IsEmpty() { - return "" - } - return fmt.Sprintf("mxc://%s/%s", uri.Homeserver, uri.FileID) -} - -func (uri ContentURI) CUString() ContentURIString { - return ContentURIString(uri.String()) -} - -func (uri ContentURI) IsEmpty() bool { - return len(uri.Homeserver) == 0 || len(uri.FileID) == 0 -} - -var simpleHomeserverRegex = regexp.MustCompile(`^[a-zA-Z0-9.:-]+$`) - -func (uri ContentURI) IsValid() bool { - return IsValidMediaID(uri.FileID) && uri.Homeserver != "" && simpleHomeserverRegex.MatchString(uri.Homeserver) -} - -func IsValidMediaID(mediaID string) bool { - if len(mediaID) == 0 { - return false - } - for _, char := range mediaID { - if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' && char != '_' { - return false - } - } - return true -} diff --git a/mautrix-patched/id/crypto.go b/mautrix-patched/id/crypto.go deleted file mode 100644 index 5ac0252b..00000000 --- a/mautrix-patched/id/crypto.go +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "encoding/base64" - "fmt" - "strings" - - "go.mau.fi/util/random" -) - -// OlmMsgType is an Olm message type -type OlmMsgType int - -const ( - OlmMsgTypePreKey OlmMsgType = 0 - OlmMsgTypeMsg OlmMsgType = 1 -) - -// Algorithm is a Matrix message encryption algorithm. -// https://spec.matrix.org/v1.2/client-server-api/#messaging-algorithm-names -type Algorithm string - -const ( - AlgorithmOlmV1 Algorithm = "m.olm.v1.curve25519-aes-sha2" - AlgorithmMegolmV1 Algorithm = "m.megolm.v1.aes-sha2" - AlgorithmBeeperStreamV1 Algorithm = "com.beeper.stream.v1.aes-gcm" -) - -type KeyAlgorithm string - -const ( - KeyAlgorithmCurve25519 KeyAlgorithm = "curve25519" - KeyAlgorithmEd25519 KeyAlgorithm = "ed25519" - KeyAlgorithmSignedCurve25519 KeyAlgorithm = "signed_curve25519" -) - -type CrossSigningUsage string - -const ( - XSUsageMaster CrossSigningUsage = "master" - XSUsageSelfSigning CrossSigningUsage = "self_signing" - XSUsageUserSigning CrossSigningUsage = "user_signing" -) - -type KeyBackupAlgorithm string - -const ( - KeyBackupAlgorithmMegolmBackupV1 KeyBackupAlgorithm = "m.megolm_backup.v1.curve25519-aes-sha2" -) - -type KeySource string - -func (source KeySource) String() string { - return string(source) -} - -func (source KeySource) Int() int { - switch source { - case KeySourceDirect: - return 100 - case KeySourceBackup: - return 90 - case KeySourceImport: - return 80 - case KeySourceForward: - return 50 - default: - return 0 - } -} - -const ( - KeySourceDirect KeySource = "direct" - KeySourceBackup KeySource = "backup" - KeySourceImport KeySource = "import" - KeySourceForward KeySource = "forward" -) - -// BackupVersion is an arbitrary string that identifies a server side key backup. -type KeyBackupVersion string - -func (version KeyBackupVersion) String() string { - return string(version) -} - -// A SessionID is an arbitrary string that identifies an Olm or Megolm session. -type SessionID string - -func (sessionID SessionID) String() string { - return string(sessionID) -} - -// Ed25519 is the base64 representation of an Ed25519 public key -type Ed25519 string -type SigningKey = Ed25519 - -func (ed25519 Ed25519) String() string { - return string(ed25519) -} - -func (ed25519 Ed25519) Bytes() []byte { - val, _ := base64.RawStdEncoding.DecodeString(string(ed25519)) - // TODO handle errors - return val -} - -func (ed25519 Ed25519) Fingerprint() string { - spacedSigningKey := make([]byte, len(ed25519)+(len(ed25519)-1)/4) - var ptr = 0 - for i, chr := range ed25519 { - spacedSigningKey[ptr] = byte(chr) - ptr++ - if i%4 == 3 { - spacedSigningKey[ptr] = ' ' - ptr++ - } - } - return string(spacedSigningKey) -} - -// Curve25519 is the base64 representation of an Curve25519 public key -type Curve25519 string -type SenderKey = Curve25519 -type IdentityKey = Curve25519 - -func (curve25519 Curve25519) String() string { - return string(curve25519) -} - -func (curve25519 Curve25519) Bytes() []byte { - val, _ := base64.RawStdEncoding.DecodeString(string(curve25519)) - // TODO handle errors - return val -} - -// A DeviceID is an arbitrary string that references a specific device. -type DeviceID string - -func (deviceID DeviceID) String() string { - return string(deviceID) -} - -// A DeviceKeyID is a string formatted as : that is used as the key in deviceid-key mappings. -type DeviceKeyID string - -func NewDeviceKeyID(algorithm KeyAlgorithm, deviceID DeviceID) DeviceKeyID { - return DeviceKeyID(fmt.Sprintf("%s:%s", algorithm, deviceID)) -} - -func (deviceKeyID DeviceKeyID) String() string { - return string(deviceKeyID) -} - -func (deviceKeyID DeviceKeyID) Parse() (Algorithm, DeviceID) { - index := strings.IndexRune(string(deviceKeyID), ':') - if index < 0 || len(deviceKeyID) <= index+1 { - return "", "" - } - return Algorithm(deviceKeyID[:index]), DeviceID(deviceKeyID[index+1:]) -} - -// A KeyID a string formatted as : that is used as the key in one-time-key mappings. -type KeyID string - -func NewKeyID(algorithm KeyAlgorithm, keyID string) KeyID { - return KeyID(fmt.Sprintf("%s:%s", algorithm, keyID)) -} - -func (keyID KeyID) String() string { - return string(keyID) -} - -func (keyID KeyID) Parse() (KeyAlgorithm, string) { - index := strings.IndexRune(string(keyID), ':') - if index < 0 || len(keyID) <= index+1 { - return "", "" - } - return KeyAlgorithm(keyID[:index]), string(keyID[index+1:]) -} - -// Device contains the identity details of a device and some additional info. -type Device struct { - UserID UserID - DeviceID DeviceID - IdentityKey Curve25519 - SigningKey Ed25519 - - Trust TrustState - Deleted bool - Name string -} - -func (device *Device) Fingerprint() string { - return device.SigningKey.Fingerprint() -} - -type CrossSigningKey struct { - Key Ed25519 - First Ed25519 -} - -// Secret storage keys -type Secret string - -func (s Secret) String() string { - return string(s) -} - -const ( - SecretXSMaster Secret = "m.cross_signing.master" - SecretXSSelfSigning Secret = "m.cross_signing.self_signing" - SecretXSUserSigning Secret = "m.cross_signing.user_signing" - SecretMegolmBackupV1 Secret = "m.megolm_backup.v1" -) - -// VerificationTransactionID is a unique identifier for a verification -// transaction. -type VerificationTransactionID string - -func NewVerificationTransactionID() VerificationTransactionID { - return VerificationTransactionID(random.String(32)) -} - -func (t VerificationTransactionID) String() string { - return string(t) -} diff --git a/mautrix-patched/id/matrixuri.go b/mautrix-patched/id/matrixuri.go deleted file mode 100644 index d5c78bc7..00000000 --- a/mautrix-patched/id/matrixuri.go +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "errors" - "fmt" - "net/url" - "strings" -) - -// Errors that can happen when parsing matrix: URIs -var ( - ErrInvalidScheme = errors.New("matrix URI scheme must be exactly 'matrix'") - ErrInvalidPartCount = errors.New("matrix URIs must have exactly 2 or 4 segments") - ErrInvalidFirstSegment = errors.New("invalid identifier in first segment of matrix URI") - ErrEmptySecondSegment = errors.New("the second segment of the matrix URI must not be empty") - ErrInvalidThirdSegment = errors.New("invalid identifier in third segment of matrix URI") - ErrEmptyFourthSegment = errors.New("the fourth segment of the matrix URI must not be empty when the third segment is present") -) - -// Errors that can happen when parsing matrix.to URLs -var ( - ErrNotMatrixTo = errors.New("that URL is not a matrix.to URL") - ErrInvalidMatrixToPartCount = errors.New("matrix.to URLs must have exactly 1 or 2 segments") - ErrEmptyMatrixToPrimaryIdentifier = errors.New("the primary identifier in the matrix.to URL is empty") - ErrInvalidMatrixToPrimaryIdentifier = errors.New("the primary identifier in the matrix.to URL has an invalid sigil") - ErrInvalidMatrixToSecondaryIdentifier = errors.New("the secondary identifier in the matrix.to URL has an invalid sigil") -) - -var ErrNotMatrixToOrMatrixURI = errors.New("that URL is not a matrix.to URL nor matrix: URI") - -// MatrixURI contains the result of parsing a matrix: URI using ParseMatrixURI -type MatrixURI struct { - Sigil1 rune - Sigil2 rune - MXID1 string - MXID2 string - Via []string - Action string -} - -// SigilToPathSegment contains a mapping from Matrix identifier sigils to matrix: URI path segments. -var SigilToPathSegment = map[rune]string{ - '$': "e", - '#': "r", - '!': "roomid", - '@': "u", -} - -func (uri *MatrixURI) getQuery() url.Values { - q := make(url.Values) - if len(uri.Via) > 0 { - q["via"] = uri.Via - } - if len(uri.Action) > 0 { - q.Set("action", uri.Action) - } - return q -} - -// String converts the parsed matrix: URI back into the string representation. -func (uri *MatrixURI) String() string { - if uri == nil { - return "" - } - parts := []string{ - SigilToPathSegment[uri.Sigil1], - url.PathEscape(uri.MXID1), - } - if uri.Sigil2 != 0 { - parts = append(parts, SigilToPathSegment[uri.Sigil2], url.PathEscape(uri.MXID2)) - } - return (&url.URL{ - Scheme: "matrix", - Opaque: strings.Join(parts, "/"), - RawQuery: uri.getQuery().Encode(), - }).String() -} - -// MatrixToURL converts to parsed matrix: URI into a matrix.to URL -func (uri *MatrixURI) MatrixToURL() string { - if uri == nil { - return "" - } - fragment := fmt.Sprintf("#/%s", url.PathEscape(uri.PrimaryIdentifier())) - if uri.Sigil2 != 0 { - fragment = fmt.Sprintf("%s/%s", fragment, url.PathEscape(uri.SecondaryIdentifier())) - } - query := uri.getQuery().Encode() - if len(query) > 0 { - fragment = fmt.Sprintf("%s?%s", fragment, query) - } - // It would be nice to use URL{...}.String() here, but figuring out the Fragment vs RawFragment stuff is a pain - return fmt.Sprintf("https://matrix.to/%s", fragment) -} - -// PrimaryIdentifier returns the first Matrix identifier in the URI. -// Currently room IDs, room aliases and user IDs can be in the primary identifier slot. -func (uri *MatrixURI) PrimaryIdentifier() string { - if uri == nil { - return "" - } - return fmt.Sprintf("%c%s", uri.Sigil1, uri.MXID1) -} - -// SecondaryIdentifier returns the second Matrix identifier in the URI. -// Currently only event IDs can be in the secondary identifier slot. -func (uri *MatrixURI) SecondaryIdentifier() string { - if uri == nil || uri.Sigil2 == 0 { - return "" - } - return fmt.Sprintf("%c%s", uri.Sigil2, uri.MXID2) -} - -// UserID returns the user ID from the URI if the primary identifier is a user ID. -func (uri *MatrixURI) UserID() UserID { - if uri != nil && uri.Sigil1 == '@' { - return UserID(uri.PrimaryIdentifier()) - } - return "" -} - -// RoomID returns the room ID from the URI if the primary identifier is a room ID. -func (uri *MatrixURI) RoomID() RoomID { - if uri != nil && uri.Sigil1 == '!' { - return RoomID(uri.PrimaryIdentifier()) - } - return "" -} - -// RoomAlias returns the room alias from the URI if the primary identifier is a room alias. -func (uri *MatrixURI) RoomAlias() RoomAlias { - if uri != nil && uri.Sigil1 == '#' { - return RoomAlias(uri.PrimaryIdentifier()) - } - return "" -} - -// EventID returns the event ID from the URI if the primary identifier is a room ID or alias and the secondary identifier is an event ID. -func (uri *MatrixURI) EventID() EventID { - if uri != nil && (uri.Sigil1 == '!' || uri.Sigil1 == '#') && uri.Sigil2 == '$' { - return EventID(uri.SecondaryIdentifier()) - } - return "" -} - -// ParseMatrixURIOrMatrixToURL parses the given matrix.to URL or matrix: URI into a unified representation. -func ParseMatrixURIOrMatrixToURL(uri string) (*MatrixURI, error) { - parsed, err := url.Parse(uri) - if err != nil { - return nil, fmt.Errorf("failed to parse URI: %w", err) - } - if parsed.Scheme == "matrix" { - return ProcessMatrixURI(parsed) - } else if strings.HasSuffix(parsed.Hostname(), "matrix.to") { - return ProcessMatrixToURL(parsed) - } else { - return nil, ErrNotMatrixToOrMatrixURI - } -} - -// ParseMatrixURI implements the matrix: URI parsing algorithm. -// -// Currently specified in https://github.com/matrix-org/matrix-doc/blob/master/proposals/2312-matrix-uri.md#uri-parsing-algorithm -func ParseMatrixURI(uri string) (*MatrixURI, error) { - // Step 1: parse the URI according to RFC 3986 - parsed, err := url.Parse(uri) - if err != nil { - return nil, fmt.Errorf("failed to parse URI: %w", err) - } - return ProcessMatrixURI(parsed) -} - -// ProcessMatrixURI implements steps 2-7 of the matrix: URI parsing algorithm -// (i.e. everything except parsing the URI itself, which is done with url.Parse or ParseMatrixURI) -func ProcessMatrixURI(uri *url.URL) (*MatrixURI, error) { - // Step 2: check that scheme is exactly `matrix` - if uri.Scheme != "matrix" { - return nil, ErrInvalidScheme - } - - // Step 3: split the path into segments separated by / - parts := strings.Split(uri.Opaque, "/") - - // Step 4: Check that the URI contains either 2 or 4 segments - if len(parts) != 2 && len(parts) != 4 { - return nil, ErrInvalidPartCount - } - - var parsed MatrixURI - - // Step 5: Construct the top-level Matrix identifier - // a: find the sigil from the first segment - switch parts[0] { - case "u", "user": - parsed.Sigil1 = '@' - case "r", "room": - parsed.Sigil1 = '#' - case "roomid": - parsed.Sigil1 = '!' - default: - return nil, fmt.Errorf("%w: '%s'", ErrInvalidFirstSegment, parts[0]) - } - // b: find the identifier from the second segment - if len(parts[1]) == 0 { - return nil, ErrEmptySecondSegment - } - var err error - parsed.MXID1, err = url.PathUnescape(parts[1]) - if err != nil { - return nil, fmt.Errorf("failed to url decode second segment %q: %w", parts[1], err) - } - - // Step 6: if the first part is a room and the URI has 4 segments, construct a second level identifier - if parsed.Sigil1 == '!' && len(parts) == 4 { - // a: find the sigil from the third segment - switch parts[2] { - case "e", "event": - parsed.Sigil2 = '$' - default: - return nil, fmt.Errorf("%w: '%s'", ErrInvalidThirdSegment, parts[0]) - } - - // b: find the identifier from the fourth segment - if len(parts[3]) == 0 { - return nil, ErrEmptyFourthSegment - } - parsed.MXID2, err = url.PathUnescape(parts[3]) - if err != nil { - return nil, fmt.Errorf("failed to url decode fourth segment %q: %w", parts[3], err) - } - } - - // Step 7: parse the query and extract via and action items - via, ok := uri.Query()["via"] - if ok && len(via) > 0 { - parsed.Via = via - } - action, ok := uri.Query()["action"] - if ok && len(action) > 0 { - parsed.Action = action[len(action)-1] - } - - return &parsed, nil -} - -// ParseMatrixToURL parses a matrix.to URL into the same container as ParseMatrixURI parses matrix: URIs. -func ParseMatrixToURL(uri string) (*MatrixURI, error) { - parsed, err := url.Parse(uri) - if err != nil { - return nil, fmt.Errorf("failed to parse URL: %w", err) - } - return ProcessMatrixToURL(parsed) -} - -// ProcessMatrixToURL is the equivalent of ProcessMatrixURI for matrix.to URLs. -func ProcessMatrixToURL(uri *url.URL) (*MatrixURI, error) { - if !strings.HasSuffix(uri.Hostname(), "matrix.to") { - return nil, ErrNotMatrixTo - } - - initialSplit := strings.SplitN(uri.Fragment, "?", 2) - parts := strings.Split(initialSplit[0], "/") - if len(initialSplit) > 1 { - uri.RawQuery = initialSplit[1] - } - - if len(parts) < 2 || len(parts) > 3 { - return nil, ErrInvalidMatrixToPartCount - } - - if len(parts[1]) == 0 { - return nil, ErrEmptyMatrixToPrimaryIdentifier - } - - var parsed MatrixURI - - parsed.Sigil1 = rune(parts[1][0]) - parsed.MXID1 = parts[1][1:] - _, isKnown := SigilToPathSegment[parsed.Sigil1] - if !isKnown { - return nil, ErrInvalidMatrixToPrimaryIdentifier - } - - if len(parts) == 3 && len(parts[2]) > 0 { - parsed.Sigil2 = rune(parts[2][0]) - parsed.MXID2 = parts[2][1:] - _, isKnown = SigilToPathSegment[parsed.Sigil2] - if !isKnown { - return nil, ErrInvalidMatrixToSecondaryIdentifier - } - } - - via, ok := uri.Query()["via"] - if ok && len(via) > 0 { - parsed.Via = via - } - action, ok := uri.Query()["action"] - if ok && len(action) > 0 { - parsed.Action = action[len(action)-1] - } - - return &parsed, nil -} diff --git a/mautrix-patched/id/matrixuri_test.go b/mautrix-patched/id/matrixuri_test.go deleted file mode 100644 index 90a0754d..00000000 --- a/mautrix-patched/id/matrixuri_test.go +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix/id" -) - -var ( - roomIDLink = id.MatrixURI{Sigil1: '!', MXID1: "7NdBVvkd4aLSbgKt9RXl:example.org"} - roomIDViaLink = id.MatrixURI{Sigil1: '!', MXID1: "7NdBVvkd4aLSbgKt9RXl:example.org", Via: []string{"maunium.net", "matrix.org"}} - roomAliasLink = id.MatrixURI{Sigil1: '#', MXID1: "someroom:example.org"} - roomIDEventLink = id.MatrixURI{Sigil1: '!', MXID1: "7NdBVvkd4aLSbgKt9RXl:example.org", Sigil2: '$', MXID2: "uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s"} - userLink = id.MatrixURI{Sigil1: '@', MXID1: "user:example.org"} - - escapeRoomIDEventLink = id.MatrixURI{Sigil1: '!', MXID1: "meow & 🐈️:example.org", Sigil2: '$', MXID2: "uOH4C9cK4HhMeFWkUXMbdF/dtndJ0j9je+kIK3XpV1s"} -) - -func TestMatrixURI_MatrixToURL(t *testing.T) { - assert.Equal(t, "https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org", roomIDLink.MatrixToURL()) - assert.Equal(t, "https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org", roomIDViaLink.MatrixToURL()) - assert.Equal(t, "https://matrix.to/#/%23someroom:example.org", roomAliasLink.MatrixToURL()) - assert.Equal(t, "https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org/$uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s", roomIDEventLink.MatrixToURL()) - assert.Equal(t, "https://matrix.to/#/@user:example.org", userLink.MatrixToURL()) - assert.Equal(t, "https://matrix.to/#/%21meow%20&%20%F0%9F%90%88%EF%B8%8F:example.org/$uOH4C9cK4HhMeFWkUXMbdF%2FdtndJ0j9je+kIK3XpV1s", escapeRoomIDEventLink.MatrixToURL()) -} - -func TestMatrixURI_String(t *testing.T) { - assert.Equal(t, "matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org", roomIDLink.String()) - assert.Equal(t, "matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org", roomIDViaLink.String()) - assert.Equal(t, "matrix:r/someroom:example.org", roomAliasLink.String()) - assert.Equal(t, "matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org/e/uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s", roomIDEventLink.String()) - assert.Equal(t, "matrix:u/user:example.org", userLink.String()) - assert.Equal(t, "matrix:roomid/meow%20&%20%F0%9F%90%88%EF%B8%8F:example.org/e/uOH4C9cK4HhMeFWkUXMbdF%2FdtndJ0j9je+kIK3XpV1s", escapeRoomIDEventLink.String()) -} - -func TestParseMatrixURIOrMatrixToURL(t *testing.T) { - const inputURI = "matrix:u/user:example.org" - const inputMatrixToURL = "https://matrix.to/#/@user:example.org" - parsed1, err := id.ParseMatrixURIOrMatrixToURL(inputURI) - require.NoError(t, err) - require.NotNil(t, parsed1) - parsed2, err := id.ParseMatrixURIOrMatrixToURL(inputMatrixToURL) - require.NoError(t, err) - require.NotNil(t, parsed2) - - assert.Equal(t, parsed1, parsed2) - assert.Equal(t, inputURI, parsed2.String()) - assert.Equal(t, inputMatrixToURL, parsed1.MatrixToURL()) -} - -func TestParseMatrixURI_RoomAlias(t *testing.T) { - parsed1, err := id.ParseMatrixURI("matrix:r/someroom:example.org") - require.NoError(t, err) - require.NotNil(t, parsed1) - parsed2, err := id.ParseMatrixURI("matrix:room/someroom:example.org") - require.NoError(t, err) - require.NotNil(t, parsed2) - - assert.Equal(t, roomAliasLink, *parsed1) - assert.Equal(t, roomAliasLink, *parsed2) -} - -func TestParseMatrixURI_RoomID(t *testing.T) { - parsed, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org") - require.NoError(t, err) - require.NotNil(t, parsed) - parsedVia, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org") - require.NoError(t, err) - require.NotNil(t, parsedVia) - parsedEncoded, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl%3Aexample.org") - require.NoError(t, err) - require.NotNil(t, parsedEncoded) - - assert.Equal(t, roomIDLink, *parsed) - assert.Equal(t, roomIDLink, *parsedEncoded) - assert.Equal(t, roomIDViaLink, *parsedVia) -} - -func TestParseMatrixURI_UserID(t *testing.T) { - parsed1, err := id.ParseMatrixURI("matrix:u/user:example.org") - require.NoError(t, err) - require.NotNil(t, parsed1) - parsed2, err := id.ParseMatrixURI("matrix:user/user:example.org") - require.NoError(t, err) - require.NotNil(t, parsed2) - - assert.Equal(t, userLink, *parsed1) - assert.Equal(t, userLink, *parsed2) -} - -func TestParseMatrixURI_EventID(t *testing.T) { - parsed, err := id.ParseMatrixURI("matrix:roomid/7NdBVvkd4aLSbgKt9RXl:example.org/e/uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s") - require.NoError(t, err) - require.NotNil(t, parsed) - - assert.Equal(t, roomIDEventLink, *parsed) -} - -func TestParseMatrixToURL_RoomAlias(t *testing.T) { - parsed, err := id.ParseMatrixToURL("https://matrix.to/#/#someroom:example.org") - require.NoError(t, err) - require.NotNil(t, parsed) - parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%23someroom%3Aexample.org") - require.NoError(t, err) - require.NotNil(t, parsedEncoded) - - assert.Equal(t, roomAliasLink, *parsed) - assert.Equal(t, roomAliasLink, *parsedEncoded) -} - -func TestParseMatrixToURL_RoomID(t *testing.T) { - parsed, err := id.ParseMatrixToURL("https://matrix.to/#/!7NdBVvkd4aLSbgKt9RXl:example.org") - require.NoError(t, err) - require.NotNil(t, parsed) - parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl%3Aexample.org") - require.NoError(t, err) - require.NotNil(t, parsedEncoded) - parsedVia, err := id.ParseMatrixToURL("https://matrix.to/#/!7NdBVvkd4aLSbgKt9RXl:example.org?via=maunium.net&via=matrix.org") - require.NoError(t, err) - require.NotNil(t, parsedVia) - parsedViaEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl%3Aexample.org?via=maunium.net&via=matrix.org") - require.NoError(t, err) - require.NotNil(t, parsedViaEncoded) - - assert.Equal(t, roomIDLink, *parsed) - assert.Equal(t, roomIDLink, *parsedEncoded) - assert.Equal(t, roomIDViaLink, *parsedVia) - assert.Equal(t, roomIDViaLink, *parsedViaEncoded) -} - -func TestParseMatrixToURL_UserID(t *testing.T) { - parsed, err := id.ParseMatrixToURL("https://matrix.to/#/@user:example.org") - require.NoError(t, err) - require.NotNil(t, parsed) - parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%40user%3Aexample.org") - require.NoError(t, err) - require.NotNil(t, parsedEncoded) - - assert.Equal(t, userLink, *parsed) - assert.Equal(t, userLink, *parsedEncoded) -} - -func TestParseMatrixToURL_EventID(t *testing.T) { - parsed, err := id.ParseMatrixToURL("https://matrix.to/#/!7NdBVvkd4aLSbgKt9RXl:example.org/$uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s") - require.NoError(t, err) - require.NotNil(t, parsed) - parsedEncoded, err := id.ParseMatrixToURL("https://matrix.to/#/%217NdBVvkd4aLSbgKt9RXl:example.org/%24uOH4C9cK4HhMeFWkUXMbdF_dtndJ0j9je-kIK3XpV1s") - require.NoError(t, err) - require.NotNil(t, parsedEncoded) - - assert.Equal(t, roomIDEventLink, *parsed) - assert.Equal(t, roomIDEventLink, *parsedEncoded) -} diff --git a/mautrix-patched/id/opaque.go b/mautrix-patched/id/opaque.go deleted file mode 100644 index c1ad4988..00000000 --- a/mautrix-patched/id/opaque.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "fmt" -) - -// A RoomID is a string starting with ! that references a specific room. -// https://matrix.org/docs/spec/appendices#room-ids-and-event-ids -type RoomID string - -// A RoomAlias is a string starting with # that can be resolved into. -// https://matrix.org/docs/spec/appendices#room-aliases -type RoomAlias string - -func NewRoomAlias(localpart, server string) RoomAlias { - return RoomAlias(fmt.Sprintf("#%s:%s", localpart, server)) -} - -// An EventID is a string starting with $ that references a specific event. -// -// https://matrix.org/docs/spec/appendices#room-ids-and-event-ids -// https://matrix.org/docs/spec/rooms/v4#event-ids -type EventID string - -// A BatchID is a string identifying a batch of events being backfilled to a room. -// https://github.com/matrix-org/matrix-doc/pull/2716 -type BatchID string - -// A DelayID is a string identifying a delayed event. -type DelayID string - -func (roomID RoomID) String() string { - return string(roomID) -} - -func (roomID RoomID) URI(via ...string) *MatrixURI { - if roomID == "" { - return nil - } - return &MatrixURI{ - Sigil1: '!', - MXID1: string(roomID)[1:], - Via: via, - } -} - -func (roomID RoomID) EventURI(eventID EventID, via ...string) *MatrixURI { - if roomID == "" { - return nil - } else if eventID == "" { - return roomID.URI(via...) - } - return &MatrixURI{ - Sigil1: '!', - MXID1: string(roomID)[1:], - Sigil2: '$', - MXID2: string(eventID)[1:], - Via: via, - } -} - -func (roomAlias RoomAlias) String() string { - return string(roomAlias) -} - -func (roomAlias RoomAlias) URI() *MatrixURI { - if roomAlias == "" { - return nil - } - return &MatrixURI{ - Sigil1: '#', - MXID1: string(roomAlias)[1:], - } -} - -// Deprecated: room alias event links should not be used. Use room IDs instead. -func (roomAlias RoomAlias) EventURI(eventID EventID) *MatrixURI { - if roomAlias == "" { - return nil - } - return &MatrixURI{ - Sigil1: '#', - MXID1: string(roomAlias)[1:], - Sigil2: '$', - MXID2: string(eventID)[1:], - } -} - -func (eventID EventID) String() string { - return string(eventID) -} - -func (batchID BatchID) String() string { - return string(batchID) -} diff --git a/mautrix-patched/id/roomversion.go b/mautrix-patched/id/roomversion.go deleted file mode 100644 index 578c10bd..00000000 --- a/mautrix-patched/id/roomversion.go +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "errors" - "fmt" - "slices" -) - -type RoomVersion string - -const ( - RoomV0 RoomVersion = "" // No room version, used for rooms created before room versions were introduced, equivalent to v1 - RoomV1 RoomVersion = "1" - RoomV2 RoomVersion = "2" - RoomV3 RoomVersion = "3" - RoomV4 RoomVersion = "4" - RoomV5 RoomVersion = "5" - RoomV6 RoomVersion = "6" - RoomV7 RoomVersion = "7" - RoomV8 RoomVersion = "8" - RoomV9 RoomVersion = "9" - RoomV10 RoomVersion = "10" - RoomV11 RoomVersion = "11" - RoomV12 RoomVersion = "12" -) - -func (rv RoomVersion) Equals(versions ...RoomVersion) bool { - return slices.Contains(versions, rv) -} - -func (rv RoomVersion) NotEquals(versions ...RoomVersion) bool { - return !rv.Equals(versions...) -} - -var ErrUnknownRoomVersion = errors.New("unknown room version") - -func (rv RoomVersion) unknownVersionError() error { - return fmt.Errorf("%w %s", ErrUnknownRoomVersion, rv) -} - -func (rv RoomVersion) IsKnown() bool { - switch rv { - case RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11, RoomV12: - return true - default: - return false - } -} - -type StateResVersion int - -const ( - // StateResV1 is the original state resolution algorithm. - StateResV1 StateResVersion = 0 - // StateResV2 is state resolution v2 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/1759 - StateResV2 StateResVersion = 1 - // StateResV2_1 is state resolution v2.1 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/4297 - StateResV2_1 StateResVersion = 2 -) - -// StateResVersion returns the version of the state resolution algorithm used by this room version. -func (rv RoomVersion) StateResVersion() StateResVersion { - switch rv { - case RoomV0, RoomV1: - return StateResV1 - case RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11: - return StateResV2 - case RoomV12: - return StateResV2_1 - default: - panic(rv.unknownVersionError()) - } -} - -type EventIDFormat int - -const ( - // EventIDFormatCustom is the original format used by room v1 and v2. - // Event IDs in this format are an arbitrary string followed by a colon and the server name. - EventIDFormatCustom EventIDFormat = 0 - // EventIDFormatBase64 is the format used by room v3 introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/1659. - // Event IDs in this format are the standard unpadded base64-encoded SHA256 reference hash of the event. - EventIDFormatBase64 EventIDFormat = 1 - // EventIDFormatURLSafeBase64 is the format used by room v4 and later introduced by https://github.com/matrix-org/matrix-spec-proposals/pull/2002. - // Event IDs in this format are the url-safe unpadded base64-encoded SHA256 reference hash of the event. - EventIDFormatURLSafeBase64 EventIDFormat = 2 -) - -// EventIDFormat returns the format of event IDs used by this room version. -func (rv RoomVersion) EventIDFormat() EventIDFormat { - switch rv { - case RoomV0, RoomV1, RoomV2: - return EventIDFormatCustom - case RoomV3: - return EventIDFormatBase64 - default: - return EventIDFormatURLSafeBase64 - } -} - -///////////////////// -// Room v5 changes // -///////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/2077 - -// EnforceSigningKeyValidity returns true if the `valid_until_ts` field of federation signing keys -// must be enforced on received events. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2076 -func (rv RoomVersion) EnforceSigningKeyValidity() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4) -} - -///////////////////// -// Room v6 changes // -///////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/2240 - -// SpecialCasedAliasesAuth returns true if the `m.room.aliases` event authorization is special cased -// to only always allow servers to modify the state event with their own server name as state key. -// This also implies that the `aliases` field is protected from redactions. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2432 -func (rv RoomVersion) SpecialCasedAliasesAuth() bool { - return rv.Equals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) -} - -// ForbidFloatsAndBigInts returns true if floats and integers greater than 2^53-1 or lower than -2^53+1 are forbidden everywhere. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2540 -func (rv RoomVersion) ForbidFloatsAndBigInts() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) -} - -// NotificationsPowerLevels returns true if the `notifications` field in `m.room.power_levels` is validated in event auth. -// However, the field is not protected from redactions. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2209 -func (rv RoomVersion) NotificationsPowerLevels() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5) -} - -///////////////////// -// Room v7 changes // -///////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/2998 - -// Knocks returns true if the `knock` join rule is supported. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2403 -func (rv RoomVersion) Knocks() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6) -} - -///////////////////// -// Room v8 changes // -///////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/3289 - -// RestrictedJoins returns true if the `restricted` join rule is supported. -// This also implies that the `allow` field in the `m.room.join_rules` event is supported and protected from redactions. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/3083 -func (rv RoomVersion) RestrictedJoins() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7) -} - -///////////////////// -// Room v9 changes // -///////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/3375 - -// RestrictedJoinsFix returns true if the `join_authorised_via_users_server` field in `m.room.member` events is protected from redactions. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/3375 -func (rv RoomVersion) RestrictedJoinsFix() bool { - return rv.RestrictedJoins() && rv != RoomV8 -} - -////////////////////// -// Room v10 changes // -////////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/3604 - -// ValidatePowerLevelInts returns true if the known values in `m.room.power_levels` must be integers (and not strings). -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/3667 -func (rv RoomVersion) ValidatePowerLevelInts() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9) -} - -// KnockRestricted returns true if the `knock_restricted` join rule is supported. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/3787 -func (rv RoomVersion) KnockRestricted() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9) -} - -////////////////////// -// Room v11 changes // -////////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/3820 - -// CreatorInContent returns true if the `m.room.create` event has a `creator` field in content. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2175 -func (rv RoomVersion) CreatorInContent() bool { - return rv.Equals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) -} - -// RedactsInContent returns true if the `m.room.redaction` event has the `redacts` field in content instead of at the top level. -// The redaction protection is also moved from the top level to the content field. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2174 -// (and https://github.com/matrix-org/matrix-spec-proposals/pull/2176 for the redaction protection). -func (rv RoomVersion) RedactsInContent() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) -} - -// UpdatedRedactionRules returns true if various updates to the redaction algorithm are applied. -// -// Specifically: -// -// * the `membership`, `origin`, and `prev_state` fields at the top level of all events are no longer protected. -// * the entire content of `m.room.create` is protected. -// * the `redacts` field in `m.room.redaction` content is protected instead of the top-level field. -// * the `m.room.power_levels` event protects the `invite` field in content. -// * the `signed` field inside the `third_party_invite` field in content of `m.room.member` events is protected. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/2176, -// https://github.com/matrix-org/matrix-spec-proposals/pull/3821, and -// https://github.com/matrix-org/matrix-spec-proposals/pull/3989 -func (rv RoomVersion) UpdatedRedactionRules() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10) -} - -////////////////////// -// Room v12 changes // -////////////////////// -// https://github.com/matrix-org/matrix-spec-proposals/pull/4304 - -// Return value of StateResVersion was changed to StateResV2_1 - -// PrivilegedRoomCreators returns true if the creator(s) of a room always have infinite power level. -// This also implies that the `m.room.create` event has an `additional_creators` field, -// and that the creators can't be present in the `m.room.power_levels` event. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/4289 -func (rv RoomVersion) PrivilegedRoomCreators() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11) -} - -// RoomIDIsCreateEventID returns true if the ID of rooms is the same as the ID of the `m.room.create` event. -// This also implies that `m.room.create` events do not have a `room_id` field. -// -// See https://github.com/matrix-org/matrix-spec-proposals/pull/4291 -func (rv RoomVersion) RoomIDIsCreateEventID() bool { - return rv.NotEquals(RoomV0, RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11) -} diff --git a/mautrix-patched/id/servername.go b/mautrix-patched/id/servername.go deleted file mode 100644 index 923705b6..00000000 --- a/mautrix-patched/id/servername.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "regexp" - "strconv" -) - -type ParsedServerNameType int - -const ( - ServerNameDNS ParsedServerNameType = iota - ServerNameIPv4 - ServerNameIPv6 -) - -type ParsedServerName struct { - Type ParsedServerNameType - Host string - Port int -} - -var ServerNameRegex = regexp.MustCompile(`^(?:\[([0-9A-Fa-f:.]{2,45})]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9A-Za-z.-]{1,255}))(?::(\d{1,5}))?$`) - -func ValidateServerName(serverName string) bool { - return len(serverName) <= 255 && len(serverName) > 0 && ServerNameRegex.MatchString(serverName) -} - -func ParseServerName(serverName string) *ParsedServerName { - if len(serverName) > 255 || len(serverName) < 1 { - return nil - } - match := ServerNameRegex.FindStringSubmatch(serverName) - if len(match) != 5 { - return nil - } - port, _ := strconv.Atoi(match[4]) - parsed := &ParsedServerName{ - Port: port, - } - switch { - case match[1] != "": - parsed.Type = ServerNameIPv6 - parsed.Host = match[1] - case match[2] != "": - parsed.Type = ServerNameIPv4 - parsed.Host = match[2] - case match[3] != "": - parsed.Type = ServerNameDNS - parsed.Host = match[3] - } - return parsed -} diff --git a/mautrix-patched/id/trust.go b/mautrix-patched/id/trust.go deleted file mode 100644 index 877ed6f1..00000000 --- a/mautrix-patched/id/trust.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "fmt" - "strings" -) - -// TrustState determines how trusted a device is. -type TrustState int - -const ( - TrustStateBlacklisted TrustState = -100 - TrustStateDeviceKeyMismatch TrustState = -5 - TrustStateUnset TrustState = 0 - TrustStateUnknownDevice TrustState = 10 - TrustStateForwarded TrustState = 20 - TrustStateBackup TrustState = 30 - TrustStateCrossSignedUntrusted TrustState = 50 - TrustStateCrossSignedTOFU TrustState = 100 - TrustStateCrossSignedVerified TrustState = 200 - TrustStateVerified TrustState = 300 - TrustStateInvalid TrustState = -2147483647 -) - -func (ts *TrustState) UnmarshalText(data []byte) error { - strData := string(data) - state := ParseTrustState(strData) - if state == TrustStateInvalid { - return fmt.Errorf("invalid trust state %q", strData) - } - *ts = state - return nil -} - -func (ts *TrustState) MarshalText() ([]byte, error) { - return []byte(ts.String()), nil -} - -func ParseTrustState(val string) TrustState { - switch strings.ToLower(val) { - case "blacklisted": - return TrustStateBlacklisted - case "device-key-mismatch": - return TrustStateDeviceKeyMismatch - case "unverified": - return TrustStateUnset - case "cross-signed-untrusted": - return TrustStateCrossSignedUntrusted - case "unknown-device": - return TrustStateUnknownDevice - case "forwarded": - return TrustStateForwarded - case "backup": - return TrustStateBackup - case "cross-signed-tofu", "cross-signed": - return TrustStateCrossSignedTOFU - case "cross-signed-verified", "cross-signed-trusted": - return TrustStateCrossSignedVerified - case "verified": - return TrustStateVerified - default: - return TrustStateInvalid - } -} - -func (ts TrustState) String() string { - switch ts { - case TrustStateBlacklisted: - return "blacklisted" - case TrustStateDeviceKeyMismatch: - return "device-key-mismatch" - case TrustStateUnset: - return "unverified" - case TrustStateCrossSignedUntrusted: - return "cross-signed-untrusted" - case TrustStateUnknownDevice: - return "unknown-device" - case TrustStateForwarded: - return "forwarded" - case TrustStateBackup: - return "backup" - case TrustStateCrossSignedTOFU: - return "cross-signed-tofu" - case TrustStateCrossSignedVerified: - return "cross-signed-verified" - case TrustStateVerified: - return "verified" - default: - return "invalid" - } -} diff --git a/mautrix-patched/id/userid.go b/mautrix-patched/id/userid.go deleted file mode 100644 index 726a0d58..00000000 --- a/mautrix-patched/id/userid.go +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id - -import ( - "bytes" - "encoding/hex" - "errors" - "fmt" - "regexp" - "strings" -) - -// UserID represents a Matrix user ID. -// https://matrix.org/docs/spec/appendices#user-identifiers -type UserID string - -const UserIDMaxLength = 255 - -func NewUserID(localpart, homeserver string) UserID { - return UserID(fmt.Sprintf("@%s:%s", localpart, homeserver)) -} - -func NewEncodedUserID(localpart, homeserver string) UserID { - return NewUserID(EncodeUserLocalpart(localpart), homeserver) -} - -var ( - ErrInvalidUserID = errors.New("is not a valid user ID") - ErrNoncompliantLocalpart = errors.New("contains characters that are not allowed") - ErrUserIDTooLong = errors.New("the given user ID is longer than 255 characters") - ErrEmptyLocalpart = errors.New("empty localparts are not allowed") - ErrNoncompliantServerPart = errors.New("is not a valid server name") -) - -// ParseCommonIdentifier parses a common identifier according to https://spec.matrix.org/v1.9/appendices/#common-identifier-format -func ParseCommonIdentifier[Stringish ~string](identifier Stringish) (sigil byte, localpart, homeserver string) { - if len(identifier) == 0 { - return - } - sigil = identifier[0] - strIdentifier := string(identifier) - colonIdx := strings.IndexByte(strIdentifier, ':') - if colonIdx > 0 { - localpart = strIdentifier[1:colonIdx] - homeserver = strIdentifier[colonIdx+1:] - } else { - localpart = strIdentifier[1:] - } - return -} - -// Parse parses the user ID into the localpart and server name. -// -// Note that this only enforces very basic user ID formatting requirements: user IDs start with -// a @, and contain a : after the @. If you want to enforce localpart validity, see the -// ParseAndValidate and ValidateUserLocalpart functions. -func (userID UserID) Parse() (localpart, homeserver string, err error) { - var sigil byte - sigil, localpart, homeserver = ParseCommonIdentifier(userID) - if sigil != '@' || homeserver == "" { - err = fmt.Errorf("'%s' %w", userID, ErrInvalidUserID) - } - return -} - -func (userID UserID) Localpart() string { - localpart, _, _ := userID.Parse() - return localpart -} - -func (userID UserID) Homeserver() string { - _, homeserver, _ := userID.Parse() - return homeserver -} - -// URI returns the user ID as a MatrixURI struct, which can then be stringified into a matrix: URI or a matrix.to URL. -// -// This does not parse or validate the user ID. Use the ParseAndValidate method if you want to ensure the user ID is valid first. -func (userID UserID) URI() *MatrixURI { - if userID == "" { - return nil - } - return &MatrixURI{ - Sigil1: '@', - MXID1: string(userID)[1:], - } -} - -var ValidLocalpartRegex = regexp.MustCompile("^[0-9a-z-.=_/+]+$") - -// ValidateUserLocalpart validates a Matrix user ID localpart using the grammar -// in https://matrix.org/docs/spec/appendices#user-identifier -func ValidateUserLocalpart(localpart string) error { - if len(localpart) == 0 { - return ErrEmptyLocalpart - } else if !ValidLocalpartRegex.MatchString(localpart) { - return fmt.Errorf("'%s' %w", localpart, ErrNoncompliantLocalpart) - } - return nil -} - -// ParseAndValidateStrict is a stricter version of ParseAndValidateRelaxed that checks the localpart to only allow non-historical localparts. -// This should be used with care: there are real users still using historical localparts. -func (userID UserID) ParseAndValidateStrict() (localpart, homeserver string, err error) { - localpart, homeserver, err = userID.ParseAndValidateRelaxed() - if err == nil { - err = ValidateUserLocalpart(localpart) - } - return -} - -// ParseAndValidateRelaxed parses the user ID into the localpart and server name like Parse, -// and also validates that the user ID is not too long and that the server name is valid. -func (userID UserID) ParseAndValidateRelaxed() (localpart, homeserver string, err error) { - if len(userID) > UserIDMaxLength { - err = ErrUserIDTooLong - return - } - localpart, homeserver, err = userID.Parse() - if err == nil && !ValidateServerName(homeserver) { - err = fmt.Errorf("%q %q", homeserver, ErrNoncompliantServerPart) - } - return -} - -func (userID UserID) ParseAndDecode() (localpart, homeserver string, err error) { - localpart, homeserver, err = userID.ParseAndValidateStrict() - if err == nil { - localpart, err = DecodeUserLocalpart(localpart) - } - return -} - -func (userID UserID) String() string { - return string(userID) -} - -const lowerhex = "0123456789abcdef" - -// encode the given byte using quoted-printable encoding (e.g "=2f") -// and writes it to the buffer -// See https://golang.org/src/mime/quotedprintable/writer.go -func encode(buf *bytes.Buffer, b byte) { - buf.WriteByte('=') - buf.WriteByte(lowerhex[b>>4]) - buf.WriteByte(lowerhex[b&0x0f]) -} - -// escape the given alpha character and writes it to the buffer -func escape(buf *bytes.Buffer, b byte) { - buf.WriteByte('_') - if b == '_' { - buf.WriteByte('_') // another _ - } else { - buf.WriteByte(b + 0x20) // ASCII shift A-Z to a-z - } -} - -func shouldEncode(b byte) bool { - return b != '-' && b != '.' && b != '_' && b != '+' && !(b >= '0' && b <= '9') && !(b >= 'a' && b <= 'z') && !(b >= 'A' && b <= 'Z') -} - -func shouldEscape(b byte) bool { - return (b >= 'A' && b <= 'Z') || b == '_' -} - -func isValidByte(b byte) bool { - return isValidEscapedChar(b) || (b >= '0' && b <= '9') || b == '.' || b == '=' || b == '-' || b == '+' -} - -func isValidEscapedChar(b byte) bool { - return b == '_' || (b >= 'a' && b <= 'z') -} - -// EncodeUserLocalpart encodes the given string into Matrix-compliant user ID localpart form. -// See https://spec.matrix.org/v1.2/appendices/#mapping-from-other-character-sets -// -// This returns a string with only the characters "a-z0-9._=-". The uppercase range A-Z -// are encoded using leading underscores ("_"). Characters outside the aforementioned ranges -// (including literal underscores ("_") and equals ("=")) are encoded as UTF8 code points (NOT NCRs) -// and converted to lower-case hex with a leading "=". For example: -// -// Alph@Bet_50up => _alph=40_bet=5f50up -func EncodeUserLocalpart(str string) string { - strBytes := []byte(str) - var outputBuffer bytes.Buffer - for _, b := range strBytes { - if shouldEncode(b) { - encode(&outputBuffer, b) - } else if shouldEscape(b) { - escape(&outputBuffer, b) - } else { - outputBuffer.WriteByte(b) - } - } - return outputBuffer.String() -} - -// DecodeUserLocalpart decodes the given string back into the original input string. -// Returns an error if the given string is not a valid user ID localpart encoding. -// See https://spec.matrix.org/v1.2/appendices/#mapping-from-other-character-sets -// -// This decodes quoted-printable bytes back into UTF8, and unescapes casing. For -// example: -// -// _alph=40_bet=5f50up => Alph@Bet_50up -// -// Returns an error if the input string contains characters outside the -// range "a-z0-9._=-", has an invalid quote-printable byte (e.g. not hex), or has -// an invalid _ escaped byte (e.g. "_5"). -func DecodeUserLocalpart(str string) (string, error) { - strBytes := []byte(str) - var outputBuffer bytes.Buffer - for i := 0; i < len(strBytes); i++ { - b := strBytes[i] - if !isValidByte(b) { - return "", fmt.Errorf("invalid encoded byte at position %d: %c", i, b) - } - - if b == '_' { // next byte is a-z and should be upper-case or is another _ and should be a literal _ - if i+1 >= len(strBytes) { - return "", fmt.Errorf("unexpected end of string after underscore at %d", i) - } - if !isValidEscapedChar(strBytes[i+1]) { // invalid escaping - return "", fmt.Errorf("unexpected byte %c after underscore at %d", strBytes[i+1], i) - } - if strBytes[i+1] == '_' { - outputBuffer.WriteByte('_') - } else { - outputBuffer.WriteByte(strBytes[i+1] - 0x20) // ASCII shift a-z to A-Z - } - i++ // skip next byte since we just handled it - } else if b == '=' { // next 2 bytes are hex and should be buffered ready to be read as utf8 - if i+2 >= len(strBytes) { - return "", fmt.Errorf("unexpected end of string after equals sign at %d", i) - } - dst := make([]byte, 1) - _, err := hex.Decode(dst, strBytes[i+1:i+3]) - if err != nil { - return "", err - } - outputBuffer.WriteByte(dst[0]) - i += 2 // skip next 2 bytes since we just handled it - } else { // pass through - outputBuffer.WriteByte(b) - } - } - return outputBuffer.String(), nil -} diff --git a/mautrix-patched/id/userid_test.go b/mautrix-patched/id/userid_test.go deleted file mode 100644 index 57a88066..00000000 --- a/mautrix-patched/id/userid_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2021 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package id_test - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/id" -) - -func TestUserID_Parse(t *testing.T) { - const inputUserID = "@s p a c e:maunium.net" - parsedLocalpart, parsedServerName, err := id.UserID(inputUserID).Parse() - assert.NoError(t, err) - assert.Equal(t, "s p a c e", parsedLocalpart) - assert.Equal(t, "maunium.net", parsedServerName) -} - -func TestUserID_Parse_Empty(t *testing.T) { - const inputUserID = "@:ponies.im" - parsedLocalpart, parsedServerName, err := id.UserID(inputUserID).Parse() - assert.NoError(t, err) - assert.Equal(t, "", parsedLocalpart) - assert.Equal(t, "ponies.im", parsedServerName) -} - -func TestUserID_Parse_Invalid(t *testing.T) { - const inputUserID = "hello world" - _, _, err := id.UserID(inputUserID).Parse() - assert.Error(t, err) - assert.True(t, errors.Is(err, id.ErrInvalidUserID)) -} - -func TestUserID_ParseAndValidateStrict_Invalid(t *testing.T) { - const inputUserID = "@s p a c e:maunium.net" - _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() - assert.Error(t, err) - assert.True(t, errors.Is(err, id.ErrNoncompliantLocalpart)) -} - -func TestUserID_ParseAndValidateStrict_Empty(t *testing.T) { - const inputUserID = "@:ponies.im" - _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() - assert.Error(t, err) - assert.True(t, errors.Is(err, id.ErrEmptyLocalpart)) -} - -func TestUserID_ParseAndValidateStrict_Long(t *testing.T) { - const inputUserID = "@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example.com" - _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() - assert.Error(t, err) - assert.True(t, errors.Is(err, id.ErrUserIDTooLong)) -} - -func TestUserID_ParseAndValidateStrict_NotLong(t *testing.T) { - const inputUserID = "@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example.com" - _, _, err := id.UserID(inputUserID).ParseAndValidateStrict() - assert.NoError(t, err) -} - -func TestUserIDEncoding(t *testing.T) { - const inputLocalpart = "This local+part contains IlLeGaL chäracters 🚨" - const encodedLocalpart = "_this=20local+part=20contains=20_il_le_ga_l=20ch=c3=a4racters=20=f0=9f=9a=a8" - const inputServerName = "example.com" - userID := id.NewEncodedUserID(inputLocalpart, inputServerName) - parsedLocalpart, parsedServerName, err := userID.ParseAndValidateStrict() - assert.NoError(t, err) - assert.Equal(t, encodedLocalpart, parsedLocalpart) - assert.Equal(t, inputServerName, parsedServerName) - decodedLocalpart, decodedServerName, err := userID.ParseAndDecode() - assert.NoError(t, err) - assert.Equal(t, inputLocalpart, decodedLocalpart) - assert.Equal(t, inputServerName, decodedServerName) -} - -func TestUserID_URI(t *testing.T) { - userID := id.NewUserID("hello", "example.com") - assert.Equal(t, userID.URI().String(), "matrix:u/hello:example.com") -} diff --git a/mautrix-patched/mediaproxy/mediaproxy.go b/mautrix-patched/mediaproxy/mediaproxy.go deleted file mode 100644 index e456b71b..00000000 --- a/mautrix-patched/mediaproxy/mediaproxy.go +++ /dev/null @@ -1,534 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mediaproxy - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime" - "mime/multipart" - "net/http" - "net/textproto" - "net/url" - "os" - "strconv" - "strings" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exhttp" - "go.mau.fi/util/ptr" - "go.mau.fi/util/requestlog" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/federation" - "maunium.net/go/mautrix/id" -) - -type GetMediaResponse interface { - isGetMediaResponse() -} - -func (*GetMediaResponseURL) isGetMediaResponse() {} -func (*GetMediaResponseData) isGetMediaResponse() {} -func (*GetMediaResponseCallback) isGetMediaResponse() {} -func (*GetMediaResponseFile) isGetMediaResponse() {} - -type GetMediaResponseURL struct { - URL string - ExpiresAt time.Time -} - -type GetMediaResponseWriter interface { - GetMediaResponse - io.WriterTo - GetContentType() string - GetContentLength() int64 -} - -var ( - _ GetMediaResponseWriter = (*GetMediaResponseCallback)(nil) - _ GetMediaResponseWriter = (*GetMediaResponseData)(nil) -) - -type GetMediaResponseData struct { - Reader io.ReadCloser - ContentType string - ContentLength int64 -} - -func (d *GetMediaResponseData) WriteTo(w io.Writer) (int64, error) { - return io.Copy(w, d.Reader) -} - -func (d *GetMediaResponseData) GetContentType() string { - return d.ContentType -} - -func (d *GetMediaResponseData) GetContentLength() int64 { - return d.ContentLength -} - -func GetMediaResponseRawData(data []byte) *GetMediaResponseData { - return &GetMediaResponseData{ - Reader: io.NopCloser(bytes.NewReader(data)), - ContentType: http.DetectContentType(data), - ContentLength: int64(len(data)), - } -} - -type GetMediaResponseCallback struct { - Callback func(w io.Writer) (int64, error) - ContentType string - ContentLength int64 -} - -func (d *GetMediaResponseCallback) WriteTo(w io.Writer) (int64, error) { - return d.Callback(w) -} - -func (d *GetMediaResponseCallback) GetContentLength() int64 { - return d.ContentLength -} - -func (d *GetMediaResponseCallback) GetContentType() string { - return d.ContentType -} - -type FileMeta struct { - ContentType string - ReplacementFile string -} - -type GetMediaResponseFile struct { - Callback func(w *os.File) (*FileMeta, error) -} - -type GetMediaFunc = func(ctx context.Context, mediaID string, params map[string]string) (response GetMediaResponse, err error) - -type MediaProxy struct { - KeyServer *federation.KeyServer - ServerAuth *federation.ServerAuth - - GetMedia GetMediaFunc - PrepareProxyRequest func(*http.Request) - - serverName string - serverKey *federation.SigningKey - - FederationRouter *http.ServeMux - ClientMediaRouter *http.ServeMux -} - -func New(serverName string, serverKey string, getMedia GetMediaFunc) (*MediaProxy, error) { - parsed, err := federation.ParseSynapseKey(serverKey) - if err != nil { - return nil, err - } - mp := &MediaProxy{ - serverName: serverName, - serverKey: parsed, - GetMedia: getMedia, - KeyServer: &federation.KeyServer{ - KeyProvider: &federation.StaticServerKey{ - ServerName: serverName, - Key: parsed, - }, - WellKnownTarget: fmt.Sprintf("%s:443", serverName), - Version: federation.ServerVersion{ - Name: "mautrix-go media proxy", - Version: strings.TrimPrefix(mautrix.VersionWithCommit, "v"), - }, - }, - } - mp.FederationRouter = http.NewServeMux() - mp.FederationRouter.HandleFunc("GET /v1/media/download/{mediaID}", mp.DownloadMediaFederation) - mp.FederationRouter.HandleFunc("GET /v1/media/thumbnail/{mediaID}", mp.DownloadMediaFederation) - mp.FederationRouter.HandleFunc("GET /v1/version", mp.KeyServer.GetServerVersion) - mp.ClientMediaRouter = http.NewServeMux() - mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}", mp.DownloadMedia) - mp.ClientMediaRouter.HandleFunc("GET /download/{serverName}/{mediaID}/{fileName}", mp.DownloadMedia) - mp.ClientMediaRouter.HandleFunc("GET /thumbnail/{serverName}/{mediaID}", mp.DownloadMedia) - mp.ClientMediaRouter.HandleFunc("PUT /upload/{serverName}/{mediaID}", mp.UploadNotSupported) - mp.ClientMediaRouter.HandleFunc("POST /upload", mp.UploadNotSupported) - mp.ClientMediaRouter.HandleFunc("POST /create", mp.UploadNotSupported) - mp.ClientMediaRouter.HandleFunc("GET /config", mp.UploadNotSupported) - mp.ClientMediaRouter.HandleFunc("GET /preview_url", mp.PreviewURLNotSupported) - return mp, nil -} - -type BasicConfig struct { - ServerName string `yaml:"server_name" json:"server_name"` - ServerKey string `yaml:"server_key" json:"server_key"` - FederationAuth bool `yaml:"federation_auth" json:"federation_auth"` - WellKnownResponse string `yaml:"well_known_response" json:"well_known_response"` -} - -func NewFromConfig(cfg BasicConfig, getMedia GetMediaFunc) (*MediaProxy, error) { - mp, err := New(cfg.ServerName, cfg.ServerKey, getMedia) - if err != nil { - return nil, err - } - if cfg.WellKnownResponse != "" { - mp.KeyServer.WellKnownTarget = cfg.WellKnownResponse - } - if cfg.FederationAuth { - mp.EnableServerAuth(nil, nil) - } - return mp, nil -} - -type ServerConfig struct { - Hostname string `yaml:"hostname" json:"hostname"` - Port uint16 `yaml:"port" json:"port"` -} - -func (mp *MediaProxy) Listen(cfg ServerConfig) error { - router := http.NewServeMux() - mp.RegisterRoutes(router, zerolog.Nop()) - return http.ListenAndServe(fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port), router) -} - -func (mp *MediaProxy) GetServerName() string { - return mp.serverName -} - -func (mp *MediaProxy) GetServerKey() *federation.SigningKey { - return mp.serverKey -} - -func (mp *MediaProxy) EnableServerAuth(client *federation.Client, keyCache federation.KeyCache) { - if keyCache == nil { - keyCache = federation.NewInMemoryCache() - } - if client == nil { - resCache, _ := keyCache.(federation.ResolutionCache) - client = federation.NewClient(mp.serverName, mp.serverKey, resCache, exhttp.SensibleClientSettings) - } - mp.ServerAuth = federation.NewServerAuth(client, keyCache, func(auth federation.XMatrixAuth) string { - return mp.GetServerName() - }) -} - -func (mp *MediaProxy) RegisterRoutes(router *http.ServeMux, log zerolog.Logger) { - errorBodies := exhttp.ErrorBodies{ - NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()), - MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()), - } - router.Handle("/_matrix/federation/", exhttp.ApplyMiddleware( - mp.FederationRouter, - exhttp.StripPrefix("/_matrix/federation"), - hlog.NewHandler(log), - hlog.RequestIDHandler("request_id", "Request-Id"), - requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), - exhttp.HandleErrors(errorBodies), - )) - router.Handle("/_matrix/client/v1/media/", exhttp.ApplyMiddleware( - mp.ClientMediaRouter, - exhttp.StripPrefix("/_matrix/client/v1/media"), - hlog.NewHandler(log), - hlog.RequestIDHandler("request_id", "Request-Id"), - exhttp.CORSMiddleware, - requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}), - exhttp.HandleErrors(errorBodies), - )) - mp.KeyServer.Register(router, log) -} - -var ErrInvalidMediaIDSyntax = errors.New("invalid media ID syntax") - -func queryToMap(vals url.Values) map[string]string { - m := make(map[string]string, len(vals)) - for k, v := range vals { - m[k] = v[0] - } - return m -} - -func (mp *MediaProxy) getMedia(w http.ResponseWriter, r *http.Request) GetMediaResponse { - mediaID := r.PathValue("mediaID") - if !id.IsValidMediaID(mediaID) { - mautrix.MNotFound.WithMessage("Media ID %q is not valid", mediaID).Write(w) - return nil - } - resp, err := mp.GetMedia(r.Context(), mediaID, queryToMap(r.URL.Query())) - if err != nil { - var mautrixRespError mautrix.RespError - if errors.Is(err, ErrInvalidMediaIDSyntax) { - mautrix.MNotFound.WithMessage("This is a media proxy at %q, other media downloads are not available here", mp.serverName).Write(w) - } else if errors.As(err, &mautrixRespError) { - mautrixRespError.Write(w) - } else { - zerolog.Ctx(r.Context()).Err(err).Str("media_id", mediaID).Msg("Failed to get media URL") - mautrix.MNotFound.WithMessage("Media not found").Write(w) - } - return nil - } - return resp -} - -func startMultipart(ctx context.Context, w http.ResponseWriter) *multipart.Writer { - mpw := multipart.NewWriter(w) - w.Header().Set("Content-Type", strings.Replace(mpw.FormDataContentType(), "form-data", "mixed", 1)) - w.WriteHeader(http.StatusOK) - metaPart, err := mpw.CreatePart(textproto.MIMEHeader{ - "Content-Type": {"application/json"}, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to create multipart metadata field") - return nil - } - _, err = metaPart.Write([]byte(`{}`)) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to write multipart metadata field") - return nil - } - return mpw -} - -func (mp *MediaProxy) DownloadMediaFederation(w http.ResponseWriter, r *http.Request) { - if mp.ServerAuth != nil { - var err *mautrix.RespError - r, err = mp.ServerAuth.Authenticate(r) - if err != nil { - err.Write(w) - return - } - } - ctx := r.Context() - log := zerolog.Ctx(ctx) - - resp := mp.getMedia(w, r) - if resp == nil { - return - } - - var mpw *multipart.Writer - if urlResp, ok := resp.(*GetMediaResponseURL); ok { - mpw = startMultipart(ctx, w) - if mpw == nil { - return - } - _, err := mpw.CreatePart(textproto.MIMEHeader{ - "Location": {urlResp.URL}, - }) - if err != nil { - log.Err(err).Msg("Failed to create multipart redirect field") - return - } - } else if fileResp, ok := resp.(*GetMediaResponseFile); ok { - responseStarted, err := doTempFileDownload(fileResp, func(wt io.WriterTo, size int64, mimeType string) error { - mpw = startMultipart(ctx, w) - if mpw == nil { - return fmt.Errorf("failed to start multipart writer") - } - dataPart, err := mpw.CreatePart(textproto.MIMEHeader{ - "Content-Type": {mimeType}, - }) - if err != nil { - return fmt.Errorf("failed to create multipart data field: %w", err) - } - _, err = wt.WriteTo(dataPart) - return err - }) - if err != nil { - log.Err(err).Msg("Failed to do media proxy with temp file") - if !responseStarted { - var mautrixRespError mautrix.RespError - if errors.As(err, &mautrixRespError) { - mautrixRespError.Write(w) - } else { - mautrix.MUnknown.WithMessage("Internal error proxying media").Write(w) - } - } - return - } - } else if dataResp, ok := resp.(GetMediaResponseWriter); ok { - mpw = startMultipart(ctx, w) - if mpw == nil { - return - } - dataPart, err := mpw.CreatePart(textproto.MIMEHeader{ - "Content-Type": {dataResp.GetContentType()}, - }) - if err != nil { - log.Err(err).Msg("Failed to create multipart data field") - return - } - _, err = dataResp.WriteTo(dataPart) - if err != nil { - log.Err(err).Msg("Failed to write multipart data field") - return - } - } else { - panic(fmt.Errorf("unknown GetMediaResponse type %T", resp)) - } - err := mpw.Close() - if err != nil { - log.Err(err).Msg("Failed to close multipart writer") - return - } -} - -func (mp *MediaProxy) addHeaders(w http.ResponseWriter, mimeType, fileName string) { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - contentDisposition := "attachment" - switch mimeType { - case "text/css", "text/plain", "text/csv", "application/json", "application/ld+json", "image/jpeg", "image/gif", - "image/png", "image/apng", "image/webp", "image/avif", "video/mp4", "video/webm", "video/ogg", "video/quicktime", - "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", "audio/wav", "audio/x-wav", - "audio/x-pn-wav", "audio/flac", "audio/x-flac", "application/pdf": - contentDisposition = "inline" - } - if fileName != "" { - contentDisposition = mime.FormatMediaType(contentDisposition, map[string]string{ - "filename": fileName, - }) - } - w.Header().Set("Content-Disposition", contentDisposition) - w.Header().Set("Content-Type", mimeType) -} - -func (mp *MediaProxy) DownloadMedia(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - log := zerolog.Ctx(ctx) - if r.PathValue("serverName") != mp.serverName { - mautrix.MNotFound.WithMessage("This is a media proxy at %q, other media downloads are not available here", mp.serverName).Write(w) - return - } - resp := mp.getMedia(w, r) - if resp == nil { - return - } - - if urlResp, ok := resp.(*GetMediaResponseURL); ok { - w.Header().Set("Location", urlResp.URL) - expirySeconds := (time.Until(urlResp.ExpiresAt) - 5*time.Minute).Seconds() - if urlResp.ExpiresAt.IsZero() { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - } else if expirySeconds > 0 { - cacheControl := fmt.Sprintf("public, max-age=%d, immutable", int(expirySeconds)) - w.Header().Set("Cache-Control", cacheControl) - } else { - w.Header().Set("Cache-Control", "no-store") - } - w.WriteHeader(http.StatusTemporaryRedirect) - } else if fileResp, ok := resp.(*GetMediaResponseFile); ok { - responseStarted, err := doTempFileDownload(fileResp, func(wt io.WriterTo, size int64, mimeType string) error { - mp.addHeaders(w, mimeType, r.PathValue("fileName")) - w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) - w.WriteHeader(http.StatusOK) - _, err := wt.WriteTo(w) - return err - }) - if err != nil { - log.Err(err).Msg("Failed to do media proxy with temp file") - if !responseStarted { - var mautrixRespError mautrix.RespError - if errors.As(err, &mautrixRespError) { - mautrixRespError.Write(w) - } else { - mautrix.MUnknown.WithMessage("Internal error proxying media").Write(w) - } - } - } - } else if writerResp, ok := resp.(GetMediaResponseWriter); ok { - if dataResp, ok := writerResp.(*GetMediaResponseData); ok { - defer dataResp.Reader.Close() - } - mp.addHeaders(w, writerResp.GetContentType(), r.PathValue("fileName")) - if writerResp.GetContentLength() != 0 { - w.Header().Set("Content-Length", strconv.FormatInt(writerResp.GetContentLength(), 10)) - } - w.WriteHeader(http.StatusOK) - _, err := writerResp.WriteTo(w) - if err != nil { - log.Err(err).Msg("Failed to write media data") - } - } else { - panic(fmt.Errorf("unknown GetMediaResponse type %T", resp)) - } -} - -func doTempFileDownload( - data *GetMediaResponseFile, - respond func(w io.WriterTo, size int64, mimeType string) error, -) (bool, error) { - tempFile, err := os.CreateTemp("", "mautrix-mediaproxy-*") - if err != nil { - return false, fmt.Errorf("failed to create temp file: %w", err) - } - origTempFile := tempFile - defer func() { - _ = origTempFile.Close() - _ = os.Remove(origTempFile.Name()) - }() - meta, err := data.Callback(tempFile) - if err != nil { - return false, err - } - if meta.ReplacementFile != "" { - tempFile, err = os.Open(meta.ReplacementFile) - if err != nil { - return false, fmt.Errorf("failed to open replacement file: %w", err) - } - defer func() { - _ = tempFile.Close() - _ = os.Remove(origTempFile.Name()) - }() - } else { - _, err = tempFile.Seek(0, io.SeekStart) - if err != nil { - return false, fmt.Errorf("failed to seek to start of temp file: %w", err) - } - } - fileInfo, err := tempFile.Stat() - if err != nil { - return false, fmt.Errorf("failed to stat temp file: %w", err) - } - mimeType := meta.ContentType - if mimeType == "" { - buf := make([]byte, 512) - n, err := tempFile.Read(buf) - if err != nil { - return false, fmt.Errorf("failed to read temp file to detect mime: %w", err) - } - buf = buf[:n] - _, err = tempFile.Seek(0, io.SeekStart) - if err != nil { - return false, fmt.Errorf("failed to seek to start of temp file: %w", err) - } - mimeType = http.DetectContentType(buf) - } - err = respond(tempFile, fileInfo.Size(), mimeType) - if err != nil { - return true, fmt.Errorf("failed to write response: %w", err) - } - return true, nil -} - -var ( - ErrUploadNotSupported = mautrix.MUnrecognized. - WithMessage("This is a media proxy and does not support media uploads."). - WithStatus(http.StatusNotImplemented) - ErrPreviewURLNotSupported = mautrix.MUnrecognized. - WithMessage("This is a media proxy and does not support URL previews."). - WithStatus(http.StatusNotImplemented) -) - -func (mp *MediaProxy) UploadNotSupported(w http.ResponseWriter, r *http.Request) { - ErrUploadNotSupported.Write(w) -} - -func (mp *MediaProxy) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) { - ErrPreviewURLNotSupported.Write(w) -} diff --git a/mautrix-patched/mockserver/mockserver.go b/mautrix-patched/mockserver/mockserver.go deleted file mode 100644 index 507c24a5..00000000 --- a/mautrix-patched/mockserver/mockserver.go +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2025 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mockserver - -import ( - "context" - "encoding/json" - "fmt" - "io" - "maps" - "net/http" - "net/http/httptest" - "strings" - "testing" - - globallog "github.com/rs/zerolog/log" // zerolog-allow-global-log - "github.com/stretchr/testify/require" - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exhttp" - "go.mau.fi/util/random" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/cryptohelper" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func mustDecode(r *http.Request, data any) { - exerrors.PanicIfNotNil(json.NewDecoder(r.Body).Decode(data)) -} - -type userAndDeviceID struct { - UserID id.UserID - DeviceID id.DeviceID -} - -type MockServer struct { - Router *http.ServeMux - Server *httptest.Server - - AccessTokenToUserID map[string]userAndDeviceID - DeviceInbox map[id.UserID]map[id.DeviceID][]event.Event - AccountData map[id.UserID]map[event.Type]json.RawMessage - DeviceKeys map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys - OneTimeKeys map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey - MasterKeys map[id.UserID]mautrix.CrossSigningKeys - SelfSigningKeys map[id.UserID]mautrix.CrossSigningKeys - UserSigningKeys map[id.UserID]mautrix.CrossSigningKeys - - PopOTKs bool - MemoryStore bool -} - -func Create(t testing.TB) *MockServer { - t.Helper() - - server := MockServer{ - AccessTokenToUserID: map[string]userAndDeviceID{}, - DeviceInbox: map[id.UserID]map[id.DeviceID][]event.Event{}, - AccountData: map[id.UserID]map[event.Type]json.RawMessage{}, - DeviceKeys: map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys{}, - OneTimeKeys: map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{}, - MasterKeys: map[id.UserID]mautrix.CrossSigningKeys{}, - SelfSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, - UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, - PopOTKs: true, - MemoryStore: true, - } - - router := http.NewServeMux() - router.HandleFunc("POST /_matrix/client/v3/login", server.postLogin) - router.HandleFunc("POST /_matrix/client/v3/keys/query", server.postKeysQuery) - router.HandleFunc("POST /_matrix/client/v3/keys/claim", server.postKeysClaim) - router.HandleFunc("PUT /_matrix/client/v3/sendToDevice/{type}/{txn}", server.putSendToDevice) - router.HandleFunc("PUT /_matrix/client/v3/user/{userID}/account_data/{type}", server.putAccountData) - router.HandleFunc("POST /_matrix/client/v3/keys/device_signing/upload", server.postDeviceSigningUpload) - router.HandleFunc("POST /_matrix/client/v3/keys/signatures/upload", server.emptyResp) - router.HandleFunc("POST /_matrix/client/v3/keys/upload", server.postKeysUpload) - server.Router = router - server.Server = httptest.NewServer(router) - t.Cleanup(server.Server.Close) - return &server -} - -func (ms *MockServer) getUserID(r *http.Request) userAndDeviceID { - authHeader := r.Header.Get("Authorization") - authHeader = strings.TrimPrefix(authHeader, "Bearer ") - userID, ok := ms.AccessTokenToUserID[authHeader] - if !ok { - panic("no user ID found for access token " + authHeader) - } - return userID -} - -func (ms *MockServer) emptyResp(w http.ResponseWriter, _ *http.Request) { - exhttp.WriteEmptyJSONResponse(w, http.StatusOK) -} - -func (ms *MockServer) postLogin(w http.ResponseWriter, r *http.Request) { - var loginReq mautrix.ReqLogin - mustDecode(r, &loginReq) - - deviceID := loginReq.DeviceID - if deviceID == "" { - deviceID = id.DeviceID(random.String(10)) - } - - accessToken := random.String(30) - userID := id.UserID(loginReq.Identifier.User) - ms.AccessTokenToUserID[accessToken] = userAndDeviceID{ - UserID: userID, - DeviceID: deviceID, - } - - exhttp.WriteJSONResponse(w, http.StatusOK, &mautrix.RespLogin{ - AccessToken: accessToken, - DeviceID: deviceID, - UserID: userID, - }) -} - -func (ms *MockServer) putSendToDevice(w http.ResponseWriter, r *http.Request) { - var req mautrix.ReqSendToDevice - mustDecode(r, &req) - evtType := event.Type{Type: r.PathValue("type"), Class: event.ToDeviceEventType} - - for user, devices := range req.Messages { - for device, content := range devices { - if _, ok := ms.DeviceInbox[user]; !ok { - ms.DeviceInbox[user] = map[id.DeviceID][]event.Event{} - } - content.ParseRaw(evtType) - ms.DeviceInbox[user][device] = append(ms.DeviceInbox[user][device], event.Event{ - Sender: ms.getUserID(r).UserID, - Type: evtType, - Content: *content, - }) - } - } - ms.emptyResp(w, r) -} - -func (ms *MockServer) putAccountData(w http.ResponseWriter, r *http.Request) { - userID := id.UserID(r.PathValue("userID")) - eventType := event.Type{Type: r.PathValue("type"), Class: event.AccountDataEventType} - - jsonData, _ := io.ReadAll(r.Body) - if _, ok := ms.AccountData[userID]; !ok { - ms.AccountData[userID] = map[event.Type]json.RawMessage{} - } - ms.AccountData[userID][eventType] = json.RawMessage(jsonData) - ms.emptyResp(w, r) -} - -func (ms *MockServer) postKeysQuery(w http.ResponseWriter, r *http.Request) { - var req mautrix.ReqQueryKeys - mustDecode(r, &req) - resp := mautrix.RespQueryKeys{ - MasterKeys: map[id.UserID]mautrix.CrossSigningKeys{}, - UserSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, - SelfSigningKeys: map[id.UserID]mautrix.CrossSigningKeys{}, - DeviceKeys: map[id.UserID]map[id.DeviceID]mautrix.DeviceKeys{}, - } - for user := range req.DeviceKeys { - resp.MasterKeys[user] = ms.MasterKeys[user] - resp.UserSigningKeys[user] = ms.UserSigningKeys[user] - resp.SelfSigningKeys[user] = ms.SelfSigningKeys[user] - resp.DeviceKeys[user] = ms.DeviceKeys[user] - } - exhttp.WriteJSONResponse(w, http.StatusOK, &resp) -} - -func (ms *MockServer) postKeysClaim(w http.ResponseWriter, r *http.Request) { - var req mautrix.ReqClaimKeys - mustDecode(r, &req) - resp := mautrix.RespClaimKeys{ - OneTimeKeys: map[id.UserID]map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{}, - } - for user, devices := range req.OneTimeKeys { - resp.OneTimeKeys[user] = map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{} - for device := range devices { - keys := ms.OneTimeKeys[user][device] - for keyID, key := range keys { - if ms.PopOTKs { - delete(keys, keyID) - } - resp.OneTimeKeys[user][device] = map[id.KeyID]mautrix.OneTimeKey{ - keyID: key, - } - break - } - } - } - exhttp.WriteJSONResponse(w, http.StatusOK, &resp) -} - -func (ms *MockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) { - var req mautrix.ReqUploadKeys - mustDecode(r, &req) - - uid := ms.getUserID(r) - userID := uid.UserID - if _, ok := ms.DeviceKeys[userID]; !ok { - ms.DeviceKeys[userID] = map[id.DeviceID]mautrix.DeviceKeys{} - } - if _, ok := ms.OneTimeKeys[userID]; !ok { - ms.OneTimeKeys[userID] = map[id.DeviceID]map[id.KeyID]mautrix.OneTimeKey{} - } - - if req.DeviceKeys != nil { - ms.DeviceKeys[userID][uid.DeviceID] = *req.DeviceKeys - } - otks, ok := ms.OneTimeKeys[userID][uid.DeviceID] - if !ok { - otks = map[id.KeyID]mautrix.OneTimeKey{} - ms.OneTimeKeys[userID][uid.DeviceID] = otks - } - if req.OneTimeKeys != nil { - maps.Copy(otks, req.OneTimeKeys) - } - - exhttp.WriteJSONResponse(w, http.StatusOK, &mautrix.RespUploadKeys{ - OneTimeKeyCounts: mautrix.OTKCount{SignedCurve25519: len(otks)}, - }) -} - -func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) { - var req mautrix.UploadCrossSigningKeysReq[any] - mustDecode(r, &req) - - userID := ms.getUserID(r).UserID - ms.MasterKeys[userID] = req.Master - ms.SelfSigningKeys[userID] = req.SelfSigning - ms.UserSigningKeys[userID] = req.UserSigning - - ms.emptyResp(w, r) -} - -func (ms *MockServer) Login(t testing.TB, ctx context.Context, userID id.UserID, deviceID id.DeviceID) (*mautrix.Client, crypto.Store) { - t.Helper() - if ctx == nil { - ctx = context.TODO() - } - client, err := mautrix.NewClient(ms.Server.URL, "", "") - require.NoError(t, err) - client.Client = ms.Server.Client() - - _, err = client.Login(ctx, &mautrix.ReqLogin{ - Type: mautrix.AuthTypePassword, - Identifier: mautrix.UserIdentifier{ - Type: mautrix.IdentifierTypeUser, - User: userID.String(), - }, - DeviceID: deviceID, - Password: "password", - StoreCredentials: true, - }) - require.NoError(t, err) - - var store any - if ms.MemoryStore { - store = crypto.NewMemoryStore(nil) - client.StateStore = mautrix.NewMemoryStateStore() - } else { - store, err = dbutil.NewFromConfig("", dbutil.Config{ - PoolConfig: dbutil.PoolConfig{ - Type: "sqlite3-fk-wal", - URI: fmt.Sprintf("file:%s?mode=memory&cache=shared&_txlock=immediate", random.String(10)), - MaxOpenConns: 5, - MaxIdleConns: 1, - }, - }, nil) - require.NoError(t, err) - } - cryptoHelper, err := cryptohelper.NewCryptoHelper(client, []byte("test"), store) - require.NoError(t, err) - client.Crypto = cryptoHelper - - err = cryptoHelper.Init(ctx) - require.NoError(t, err) - - machineLog := globallog.Logger.With(). - Stringer("my_user_id", userID). - Stringer("my_device_id", deviceID). - Logger() - cryptoHelper.Machine().Log = &machineLog - - err = cryptoHelper.Machine().ShareKeys(ctx, 50) - require.NoError(t, err) - - return client, cryptoHelper.Machine().CryptoStore -} - -func (ms *MockServer) DispatchToDevice(t testing.TB, ctx context.Context, client *mautrix.Client) { - t.Helper() - - for _, evt := range ms.DeviceInbox[client.UserID][client.DeviceID] { - client.Syncer.(*mautrix.DefaultSyncer).Dispatch(ctx, &evt) - ms.DeviceInbox[client.UserID][client.DeviceID] = ms.DeviceInbox[client.UserID][client.DeviceID][1:] - } -} diff --git a/mautrix-patched/pushrules/action.go b/mautrix-patched/pushrules/action.go deleted file mode 100644 index 1af85baf..00000000 --- a/mautrix-patched/pushrules/action.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules - -import "encoding/json" - -// PushActionType is the type of a PushAction -type PushActionType string - -// The allowed push action types as specified in spec section 11.12.1.4.1. -const ( - ActionNotify PushActionType = "notify" - ActionSetTweak PushActionType = "set_tweak" - // Deprecated: this does nothing. An empty actions array is equivalent - ActionDontNotify PushActionType = "dont_notify" - // Deprecated: this was never implemented and was removed from the Matrix spec - ActionCoalesce PushActionType = "coalesce" -) - -// PushActionTweak is the type of the tweak in SetTweak push actions. -type PushActionTweak string - -// The allowed tweak types as specified in spec section 11.12.1.4.1.1. -const ( - TweakSound PushActionTweak = "sound" - TweakHighlight PushActionTweak = "highlight" -) - -// PushActionArray is an array of PushActions. -type PushActionArray []*PushAction - -// PushActionArrayShould contains the important information parsed from a PushActionArray. -type PushActionArrayShould struct { - // Whether the event in question should trigger a notification. - Notify bool - // Whether the event in question should be highlighted. - Highlight bool - - // Whether the event in question should trigger a sound alert. - PlaySound bool - // The name of the sound to play if PlaySound is true. - SoundName string -} - -var ShouldDoNothing = PushActionArrayShould{} - -// Should parses this push action array and returns the relevant details wrapped in a PushActionArrayShould struct. -func (actions PushActionArray) Should() (should PushActionArrayShould) { - for _, action := range actions { - switch action.Action { - case ActionNotify, ActionCoalesce: - should.Notify = true - case ActionSetTweak: - switch action.Tweak { - case TweakHighlight: - var ok bool - should.Highlight, ok = action.Value.(bool) - if !ok { - // Highlight value not specified, so assume true since the tweak is set. - should.Highlight = true - } - case TweakSound: - should.SoundName = action.Value.(string) - should.PlaySound = len(should.SoundName) > 0 - } - } - } - return -} - -// PushAction is a single action that should be triggered when receiving a message. -type PushAction struct { - Action PushActionType `json:"action"` - Tweak PushActionTweak `json:"set_tweak,omitempty"` - Value any `json:"value,omitempty"` -} - -// UnmarshalJSON parses JSON into this PushAction. -// -// - If the JSON is a single string, the value is stored in the Action field. -// - If the JSON is an object with the set_tweak field, Action will be set to -// "set_tweak", Tweak will be set to the value of the set_tweak field and -// and Value will be set to the value of the value field. -// - In any other case, the function does nothing. -func (action *PushAction) UnmarshalJSON(raw []byte) error { - var data any - - err := json.Unmarshal(raw, &data) - if err != nil { - return err - } - - switch val := data.(type) { - case string: - action.Action = PushActionType(val) - case map[string]any: - if tweak, ok := val["set_tweak"].(string); ok { - action.Action = ActionSetTweak - action.Tweak = PushActionTweak(tweak) - action.Value = val["value"] - } else if actionVal, ok := val["action"].(string); ok { - action.Action = PushActionType(actionVal) - } - } - return nil -} - -// MarshalJSON is the reverse of UnmarshalJSON() -func (action *PushAction) MarshalJSON() (raw []byte, err error) { - if action.Action == ActionSetTweak { - data := map[string]any{ - "set_tweak": action.Tweak, - "value": action.Value, - } - return json.Marshal(&data) - } - data := string(action.Action) - return json.Marshal(&data) -} diff --git a/mautrix-patched/pushrules/action_test.go b/mautrix-patched/pushrules/action_test.go deleted file mode 100644 index 9cc98985..00000000 --- a/mautrix-patched/pushrules/action_test.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/pushrules" -) - -func TestPushActionArray_Should_EmptyArrayReturnsDefaults(t *testing.T) { - should := pushrules.PushActionArray{}.Should() - assert.False(t, should.Notify) - assert.False(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) -} - -func TestPushActionArray_Should_MixedArrayReturnsExpected1(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, - }.Should() - assert.True(t, should.Notify) - assert.True(t, should.Highlight) - assert.True(t, should.PlaySound) - assert.Equal(t, "ping", should.SoundName) -} - -func TestPushActionArray_Should_MixedArrayReturnsExpected2(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: ""}, - }.Should() - assert.False(t, should.Notify) - assert.False(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) -} - -func TestPushActionArray_Should_NotifySet(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - }.Should() - assert.True(t, should.Notify) - assert.False(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) -} - -func TestPushActionArray_Should_DontNotify(t *testing.T) { - should := pushrules.PushActionArray{}.Should() - assert.False(t, should.Notify) - assert.False(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) - assert.Equal(t, should, pushrules.ShouldDoNothing) -} - -func TestPushActionArray_Should_HighlightBlank(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, - }.Should() - assert.False(t, should.Notify) - assert.True(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) -} - -func TestPushActionArray_Should_HighlightFalse(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - }.Should() - assert.False(t, should.Notify) - assert.False(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) -} - -func TestPushActionArray_Should_SoundName(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, - }.Should() - assert.False(t, should.Notify) - assert.False(t, should.Highlight) - assert.True(t, should.PlaySound) - assert.Equal(t, "ping", should.SoundName) -} - -func TestPushActionArray_Should_SoundNameEmpty(t *testing.T) { - should := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: ""}, - }.Should() - assert.False(t, should.Notify) - assert.False(t, should.Highlight) - assert.False(t, should.PlaySound) - assert.Empty(t, should.SoundName) -} - -func TestPushAction_UnmarshalJSON_InvalidJSONFails(t *testing.T) { - pa := &pushrules.PushAction{} - err := pa.UnmarshalJSON([]byte("Not JSON")) - assert.NotNil(t, err) -} - -func TestPushAction_UnmarshalJSON_InvalidTypeDoesNothing(t *testing.T) { - pa := &pushrules.PushAction{ - Action: pushrules.PushActionType("unchanged"), - Tweak: pushrules.PushActionTweak("unchanged"), - Value: "unchanged", - } - - err := pa.UnmarshalJSON([]byte(`{"foo": "bar"}`)) - assert.NoError(t, err) - err = pa.UnmarshalJSON([]byte(`9001`)) - assert.NoError(t, err) - - assert.Equal(t, pushrules.PushActionType("unchanged"), pa.Action) - assert.Equal(t, pushrules.PushActionTweak("unchanged"), pa.Tweak) - assert.Equal(t, "unchanged", pa.Value) -} - -func TestPushAction_UnmarshalJSON_StringChangesActionType(t *testing.T) { - pa := &pushrules.PushAction{ - Action: pushrules.PushActionType("unchanged"), - Tweak: pushrules.PushActionTweak("unchanged"), - Value: "unchanged", - } - - err := pa.UnmarshalJSON([]byte(`"foo"`)) - assert.NoError(t, err) - - assert.Equal(t, pushrules.PushActionType("foo"), pa.Action) - assert.Equal(t, pushrules.PushActionTweak("unchanged"), pa.Tweak) - assert.Equal(t, "unchanged", pa.Value) -} - -func TestPushAction_UnmarshalJSON_SetTweakChangesTweak(t *testing.T) { - pa := &pushrules.PushAction{ - Action: pushrules.PushActionType("unchanged"), - Tweak: pushrules.PushActionTweak("unchanged"), - Value: "unchanged", - } - - err := pa.UnmarshalJSON([]byte(`{"set_tweak": "foo", "value": 123.0}`)) - assert.NoError(t, err) - - assert.Equal(t, pushrules.ActionSetTweak, pa.Action) - assert.Equal(t, pushrules.PushActionTweak("foo"), pa.Tweak) - assert.Equal(t, 123.0, pa.Value) -} - -func TestPushAction_MarshalJSON_TweakOutputWorks(t *testing.T) { - pa := &pushrules.PushAction{ - Action: pushrules.ActionSetTweak, - Tweak: pushrules.PushActionTweak("foo"), - Value: "bar", - } - data, err := pa.MarshalJSON() - assert.NoError(t, err) - assert.Equal(t, []byte(`{"set_tweak":"foo","value":"bar"}`), data) -} - -func TestPushAction_MarshalJSON_OtherOutputWorks(t *testing.T) { - pa := &pushrules.PushAction{ - Action: pushrules.PushActionType("something else"), - Tweak: pushrules.PushActionTweak("foo"), - Value: "bar", - } - data, err := pa.MarshalJSON() - assert.NoError(t, err) - assert.Equal(t, []byte(`"something else"`), data) -} diff --git a/mautrix-patched/pushrules/condition.go b/mautrix-patched/pushrules/condition.go deleted file mode 100644 index caa717de..00000000 --- a/mautrix-patched/pushrules/condition.go +++ /dev/null @@ -1,359 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules - -import ( - "encoding/json" - "fmt" - "regexp" - "strconv" - "strings" - "unicode" - - "github.com/tidwall/gjson" - "go.mau.fi/util/glob" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// Room is an interface with the functions that are needed for processing room-specific push conditions -type Room interface { - GetOwnDisplayname() string - GetMemberCount() int -} - -type PowerLevelfulRoom interface { - Room - GetPowerLevels() *event.PowerLevelsEventContent -} - -// EventfulRoom is an extension of Room to support MSC3664. -type EventfulRoom interface { - Room - GetEvent(id.EventID) *event.Event -} - -// PushCondKind is the type of a push condition. -type PushCondKind string - -// The allowed push condition kinds as specified in https://spec.matrix.org/v1.2/client-server-api/#conditions-1 -const ( - KindEventMatch PushCondKind = "event_match" - KindContainsDisplayName PushCondKind = "contains_display_name" - KindRoomMemberCount PushCondKind = "room_member_count" - KindEventPropertyIs PushCondKind = "event_property_is" - KindEventPropertyContains PushCondKind = "event_property_contains" - KindSenderNotificationPermission PushCondKind = "sender_notification_permission" - - // MSC3664: https://github.com/matrix-org/matrix-spec-proposals/pull/3664 - - KindRelatedEventMatch PushCondKind = "related_event_match" - KindUnstableRelatedEventMatch PushCondKind = "im.nheko.msc3664.related_event_match" -) - -// PushCondition wraps a condition that is required for a specific PushRule to be used. -type PushCondition struct { - // The type of the condition. - Kind PushCondKind `json:"kind"` - // The dot-separated field of the event to match. Only applicable if kind is EventMatch. - Key string `json:"key,omitempty"` - // The glob-style pattern to match the field against. Only applicable if kind is EventMatch. - Pattern string `json:"pattern,omitempty"` - // The exact value to match the field against. Only applicable if kind is EventPropertyIs or EventPropertyContains. - Value any `json:"value,omitempty"` - // The condition that needs to be fulfilled for RoomMemberCount-type conditions. - // A decimal integer optionally prefixed by ==, <, >, >= or <=. Prefix "==" is assumed if no prefix found. - MemberCountCondition string `json:"is,omitempty"` - - // The relation type for related_event_match from MSC3664 - RelType event.RelationType `json:"rel_type,omitempty"` -} - -// MemberCountFilterRegex is the regular expression to parse the MemberCountCondition of PushConditions. -var MemberCountFilterRegex = regexp.MustCompile("^(==|[<>]=?)?([0-9]+)$") - -// Match checks if this condition is fulfilled for the given event in the given room. -func (cond *PushCondition) Match(room Room, evt *event.Event) bool { - switch cond.Kind { - case KindEventMatch, KindEventPropertyIs, KindEventPropertyContains: - return cond.matchValue(evt) - case KindRelatedEventMatch, KindUnstableRelatedEventMatch: - return cond.matchRelatedEvent(room, evt) - case KindContainsDisplayName: - return cond.matchDisplayName(room, evt) - case KindRoomMemberCount: - return cond.matchMemberCount(room) - case KindSenderNotificationPermission: - return cond.matchSenderNotificationPermission(room, evt.Sender, cond.Key) - default: - return false - } -} - -func splitWithEscaping(s string, separator, escape byte) []string { - var token []byte - var tokens []string - for i := 0; i < len(s); i++ { - if s[i] == separator { - tokens = append(tokens, string(token)) - token = token[:0] - } else if s[i] == escape && i+1 < len(s) { - i++ - token = append(token, s[i]) - } else { - token = append(token, s[i]) - } - } - tokens = append(tokens, string(token)) - return tokens -} - -func hackyNestedGet(data map[string]any, path []string) (any, bool) { - val, ok := data[path[0]] - if len(path) == 1 { - // We don't have any more path parts, return the value regardless of whether it exists or not. - return val, ok - } else if ok { - if mapVal, ok := val.(map[string]any); ok { - val, ok = hackyNestedGet(mapVal, path[1:]) - if ok { - return val, true - } - } - } - // If we don't find the key, try to combine the first two parts. - // e.g. if the key is content.m.relates_to.rel_type, we'll first try data["m"], which will fail, - // then combine m and relates_to to get data["m.relates_to"], which should succeed. - path[1] = path[0] + "." + path[1] - return hackyNestedGet(data, path[1:]) -} - -func stringifyForPushCondition(val interface{}) string { - // Implement MSC3862 to allow matching any type of field - // https://github.com/matrix-org/matrix-spec-proposals/pull/3862 - switch typedVal := val.(type) { - case string: - return typedVal - case nil: - return "null" - case float64: - // Floats aren't allowed in Matrix events, but the JSON parser always stores numbers as floats, - // so just handle that and convert to int - return strconv.FormatInt(int64(typedVal), 10) - default: - return fmt.Sprint(val) - } -} - -func (cond *PushCondition) getValue(evt *event.Event) (any, bool) { - key, subkey, _ := strings.Cut(cond.Key, ".") - - switch key { - case "type": - return evt.Type.Type, true - case "sender": - return evt.Sender.String(), true - case "room_id": - return evt.RoomID.String(), true - case "state_key": - if evt.StateKey == nil { - return nil, false - } - return *evt.StateKey, true - case "content": - // Split the match key with escaping to implement https://github.com/matrix-org/matrix-spec-proposals/pull/3873 - splitKey := splitWithEscaping(subkey, '.', '\\') - // Then do a hacky nested get that supports combining parts for the backwards-compat part of MSC3873 - return hackyNestedGet(evt.Content.Raw, splitKey) - default: - return nil, false - } -} - -func numberToInt64(a any) int64 { - switch typed := a.(type) { - case float64: - return int64(typed) - case float32: - return int64(typed) - case int: - return int64(typed) - case int8: - return int64(typed) - case int16: - return int64(typed) - case int32: - return int64(typed) - case int64: - return typed - case uint: - return int64(typed) - case uint8: - return int64(typed) - case uint16: - return int64(typed) - case uint32: - return int64(typed) - case uint64: - return int64(typed) - default: - return 0 - } -} - -func valueEquals(a, b any) bool { - // Convert floats to ints when comparing numbers (the JSON parser generates floats, but Matrix only allows integers) - // Also allow other numeric types in case something generates events manually without json - switch a.(type) { - case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - switch b.(type) { - case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - return numberToInt64(a) == numberToInt64(b) - } - } - return a == b -} - -func (cond *PushCondition) matchValue(evt *event.Event) bool { - val, ok := cond.getValue(evt) - if !ok { - return false - } - - switch cond.Kind { - case KindEventMatch, KindRelatedEventMatch, KindUnstableRelatedEventMatch: - pattern := glob.CompileWithImplicitContains(cond.Pattern) - if pattern == nil { - return false - } - return pattern.Match(stringifyForPushCondition(val)) - case KindEventPropertyIs: - return valueEquals(val, cond.Value) - case KindEventPropertyContains: - valArr, ok := val.([]any) - if !ok { - return false - } - for _, item := range valArr { - if valueEquals(item, cond.Value) { - return true - } - } - return false - default: - panic(fmt.Errorf("matchValue called for unknown condition kind %s", cond.Kind)) - } -} - -func (cond *PushCondition) getRelationEventID(relatesTo *event.RelatesTo) id.EventID { - if relatesTo == nil { - return "" - } - switch cond.RelType { - case "": - return relatesTo.EventID - case "m.in_reply_to": - if relatesTo.IsFallingBack || relatesTo.InReplyTo == nil { - return "" - } - return relatesTo.InReplyTo.EventID - default: - if relatesTo.Type != cond.RelType { - return "" - } - return relatesTo.EventID - } -} - -func (cond *PushCondition) matchRelatedEvent(room Room, evt *event.Event) bool { - var relatesTo *event.RelatesTo - if relatable, ok := evt.Content.Parsed.(event.Relatable); ok { - relatesTo = relatable.OptionalGetRelatesTo() - } else { - res := gjson.GetBytes(evt.Content.VeryRaw, `m\.relates_to`) - if res.Exists() && res.IsObject() { - _ = json.Unmarshal([]byte(res.Raw), &relatesTo) - } - } - if evtID := cond.getRelationEventID(relatesTo); evtID == "" { - return false - } else if eventfulRoom, ok := room.(EventfulRoom); !ok { - return false - } else if evt = eventfulRoom.GetEvent(relatesTo.EventID); evt == nil { - return false - } else { - return cond.matchValue(evt) - } -} - -func (cond *PushCondition) matchDisplayName(room Room, evt *event.Event) bool { - displayname := room.GetOwnDisplayname() - if len(displayname) == 0 { - return false - } - - msg, ok := evt.Content.Raw["body"].(string) - if !ok { - return false - } - - isAcceptable := func(r uint8) bool { - return unicode.IsSpace(rune(r)) || unicode.IsPunct(rune(r)) - } - length := len(displayname) - for index := strings.Index(msg, displayname); index != -1; index = strings.Index(msg, displayname) { - if (index <= 0 || isAcceptable(msg[index-1])) && (index+length >= len(msg) || isAcceptable(msg[index+length])) { - return true - } - msg = msg[index+len(displayname):] - } - return false -} - -func (cond *PushCondition) matchMemberCount(room Room) bool { - group := MemberCountFilterRegex.FindStringSubmatch(cond.MemberCountCondition) - if len(group) != 3 { - return false - } - - operator := group[1] - wantedMemberCount, _ := strconv.Atoi(group[2]) - - memberCount := room.GetMemberCount() - - switch operator { - case "==", "": - return memberCount == wantedMemberCount - case ">": - return memberCount > wantedMemberCount - case ">=": - return memberCount >= wantedMemberCount - case "<": - return memberCount < wantedMemberCount - case "<=": - return memberCount <= wantedMemberCount - default: - // Should be impossible due to regex. - return false - } -} - -func (cond *PushCondition) matchSenderNotificationPermission(room Room, sender id.UserID, key string) bool { - if key != "room" { - return false - } - plRoom, ok := room.(PowerLevelfulRoom) - if !ok { - return false - } - pls := plRoom.GetPowerLevels() - if pls == nil { - return false - } - return pls.GetUserLevel(sender) >= pls.Notifications.Room() -} diff --git a/mautrix-patched/pushrules/condition_displayname_test.go b/mautrix-patched/pushrules/condition_displayname_test.go deleted file mode 100644 index c7057401..00000000 --- a/mautrix-patched/pushrules/condition_displayname_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "maunium.net/go/mautrix/event" - - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPushCondition_Match_DisplayName(t *testing.T) { - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "tulir: test mention", - }) - evt.Sender = "@someone_else:matrix.org" - assert.True(t, displaynamePushCondition.Match(displaynameTestRoom, evt)) -} - -func TestPushCondition_Match_DisplayName_Fail(t *testing.T) { - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "not a mention", - }) - evt.Sender = "@someone_else:matrix.org" - assert.False(t, displaynamePushCondition.Match(displaynameTestRoom, evt)) -} - -func TestPushCondition_Match_DisplayName_FailsOnEmptyRoom(t *testing.T) { - emptyRoom := newFakeRoom(0) - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "tulir: this room doesn't have the owner Member available, so it fails.", - }) - evt.Sender = "@someone_else:matrix.org" - assert.False(t, displaynamePushCondition.Match(emptyRoom, evt)) -} diff --git a/mautrix-patched/pushrules/condition_eventmatch_test.go b/mautrix-patched/pushrules/condition_eventmatch_test.go deleted file mode 100644 index aab4b205..00000000 --- a/mautrix-patched/pushrules/condition_eventmatch_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "maunium.net/go/mautrix/event" - - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPushCondition_Match_KindEvent_MsgType(t *testing.T) { - condition := newMatchPushCondition("content.msgtype", "m.emote") - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "tests gomuks pushconditions", - }) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_MsgType_Fail(t *testing.T) { - condition := newMatchPushCondition("content.msgtype", "m.emote") - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "I'm testing gomuks pushconditions", - }) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_EventType(t *testing.T) { - condition := newMatchPushCondition("type", "m.room.foo") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_EventType_IllegalGlob(t *testing.T) { - condition := newMatchPushCondition("type", "m.room.invalid_glo[b") - evt := newFakeEvent(event.NewEventType("m.room.invalid_glob"), &struct{}{}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_Sender_Fail(t *testing.T) { - condition := newMatchPushCondition("sender", "@foo:maunium.net") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_RoomID(t *testing.T) { - condition := newMatchPushCondition("room_id", "!fakeroom:maunium.net") - evt := newFakeEvent(event.NewEventType(""), &struct{}{}) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_BlankStateKey(t *testing.T) { - condition := newMatchPushCondition("state_key", "") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - blankString := "" - evt.StateKey = &blankString - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_NonStateStateKey(t *testing.T) { - condition := newMatchPushCondition("state_key", "") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_BlankStateKey_Fail(t *testing.T) { - condition := newMatchPushCondition("state_key", "not blank") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_NonBlankStateKey(t *testing.T) { - condition := newMatchPushCondition("state_key", "*:maunium.net") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - evt.StateKey = (*string)(&evt.Sender) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEvent_UnknownKey(t *testing.T) { - condition := newMatchPushCondition("non-existent key", "doesn't affect anything") - evt := newFakeEvent(event.NewEventType("m.room.foo"), &struct{}{}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} diff --git a/mautrix-patched/pushrules/condition_eventpropertyis_test.go b/mautrix-patched/pushrules/condition_eventpropertyis_test.go deleted file mode 100644 index 1d26a9d3..00000000 --- a/mautrix-patched/pushrules/condition_eventpropertyis_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "maunium.net/go/mautrix/event" - - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPushCondition_Match_KindEventPropertyIs_MsgType(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.msgtype", "m.emote") - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "tests gomuks pushconditions", - }) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_MsgType_Fail(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.msgtype", "m.emote") - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "I'm testing gomuks pushconditions", - }) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_Integer(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", 5) - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": 5}) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_Integer_NoMatch(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", 0) - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "NaN"}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_String(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", "foo") - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "foo"}) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_String_NoMatch(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", "foo") - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "foo!"}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_Null(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", nil) - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": nil}) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_Null_NoMatch(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", nil) - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "a"}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_Bool(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", false) - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": false}) - assert.True(t, condition.Match(blankTestRoom, evt)) - condition = newEventPropertyIsPushCondition("content.meow", true) - evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": true}) - assert.True(t, condition.Match(blankTestRoom, evt)) -} - -func TestPushCondition_Match_KindEventPropertyIs_Bool_NoMatch(t *testing.T) { - condition := newEventPropertyIsPushCondition("content.meow", false) - evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": true}) - assert.False(t, condition.Match(blankTestRoom, evt)) - condition = newEventPropertyIsPushCondition("content.meow", true) - evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": false}) - assert.False(t, condition.Match(blankTestRoom, evt)) - condition = newEventPropertyIsPushCondition("content.meow", false) - evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": ""}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} diff --git a/mautrix-patched/pushrules/condition_membercount_test.go b/mautrix-patched/pushrules/condition_membercount_test.go deleted file mode 100644 index 451714df..00000000 --- a/mautrix-patched/pushrules/condition_membercount_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPushCondition_Match_KindMemberCount_OneToOne_ImplicitPrefix(t *testing.T) { - condition := newCountPushCondition("2") - room := newFakeRoom(2) - assert.True(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_OneToOne_ExplicitPrefix(t *testing.T) { - condition := newCountPushCondition("==2") - room := newFakeRoom(2) - assert.True(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_BigRoom(t *testing.T) { - condition := newCountPushCondition(">200") - room := newFakeRoom(201) - assert.True(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_BigRoom_Fail(t *testing.T) { - condition := newCountPushCondition(">=200") - room := newFakeRoom(199) - assert.False(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_SmallRoom(t *testing.T) { - condition := newCountPushCondition("<10") - room := newFakeRoom(9) - assert.True(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_SmallRoom_Fail(t *testing.T) { - condition := newCountPushCondition("<=10") - room := newFakeRoom(11) - assert.False(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_InvalidPrefix(t *testing.T) { - condition := newCountPushCondition("??10") - room := newFakeRoom(11) - assert.False(t, condition.Match(room, countConditionTestEvent)) -} - -func TestPushCondition_Match_KindMemberCount_InvalidCondition(t *testing.T) { - condition := newCountPushCondition("foobar") - room := newFakeRoom(1) - assert.False(t, condition.Match(room, countConditionTestEvent)) -} diff --git a/mautrix-patched/pushrules/condition_test.go b/mautrix-patched/pushrules/condition_test.go deleted file mode 100644 index 37af3e34..00000000 --- a/mautrix-patched/pushrules/condition_test.go +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" -) - -var ( - blankTestRoom pushrules.Room - displaynameTestRoom pushrules.Room - - countConditionTestEvent *event.Event - - displaynamePushCondition *pushrules.PushCondition -) - -func init() { - blankTestRoom = newFakeRoom(1) - - countConditionTestEvent = &event.Event{ - Sender: "@tulir:maunium.net", - Type: event.EventMessage, - Timestamp: 1523791120, - ID: "$123:maunium.net", - RoomID: "!fakeroom:maunium.net", - Content: event.Content{ - Raw: map[string]interface{}{ - "msgtype": "m.text", - "body": "test", - }, - Parsed: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "test", - }, - }, - } - - displaynameTestRoom = newFakeRoom(4) - displaynamePushCondition = &pushrules.PushCondition{ - Kind: pushrules.KindContainsDisplayName, - } -} - -func newFakeEvent(evtType event.Type, parsed interface{}) *event.Event { - data, err := json.Marshal(parsed) - if err != nil { - panic(err) - } - var raw map[string]interface{} - err = json.Unmarshal(data, &raw) - if err != nil { - panic(err) - } - content := event.Content{ - VeryRaw: data, - Raw: raw, - Parsed: parsed, - } - return &event.Event{ - Sender: "@tulir:maunium.net", - Type: evtType, - Timestamp: 1523791120, - ID: "$123:maunium.net", - RoomID: "!fakeroom:maunium.net", - Content: content, - } -} - -func newCountPushCondition(condition string) *pushrules.PushCondition { - return &pushrules.PushCondition{ - Kind: pushrules.KindRoomMemberCount, - MemberCountCondition: condition, - } -} - -func newMatchPushCondition(key, pattern string) *pushrules.PushCondition { - return &pushrules.PushCondition{ - Kind: pushrules.KindEventMatch, - Key: key, - Pattern: pattern, - } -} - -func newEventPropertyIsPushCondition(key string, value any) *pushrules.PushCondition { - return &pushrules.PushCondition{ - Kind: pushrules.KindEventPropertyIs, - Key: key, - Value: value, - } -} - -func TestPushCondition_Match_InvalidKind(t *testing.T) { - condition := &pushrules.PushCondition{ - Kind: pushrules.PushCondKind("invalid"), - } - evt := newFakeEvent(event.Type{Type: "m.room.foobar"}, &struct{}{}) - assert.False(t, condition.Match(blankTestRoom, evt)) -} - -type FakeRoom struct { - members map[string]*event.MemberEventContent - owner string - - events map[id.EventID]*event.Event -} - -func newFakeRoom(memberCount int) *FakeRoom { - room := &FakeRoom{ - owner: "@tulir:maunium.net", - members: make(map[string]*event.MemberEventContent), - events: make(map[id.EventID]*event.Event), - } - - if memberCount >= 1 { - room.members["@tulir:maunium.net"] = &event.MemberEventContent{ - Membership: event.MembershipJoin, - Displayname: "tulir", - } - } - - for i := 0; i < memberCount-1; i++ { - mxid := fmt.Sprintf("@extrauser_%d:matrix.org", i) - room.members[mxid] = &event.MemberEventContent{ - Membership: event.MembershipJoin, - Displayname: fmt.Sprintf("Extra User %d", i), - } - } - - return room -} - -func (fr *FakeRoom) GetMemberCount() int { - return len(fr.members) -} - -func (fr *FakeRoom) GetOwnDisplayname() string { - member, ok := fr.members[fr.owner] - if ok { - return member.Displayname - } - return "" -} - -func (fr *FakeRoom) GetEvent(evtID id.EventID) *event.Event { - return fr.events[evtID] -} diff --git a/mautrix-patched/pushrules/doc.go b/mautrix-patched/pushrules/doc.go deleted file mode 100644 index 19cd7745..00000000 --- a/mautrix-patched/pushrules/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package pushrules contains utilities to parse push notification rules. -package pushrules diff --git a/mautrix-patched/pushrules/pushgateway/gateway.go b/mautrix-patched/pushrules/pushgateway/gateway.go deleted file mode 100644 index 1c7ec0e1..00000000 --- a/mautrix-patched/pushrules/pushgateway/gateway.go +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushgateway - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" -) - -type NotificationCounts struct { - MissedCalls int `json:"missed_calls,omitempty"` - Unread int `json:"unread,omitempty"` - - BeeperServerType string `json:"com.beeper.server_type,omitempty"` - BeeperAsOfToken string `json:"com.beeper.as_of_token,omitempty"` -} - -type PushPriority string - -const ( - PushPriorityHigh PushPriority = "high" - PushPriorityLow PushPriority = "low" -) - -type PushFormat string - -const ( - PushFormatDefault PushFormat = "" - PushFormatEventIDOnly PushFormat = "event_id_only" -) - -type PusherData map[string]any - -func (pd PusherData) Format() PushFormat { - val, _ := pd["format"].(string) - return PushFormat(val) -} - -func (pd PusherData) URL() string { - val, _ := pd["url"].(string) - return val -} - -// ConvertToNotificationData returns a copy of the map with the url and format fields removed. -func (pd PusherData) ConvertToNotificationData() PusherData { - pdCopy := make(PusherData, len(pd)-2) - for key, value := range pd { - if key != "format" && key != "url" { - pdCopy[key] = value - } - } - return pdCopy -} - -type PusherKind string - -const ( - PusherKindHTTP PusherKind = "http" - PusherKindEmail PusherKind = "email" -) - -type PusherAppID string - -const ( - PusherAppEmail PusherAppID = "m.email" -) - -type Pusher struct { - AppDisplayName string `json:"app_display_name"` - AppID PusherAppID `json:"app_id"` - Data PusherData `json:"data"` - DeviceDisplayName string `json:"device_display_name"` - Kind *PusherKind `json:"kind"` - Language string `json:"lang"` - ProfileTag string `json:"profile_tag,omitempty"` - PushKey string `json:"pushkey"` -} - -type RespPushers struct { - Pushers []Pusher `json:"pushers"` -} - -type BaseDevice struct { - AppID PusherAppID `json:"app_id"` - PushKey string `json:"pushkey"` - PushKeyTS int64 `json:"pushkey_ts,omitempty"` - Data PusherData `json:"data,omitempty"` -} - -type PushKey struct { - BaseDevice - URL string `json:"url"` -} - -type Device struct { - BaseDevice - Tweaks map[pushrules.PushActionTweak]any `json:"tweaks,omitempty"` -} - -type PushNotification struct { - Devices []Device `json:"devices"` - - Counts *NotificationCounts `json:"counts,omitempty"` - - EventID id.EventID `json:"event_id,omitempty"` - Priority PushPriority `json:"prio,omitempty"` - RoomAlias id.RoomAlias `json:"room_alias,omitempty"` - RoomID id.RoomID `json:"room_id,omitempty"` - RoomName string `json:"room_name,omitempty"` - Sender id.UserID `json:"sender,omitempty"` - SenderDisplayName string `json:"sender_display_name,omitempty"` - Type string `json:"type,omitempty"` - Content json.RawMessage `json:"content,omitempty"` - UserIsTarget bool `json:"user_is_target,omitempty"` - - BeeperTTL *int `json:"com.beeper.ttl,omitempty"` - BeeperUserID id.UserID `json:"com.beeper.user_id,omitempty"` - BeeperLastFullyReadRoomID id.RoomID `json:"com.beeper.last_fully_read_room_id,omitempty"` -} - -func (pk *PushKey) Push(ctx context.Context, data *PushNotification) error { - data.Devices = []Device{{BaseDevice: pk.BaseDevice}} - return data.Push(ctx, pk.URL) -} - -type ReqPush struct { - Notification *PushNotification `json:"notification"` -} - -type RespPush struct { - Rejected []string `json:"rejected"` -} - -var ErrPushRejected error = &RespPush{} - -func (rp *RespPush) Error() string { - return fmt.Sprintf("push gateway rejected keys: %v", rp.Rejected) -} - -func (rp *RespPush) Is(other error) bool { - // Allow using errors.Is(err, ErrPushRejected) as a generic type check - // without caring about the actual rejected keys. - if other == ErrPushRejected { - return true - } - otherRespPush, ok := other.(*RespPush) - if !ok || len(otherRespPush.Rejected) != len(rp.Rejected) { - return false - } - for i, key := range otherRespPush.Rejected { - if key != rp.Rejected[i] { - return false - } - } - return true -} - -func (pn *PushNotification) Push(ctx context.Context, url string) error { - payload, err := json.Marshal(&ReqPush{Notification: pn}) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) - if err != nil { - return fmt.Errorf("failed to prepare push request: %w", err) - } - req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (notification pusher)") - req.Header.Set("Content-Type", "application/json") - var respData RespPush - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send push request: %w", err) - } else if body, err := io.ReadAll(resp.Body); err != nil { - return fmt.Errorf("failed to read push response body (status %d): %v", resp.StatusCode, err) - } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, bytes.ReplaceAll(body, []byte("\n"), []byte("\\n"))) - } else if err = json.Unmarshal(body, &respData); err != nil { - return fmt.Errorf("unexpected non-JSON body in push response (status %d): %s", resp.StatusCode, bytes.ReplaceAll(body, []byte("\n"), []byte("\\n"))) - } else if len(respData.Rejected) > 0 { - return &respData - } - return nil -} diff --git a/mautrix-patched/pushrules/pushrules.go b/mautrix-patched/pushrules/pushrules.go deleted file mode 100644 index f9bdcd2e..00000000 --- a/mautrix-patched/pushrules/pushrules.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules - -import ( - "encoding/json" - "reflect" - - "maunium.net/go/mautrix/event" -) - -// EventContent represents the content of a m.push_rules account data event. -// https://spec.matrix.org/v1.2/client-server-api/#mpush_rules -type EventContent struct { - Ruleset *PushRuleset `json:"global"` -} - -func init() { - event.TypeMap[event.AccountDataPushRules] = reflect.TypeOf(EventContent{}) -} - -// EventToPushRules converts a m.push_rules event to a PushRuleset by passing the data through JSON. -func EventToPushRules(evt *event.Event) (*PushRuleset, error) { - content := &EventContent{} - err := json.Unmarshal(evt.Content.VeryRaw, content) - if err != nil { - return nil, err - } - - return content.Ruleset, nil -} diff --git a/mautrix-patched/pushrules/pushrules_test.go b/mautrix-patched/pushrules/pushrules_test.go deleted file mode 100644 index 965f41c1..00000000 --- a/mautrix-patched/pushrules/pushrules_test.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/pushrules" -) - -func TestEventToPushRules(t *testing.T) { - evt := &event.Event{ - Type: event.AccountDataPushRules, - Timestamp: 1523380910, - Content: event.Content{ - VeryRaw: json.RawMessage(JSONExamplePushRules), - }, - } - pushRuleset, err := pushrules.EventToPushRules(evt) - assert.NoError(t, err) - assert.NotNil(t, pushRuleset) - - assert.IsType(t, pushRuleset.Override, pushrules.PushRuleArray{}) - assert.IsType(t, pushRuleset.Content, pushrules.PushRuleArray{}) - assert.IsType(t, pushRuleset.Room, pushrules.PushRuleMap{}) - assert.IsType(t, pushRuleset.Sender, pushrules.PushRuleMap{}) - assert.IsType(t, pushRuleset.Underride, pushrules.PushRuleArray{}) - assert.Len(t, pushRuleset.Override, 2) - assert.Len(t, pushRuleset.Content, 1) - assert.Empty(t, pushRuleset.Room.Map) - assert.Empty(t, pushRuleset.Sender.Map) - assert.Len(t, pushRuleset.Underride, 6) - - assert.Len(t, pushRuleset.Content[0].Actions, 3) - assert.True(t, pushRuleset.Content[0].Default) - assert.True(t, pushRuleset.Content[0].Enabled) - assert.Empty(t, pushRuleset.Content[0].Conditions) - assert.Equal(t, "alice", pushRuleset.Content[0].Pattern) - assert.Equal(t, ".m.rule.contains_user_name", pushRuleset.Content[0].RuleID) - - assert.False(t, pushRuleset.Override[0].Actions.Should().Notify) -} - -const JSONExamplePushRules = `{ - "global": { - "content": [ - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight" - } - ], - "default": true, - "enabled": true, - "pattern": "alice", - "rule_id": ".m.rule.contains_user_name" - } - ], - "override": [ - { - "actions": [ - "dont_notify" - ], - "conditions": [], - "default": true, - "enabled": false, - "rule_id": ".m.rule.master" - }, - { - "actions": [ - "dont_notify" - ], - "conditions": [ - { - "key": "content.msgtype", - "kind": "event_match", - "pattern": "m.notice" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.suppress_notices" - } - ], - "room": [], - "sender": [], - "underride": [ - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "ring" - }, - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.call.invite" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.call" - }, - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight" - } - ], - "conditions": [ - { - "kind": "contains_display_name" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.contains_display_name" - }, - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "is": "2", - "kind": "room_member_count" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.room_one_to_one" - }, - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.room.member" - }, - { - "key": "content.membership", - "kind": "event_match", - "pattern": "invite" - }, - { - "key": "state_key", - "kind": "event_match", - "pattern": "@alice:example.com" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.invite_for_me" - }, - { - "actions": [ - "notify", - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.room.member" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.member_event" - }, - { - "actions": [ - "notify", - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.room.message" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.message" - } - ] - } -}` diff --git a/mautrix-patched/pushrules/rule.go b/mautrix-patched/pushrules/rule.go deleted file mode 100644 index 9ec4e786..00000000 --- a/mautrix-patched/pushrules/rule.go +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules - -import ( - "regexp" - "strings" - - "go.mau.fi/util/exerrors" - "go.mau.fi/util/glob" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type PushRuleCollection interface { - GetMatchingRule(room Room, evt *event.Event) *PushRule - GetActions(room Room, evt *event.Event) PushActionArray -} - -type PushRuleArray []*PushRule - -func (rules PushRuleArray) SetType(typ PushRuleType) PushRuleArray { - for _, rule := range rules { - rule.Type = typ - } - return rules -} - -func (rules PushRuleArray) GetMatchingRule(room Room, evt *event.Event) *PushRule { - for _, rule := range rules { - if !rule.Match(room, evt) { - continue - } - return rule - } - return nil -} - -func (rules PushRuleArray) GetActions(room Room, evt *event.Event) PushActionArray { - return rules.GetMatchingRule(room, evt).GetActions() -} - -type PushRuleMap struct { - Map map[string]*PushRule - Type PushRuleType -} - -func (rules PushRuleArray) SetTypeAndMap(typ PushRuleType) PushRuleMap { - data := PushRuleMap{ - Map: make(map[string]*PushRule), - Type: typ, - } - for _, rule := range rules { - rule.Type = typ - data.Map[rule.RuleID] = rule - } - return data -} - -func (ruleMap PushRuleMap) GetMatchingRule(room Room, evt *event.Event) *PushRule { - var rule *PushRule - var found bool - switch ruleMap.Type { - case RoomRule: - rule, found = ruleMap.Map[string(evt.RoomID)] - case SenderRule: - rule, found = ruleMap.Map[string(evt.Sender)] - } - if found && rule.Match(room, evt) { - return rule - } - return nil -} - -func (ruleMap PushRuleMap) GetActions(room Room, evt *event.Event) PushActionArray { - return ruleMap.GetMatchingRule(room, evt).GetActions() -} - -func (ruleMap PushRuleMap) Unmap() PushRuleArray { - array := make(PushRuleArray, len(ruleMap.Map)) - index := 0 - for _, rule := range ruleMap.Map { - array[index] = rule - index++ - } - return array -} - -type PushRuleType string - -const ( - OverrideRule PushRuleType = "override" - ContentRule PushRuleType = "content" - RoomRule PushRuleType = "room" - SenderRule PushRuleType = "sender" - UnderrideRule PushRuleType = "underride" -) - -type PushRule struct { - // The type of this rule. - Type PushRuleType `json:"-"` - // The ID of this rule. - // For room-specific rules and user-specific rules, this is the room or user ID (respectively) - // For other types of rules, this doesn't affect anything. - RuleID string `json:"rule_id"` - // The actions this rule should trigger when matched. - Actions PushActionArray `json:"actions"` - // Whether this is a default rule, or has been set explicitly. - Default bool `json:"default"` - // Whether or not this push rule is enabled. - Enabled bool `json:"enabled"` - // The conditions to match in order to trigger this rule. - // Only applicable to generic underride/override rules. - Conditions []*PushCondition `json:"conditions,omitempty"` - // Pattern for content-specific push rules - Pattern string `json:"pattern,omitempty"` -} - -func (rule *PushRule) GetActions() PushActionArray { - if rule == nil { - return nil - } - return rule.Actions -} - -func (rule *PushRule) Match(room Room, evt *event.Event) bool { - if rule == nil || !rule.Enabled { - return false - } - if rule.RuleID == ".m.rule.contains_display_name" || rule.RuleID == ".m.rule.contains_user_name" || rule.RuleID == ".m.rule.roomnotif" { - if _, containsMentions := evt.Content.Raw["m.mentions"]; containsMentions { - // Disable legacy mention push rules when the event contains the new mentions key - return false - } - } - switch rule.Type { - case OverrideRule, UnderrideRule: - return rule.matchConditions(room, evt) - case ContentRule: - return rule.matchPattern(room, evt) - case RoomRule: - return id.RoomID(rule.RuleID) == evt.RoomID - case SenderRule: - return id.UserID(rule.RuleID) == evt.Sender - default: - return false - } -} - -func (rule *PushRule) matchConditions(room Room, evt *event.Event) bool { - for _, cond := range rule.Conditions { - if !cond.Match(room, evt) { - return false - } - } - return true -} - -func (rule *PushRule) matchPattern(room Room, evt *event.Event) bool { - msg, ok := evt.Content.Raw["body"].(string) - if !ok { - return false - } - var buf strings.Builder - // As per https://spec.matrix.org/unstable/client-server-api/#push-rules, content rules are case-insensitive - // and must match whole words, so wrap the converted glob in (?i) and \b. - buf.WriteString(`(?i)\b`) - // strings.Builder will never return errors - exerrors.PanicIfNotNil(glob.ToRegexPattern(rule.Pattern, &buf)) - buf.WriteString(`\b`) - pattern, err := regexp.Compile(buf.String()) - if err != nil { - return false - } - return pattern.MatchString(msg) -} diff --git a/mautrix-patched/pushrules/rule_array_test.go b/mautrix-patched/pushrules/rule_array_test.go deleted file mode 100644 index ae77d70f..00000000 --- a/mautrix-patched/pushrules/rule_array_test.go +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/pushrules" - - "testing" -) - -func TestPushRuleArray_GetActions_FirstMatchReturns(t *testing.T) { - cond1 := newMatchPushCondition("content.msgtype", "m.emote") - cond2 := newMatchPushCondition("content.body", "no match") - actions1 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, - } - rule1 := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1, cond2}, - Actions: actions1, - } - - actions2 := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, - } - rule2 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!fakeroom:maunium.net", - Actions: actions2, - } - - actions3 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, - } - rule3 := &pushrules.PushRule{ - Type: pushrules.SenderRule, - Enabled: true, - RuleID: "@tulir:maunium.net", - Actions: actions3, - } - - rules := pushrules.PushRuleArray{rule1, rule2, rule3} - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.Equal(t, rules.GetActions(blankTestRoom, evt), actions2) -} - -func TestPushRuleArray_GetActions_NoMatchesIsNil(t *testing.T) { - cond1 := newMatchPushCondition("content.msgtype", "m.emote") - cond2 := newMatchPushCondition("content.body", "no match") - actions1 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ping"}, - } - rule1 := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1, cond2}, - Actions: actions1, - } - - actions2 := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, - } - rule2 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!realroom:maunium.net", - Actions: actions2, - } - - actions3 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, - } - rule3 := &pushrules.PushRule{ - Type: pushrules.SenderRule, - Enabled: true, - RuleID: "@otheruser:maunium.net", - Actions: actions3, - } - - rules := pushrules.PushRuleArray{rule1, rule2, rule3} - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.Nil(t, rules.GetActions(blankTestRoom, evt)) -} - -func TestPushRuleMap_GetActions_RoomRuleExists(t *testing.T) { - actions1 := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, - } - rule1 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!realroom:maunium.net", - Actions: actions1, - } - - actions2 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - } - rule2 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!thirdroom:maunium.net", - Actions: actions2, - } - - actions3 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, - } - rule3 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!fakeroom:maunium.net", - Actions: actions3, - } - - rules := pushrules.PushRuleMap{ - Map: map[string]*pushrules.PushRule{ - rule1.RuleID: rule1, - rule2.RuleID: rule2, - rule3.RuleID: rule3, - }, - Type: pushrules.RoomRule, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.Equal(t, rules.GetActions(blankTestRoom, evt), actions3) -} - -func TestPushRuleMap_GetActions_RoomRuleDoesntExist(t *testing.T) { - actions1 := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, - } - rule1 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!realroom:maunium.net", - Actions: actions1, - } - - actions2 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - } - rule2 := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!thirdroom:maunium.net", - Actions: actions2, - } - - rules := pushrules.PushRuleMap{ - Map: map[string]*pushrules.PushRule{ - rule1.RuleID: rule1, - rule2.RuleID: rule2, - }, - Type: pushrules.RoomRule, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.Nil(t, rules.GetActions(blankTestRoom, evt)) -} - -func TestPushRuleMap_GetActions_SenderRuleExists(t *testing.T) { - actions1 := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, - } - rule1 := &pushrules.PushRule{ - Type: pushrules.SenderRule, - Enabled: true, - RuleID: "@tulir:maunium.net", - Actions: actions1, - } - - actions2 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - } - rule2 := &pushrules.PushRule{ - Type: pushrules.SenderRule, - Enabled: true, - RuleID: "@someone:maunium.net", - Actions: actions2, - } - - actions3 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, - } - rule3 := &pushrules.PushRule{ - Type: pushrules.SenderRule, - Enabled: true, - RuleID: "@otheruser:matrix.org", - Actions: actions3, - } - - rules := pushrules.PushRuleMap{ - Map: map[string]*pushrules.PushRule{ - rule1.RuleID: rule1, - rule2.RuleID: rule2, - rule3.RuleID: rule3, - }, - Type: pushrules.SenderRule, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.Equal(t, rules.GetActions(blankTestRoom, evt), actions1) -} - -func TestPushRuleArray_SetTypeAndMap(t *testing.T) { - actions1 := pushrules.PushActionArray{ - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight, Value: false}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "pong"}, - } - rule1 := &pushrules.PushRule{ - Enabled: true, - RuleID: "@tulir:maunium.net", - Actions: actions1, - } - - actions2 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - } - rule2 := &pushrules.PushRule{ - Enabled: true, - RuleID: "@someone:maunium.net", - Actions: actions2, - } - - actions3 := pushrules.PushActionArray{ - {Action: pushrules.ActionNotify}, - {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "meow"}, - } - rule3 := &pushrules.PushRule{ - Enabled: true, - RuleID: "@otheruser:matrix.org", - Actions: actions3, - } - - ruleArray := pushrules.PushRuleArray{rule1, rule2, rule3} - ruleMap := ruleArray.SetTypeAndMap(pushrules.SenderRule) - assert.Equal(t, pushrules.SenderRule, ruleMap.Type) - for _, rule := range ruleArray { - assert.Equal(t, rule, ruleMap.Map[rule.RuleID]) - } - newRuleArray := ruleMap.Unmap() - for _, rule := range ruleArray { - assert.Contains(t, newRuleArray, rule) - } -} diff --git a/mautrix-patched/pushrules/rule_test.go b/mautrix-patched/pushrules/rule_test.go deleted file mode 100644 index 7ff839a7..00000000 --- a/mautrix-patched/pushrules/rule_test.go +++ /dev/null @@ -1,312 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules_test - -import ( - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/pushrules" - - "testing" -) - -func TestPushRule_Match_Conditions(t *testing.T) { - cond1 := newMatchPushCondition("content.msgtype", "m.emote") - cond2 := newMatchPushCondition("content.body", "*pushrules") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1, cond2}, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Conditions_NestedKey(t *testing.T) { - cond1 := newMatchPushCondition("content.m.relates_to.rel_type", "m.replace") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1}, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - RelatesTo: &event.RelatesTo{ - Type: event.RelReplace, - EventID: "$meow", - }, - }) - assert.True(t, rule.Match(blankTestRoom, evt)) - - evt = newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Conditions_NestedKey_Boolean(t *testing.T) { - cond1 := newMatchPushCondition("content.fi.mau.will_auto_accept", "true") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1}, - } - - evt := newFakeEvent(event.StateMember, &event.MemberEventContent{ - Membership: "invite", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) - evt.Content.Raw["fi.mau.will_auto_accept"] = true - assert.True(t, rule.Match(blankTestRoom, evt)) - delete(evt.Content.Raw, "fi.mau.will_auto_accept") - assert.False(t, rule.Match(blankTestRoom, evt)) - evt.Content.Raw["fi.mau"] = map[string]interface{}{ - "will_auto_accept": true, - } - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Conditions_EscapedKey(t *testing.T) { - cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1}, - } - - evt := newFakeEvent(event.StateMember, &event.MemberEventContent{ - Membership: "invite", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) - evt.Content.Raw["fi.mau.will_auto_accept"] = true - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Conditions_EscapedKey_NoNesting(t *testing.T) { - cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1}, - } - - evt := newFakeEvent(event.StateMember, &event.MemberEventContent{ - Membership: "invite", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) - evt.Content.Raw["fi.mau"] = map[string]interface{}{ - "will_auto_accept": true, - } - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Conditions_RelatedEvent(t *testing.T) { - cond1 := &pushrules.PushCondition{ - Kind: pushrules.KindRelatedEventMatch, - Key: "sender", - Pattern: "@tulir:maunium.net", - } - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1}, - } - - evt := newFakeEvent(event.EventReaction, &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: "$meow", - Key: "🐈️", - }, - }) - roomWithEvent := newFakeRoom(1) - assert.False(t, rule.Match(roomWithEvent, evt)) - roomWithEvent.events["$meow"] = newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.True(t, rule.Match(roomWithEvent, evt)) -} - -func TestPushRule_Match_Conditions_Disabled(t *testing.T) { - cond1 := newMatchPushCondition("content.msgtype", "m.emote") - cond2 := newMatchPushCondition("content.body", "*pushrules") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: false, - Conditions: []*pushrules.PushCondition{cond1, cond2}, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Conditions_FailIfOneFails(t *testing.T) { - cond1 := newMatchPushCondition("content.msgtype", "m.emote") - cond2 := newMatchPushCondition("content.body", "*pushrules") - rule := &pushrules.PushRule{ - Type: pushrules.OverrideRule, - Enabled: true, - Conditions: []*pushrules.PushCondition{cond1, cond2}, - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "I'm testing pushrules", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Content(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.ContentRule, - Enabled: true, - Pattern: "is testing*", - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_WordBoundary(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.ContentRule, - Enabled: true, - Pattern: "test", - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is testing pushrules", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_CaseInsensitive(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.ContentRule, - Enabled: true, - Pattern: "test", - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is TeSt-InG pushrules", - }) - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Content_Fail(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.ContentRule, - Enabled: true, - Pattern: "is testing*", - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is not testing pushrules", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Content_ImplicitGlob(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.ContentRule, - Enabled: true, - Pattern: "testing", - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "is not testing pushrules", - }) - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Content_IllegalGlob(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.ContentRule, - Enabled: true, - Pattern: "this is not a valid glo[b", - } - - evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "this is not a valid glob", - }) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Room(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!fakeroom:maunium.net", - } - - evt := newFakeEvent(event.EventMessage, &struct{}{}) - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Room_Fail(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "!otherroom:maunium.net", - } - - evt := newFakeEvent(event.EventMessage, &struct{}{}) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Sender(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.SenderRule, - Enabled: true, - RuleID: "@tulir:maunium.net", - } - - evt := newFakeEvent(event.EventMessage, &struct{}{}) - assert.True(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_Sender_Fail(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.RoomRule, - Enabled: true, - RuleID: "@someone:matrix.org", - } - - evt := newFakeEvent(event.EventMessage, &struct{}{}) - assert.False(t, rule.Match(blankTestRoom, evt)) -} - -func TestPushRule_Match_UnknownTypeAlwaysFail(t *testing.T) { - rule := &pushrules.PushRule{ - Type: pushrules.PushRuleType("foobar"), - Enabled: true, - RuleID: "@someone:matrix.org", - } - - evt := newFakeEvent(event.EventMessage, &struct{}{}) - assert.False(t, rule.Match(blankTestRoom, evt)) -} diff --git a/mautrix-patched/pushrules/ruleset.go b/mautrix-patched/pushrules/ruleset.go deleted file mode 100644 index 3155d53e..00000000 --- a/mautrix-patched/pushrules/ruleset.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2020 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package pushrules - -import ( - "encoding/json" - - "maunium.net/go/mautrix/event" -) - -type PushRuleset struct { - Override PushRuleArray - Content PushRuleArray - Room PushRuleMap - Sender PushRuleMap - Underride PushRuleArray -} - -type rawPushRuleset struct { - Override PushRuleArray `json:"override"` - Content PushRuleArray `json:"content"` - Room PushRuleArray `json:"room"` - Sender PushRuleArray `json:"sender"` - Underride PushRuleArray `json:"underride"` -} - -// UnmarshalJSON parses JSON into this PushRuleset. -// -// For override, sender and underride push rule arrays, the type is added -// to each PushRule and the array is used as-is. -// -// For room and sender push rule arrays, the type is added to each PushRule -// and the array is converted to a map with the rule ID as the key and the -// PushRule as the value. -func (rs *PushRuleset) UnmarshalJSON(raw []byte) (err error) { - data := rawPushRuleset{} - err = json.Unmarshal(raw, &data) - if err != nil { - return - } - - rs.Override = data.Override.SetType(OverrideRule) - rs.Content = data.Content.SetType(ContentRule) - rs.Room = data.Room.SetTypeAndMap(RoomRule) - rs.Sender = data.Sender.SetTypeAndMap(SenderRule) - rs.Underride = data.Underride.SetType(UnderrideRule) - return -} - -// MarshalJSON is the reverse of UnmarshalJSON() -func (rs *PushRuleset) MarshalJSON() ([]byte, error) { - data := rawPushRuleset{ - Override: rs.Override, - Content: rs.Content, - Room: rs.Room.Unmap(), - Sender: rs.Sender.Unmap(), - Underride: rs.Underride, - } - return json.Marshal(&data) -} - -// DefaultPushActions is the value returned if none of the rule -// collections in a Ruleset match the event given to GetActions() -var DefaultPushActions = PushActionArray{} - -func (rs *PushRuleset) GetMatchingRule(room Room, evt *event.Event) (rule *PushRule) { - if rs == nil { - return nil - } - // Add push rule collections to array in priority order - arrays := []PushRuleCollection{rs.Override, rs.Content, rs.Room, rs.Sender, rs.Underride} - // Loop until one of the push rule collections matches the room/event combo. - for _, pra := range arrays { - if pra == nil { - continue - } - if rule = pra.GetMatchingRule(room, evt); rule != nil { - // Match found, return it. - return - } - } - // No match found - return nil -} - -// GetActions matches the given event against all of the push rule -// collections in this push ruleset in the order of priority as -// specified in spec section 11.12.1.4. -func (rs *PushRuleset) GetActions(room Room, evt *event.Event) (match PushActionArray) { - actions := rs.GetMatchingRule(room, evt).GetActions() - if actions == nil { - // No match found, return default actions. - return DefaultPushActions - } - return actions -} diff --git a/mautrix-patched/requests.go b/mautrix-patched/requests.go deleted file mode 100644 index fd63cc04..00000000 --- a/mautrix-patched/requests.go +++ /dev/null @@ -1,711 +0,0 @@ -package mautrix - -import ( - "encoding/json" - "fmt" - "strconv" - "time" - - "maunium.net/go/mautrix/crypto/signatures" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" -) - -type AuthType string - -const ( - AuthTypePassword AuthType = "m.login.password" - AuthTypeReCAPTCHA AuthType = "m.login.recaptcha" - AuthTypeOAuth2 AuthType = "m.login.oauth2" - AuthTypeSSO AuthType = "m.login.sso" - AuthTypeEmail AuthType = "m.login.email.identity" - AuthTypeMSISDN AuthType = "m.login.msisdn" - AuthTypeToken AuthType = "m.login.token" - AuthTypeDummy AuthType = "m.login.dummy" - AuthTypeAppservice AuthType = "m.login.application_service" - - AuthTypeSynapseJWT AuthType = "org.matrix.login.jwt" - - AuthTypeDevtureSharedSecret AuthType = "com.devture.shared_secret_auth" -) - -type IdentifierType string - -const ( - IdentifierTypeUser = "m.id.user" - IdentifierTypeThirdParty = "m.id.thirdparty" - IdentifierTypePhone = "m.id.phone" -) - -type Direction rune - -func (d Direction) MarshalJSON() ([]byte, error) { - return json.Marshal(string(d)) -} - -func (d *Direction) UnmarshalJSON(data []byte) error { - var str string - if err := json.Unmarshal(data, &str); err != nil { - return err - } - switch str { - case "f": - *d = DirectionForward - case "b": - *d = DirectionBackward - default: - return fmt.Errorf("invalid direction %q, must be 'f' or 'b'", str) - } - return nil -} - -const ( - DirectionForward Direction = 'f' - DirectionBackward Direction = 'b' -) - -// ReqRegister is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register -type ReqRegister[UIAType any] struct { - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - DeviceID id.DeviceID `json:"device_id,omitempty"` - InitialDeviceDisplayName string `json:"initial_device_display_name,omitempty"` - InhibitLogin bool `json:"inhibit_login,omitempty"` - RefreshToken bool `json:"refresh_token,omitempty"` - Auth UIAType `json:"auth,omitempty"` - - // Type for registration, only used for appservice user registrations - // https://spec.matrix.org/v1.2/application-service-api/#server-admin-style-permissions - Type AuthType `json:"type,omitempty"` -} - -type BaseAuthData struct { - Type AuthType `json:"type"` - Session string `json:"session,omitempty"` -} - -type UserIdentifier struct { - Type IdentifierType `json:"type"` - - User string `json:"user,omitempty"` - - Medium string `json:"medium,omitempty"` - Address string `json:"address,omitempty"` - - Country string `json:"country,omitempty"` - Phone string `json:"phone,omitempty"` -} - -// ReqLogin is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login -type ReqLogin struct { - Type AuthType `json:"type"` - Identifier UserIdentifier `json:"identifier"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` - DeviceID id.DeviceID `json:"device_id,omitempty"` - InitialDeviceDisplayName string `json:"initial_device_display_name,omitempty"` - RefreshToken bool `json:"refresh_token,omitempty"` - - // Whether or not the returned credentials should be stored in the Client - StoreCredentials bool `json:"-"` - // Whether or not the returned .well-known data should update the homeserver URL in the Client - StoreHomeserverURL bool `json:"-"` -} - -type ReqPutDevice struct { - DisplayName string `json:"display_name,omitempty"` -} - -type ReqUIAuthFallback struct { - Session string `json:"session"` - User string `json:"user"` -} - -type ReqUIAuthLogin struct { - BaseAuthData - User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` -} - -// ReqCreateRoom is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom -type ReqCreateRoom struct { - Visibility string `json:"visibility,omitempty"` - RoomAliasName string `json:"room_alias_name,omitempty"` - Name string `json:"name,omitempty"` - Topic string `json:"topic,omitempty"` - Invite []id.UserID `json:"invite,omitempty"` - Invite3PID []ReqInvite3PID `json:"invite_3pid,omitempty"` - CreationContent map[string]interface{} `json:"creation_content,omitempty"` - InitialState []*event.Event `json:"initial_state,omitempty"` - Preset string `json:"preset,omitempty"` - IsDirect bool `json:"is_direct,omitempty"` - RoomVersion id.RoomVersion `json:"room_version,omitempty"` - - PowerLevelOverride *event.PowerLevelsEventContent `json:"power_level_content_override,omitempty"` - - MeowRoomID id.RoomID `json:"fi.mau.room_id,omitempty"` - MeowCreateTS int64 `json:"fi.mau.origin_server_ts,omitempty"` - BeeperInitialMembers []id.UserID `json:"com.beeper.initial_members,omitempty"` - BeeperAutoJoinInvites bool `json:"com.beeper.auto_join_invites,omitempty"` - BeeperLocalRoomID id.RoomID `json:"com.beeper.local_room_id,omitempty"` - BeeperBridgeName string `json:"com.beeper.bridge_name,omitempty"` - BeeperBridgeAccountID string `json:"com.beeper.bridge_account_id,omitempty"` -} - -// ReqRedact is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidredacteventidtxnid -type ReqRedact struct { - Reason string - TxnID string - Extra map[string]interface{} -} - -type ReqRedactUser struct { - Reason string `json:"reason"` - Limit int `json:"-"` -} - -type ReqMembers struct { - At string `json:"at"` - Membership event.Membership `json:"membership,omitempty"` - NotMembership event.Membership `json:"not_membership,omitempty"` -} - -type ReqJoinRoom struct { - Via []string `json:"-"` - Reason string `json:"reason,omitempty"` - ThirdPartySigned any `json:"third_party_signed,omitempty"` -} - -type ReqKnockRoom struct { - Via []string `json:"-"` - Reason string `json:"reason,omitempty"` -} - -type ReqSearchUserDirectory struct { - SearchTerm string `json:"search_term"` - Limit int `json:"limit,omitempty"` -} - -type ReqMutualRooms struct { - From string `json:"-"` -} - -// ReqInvite3PID is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite-1 -// It is also a JSON object used in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom -type ReqInvite3PID struct { - IDServer string `json:"id_server"` - Medium string `json:"medium"` - Address string `json:"address"` -} - -type ReqLeave struct { - Reason string `json:"reason,omitempty"` -} - -// ReqInviteUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite -type ReqInviteUser struct { - Reason string `json:"reason,omitempty"` - UserID id.UserID `json:"user_id"` -} - -// ReqKickUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick -type ReqKickUser struct { - Reason string `json:"reason,omitempty"` - UserID id.UserID `json:"user_id"` -} - -// ReqBanUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban -type ReqBanUser struct { - Reason string `json:"reason,omitempty"` - UserID id.UserID `json:"user_id"` - - MSC4293RedactEvents bool `json:"org.matrix.msc4293.redact_events,omitempty"` -} - -// ReqUnbanUser is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban -type ReqUnbanUser struct { - Reason string `json:"reason,omitempty"` - UserID id.UserID `json:"user_id"` -} - -// ReqTyping is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid -type ReqTyping struct { - Typing bool `json:"typing"` - Timeout int64 `json:"timeout,omitempty"` -} - -type ReqPresence struct { - Presence event.Presence `json:"presence"` - StatusMsg string `json:"status_msg,omitempty"` -} - -type ReqAliasCreate struct { - RoomID id.RoomID `json:"room_id"` -} - -type OneTimeKey struct { - Key id.Curve25519 `json:"key"` - Fallback bool `json:"fallback,omitempty"` - Signatures signatures.Signatures `json:"signatures,omitempty"` - Unsigned map[string]any `json:"unsigned,omitempty"` - IsSigned bool `json:"-"` - - // Raw data in the one-time key. This must be used for signature verification to ensure unrecognized fields - // aren't thrown away (because that would invalidate the signature). - RawData json.RawMessage `json:"-"` -} - -type serializableOTK OneTimeKey - -func (otk *OneTimeKey) UnmarshalJSON(data []byte) (err error) { - if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' { - err = json.Unmarshal(data, &otk.Key) - otk.Signatures = nil - otk.Unsigned = nil - otk.IsSigned = false - } else { - err = json.Unmarshal(data, (*serializableOTK)(otk)) - otk.RawData = data - otk.IsSigned = true - } - return err -} - -func (otk *OneTimeKey) MarshalJSON() ([]byte, error) { - if !otk.IsSigned { - return json.Marshal(otk.Key) - } else { - return json.Marshal((*serializableOTK)(otk)) - } -} - -type ReqUploadKeys struct { - DeviceKeys *DeviceKeys `json:"device_keys,omitempty"` - OneTimeKeys map[id.KeyID]OneTimeKey `json:"one_time_keys,omitempty"` -} - -type ReqKeysSignatures struct { - UserID id.UserID `json:"user_id"` - DeviceID id.DeviceID `json:"device_id,omitempty"` - Algorithms []id.Algorithm `json:"algorithms,omitempty"` - Usage []id.CrossSigningUsage `json:"usage,omitempty"` - Keys map[id.KeyID]string `json:"keys"` - Signatures signatures.Signatures `json:"signatures"` -} - -type ReqUploadSignatures map[id.UserID]map[string]ReqKeysSignatures - -type DeviceKeys struct { - UserID id.UserID `json:"user_id"` - DeviceID id.DeviceID `json:"device_id"` - Algorithms []id.Algorithm `json:"algorithms"` - Keys KeyMap `json:"keys"` - Signatures signatures.Signatures `json:"signatures"` - Dehydrated bool `json:"dehydrated,omitempty"` - Unsigned map[string]any `json:"unsigned,omitempty"` - Extra map[string]any `json:"-"` -} - -type serializableDeviceKeys DeviceKeys - -func (dk *DeviceKeys) deleteStandardExtraFields() { - if len(dk.Extra) == 0 { - return - } - delete(dk.Extra, "user_id") - delete(dk.Extra, "device_id") - delete(dk.Extra, "algorithms") - delete(dk.Extra, "keys") - delete(dk.Extra, "signatures") - delete(dk.Extra, "dehydrated") - delete(dk.Extra, "unsigned") -} - -func (dk *DeviceKeys) MarshalJSON() ([]byte, error) { - dk.deleteStandardExtraFields() - return event.MarshalMerge((*serializableDeviceKeys)(dk), dk.Extra) -} - -func (dk *DeviceKeys) UnmarshalJSON(data []byte) error { - if err := json.Unmarshal(data, (*serializableDeviceKeys)(dk)); err != nil { - return err - } - if err := json.Unmarshal(data, &dk.Extra); err != nil { - return err - } - dk.deleteStandardExtraFields() - return nil -} - -type CrossSigningKeys struct { - UserID id.UserID `json:"user_id"` - Usage []id.CrossSigningUsage `json:"usage"` - Keys map[id.KeyID]id.Ed25519 `json:"keys"` - Signatures signatures.Signatures `json:"signatures,omitempty"` -} - -func (csk *CrossSigningKeys) FirstKey() id.Ed25519 { - for _, key := range csk.Keys { - return key - } - return "" -} - -type UploadCrossSigningKeysReq[UIAType any] struct { - Master CrossSigningKeys `json:"master_key"` - SelfSigning CrossSigningKeys `json:"self_signing_key"` - UserSigning CrossSigningKeys `json:"user_signing_key"` - Auth UIAType `json:"auth,omitempty"` -} - -type KeyMap map[id.DeviceKeyID]string - -func (km KeyMap) GetEd25519(deviceID id.DeviceID) id.Ed25519 { - val, ok := km[id.NewDeviceKeyID(id.KeyAlgorithmEd25519, deviceID)] - if !ok { - return "" - } - return id.Ed25519(val) -} - -func (km KeyMap) GetCurve25519(deviceID id.DeviceID) id.Curve25519 { - val, ok := km[id.NewDeviceKeyID(id.KeyAlgorithmCurve25519, deviceID)] - if !ok { - return "" - } - return id.Curve25519(val) -} - -type ReqQueryKeys struct { - DeviceKeys DeviceKeysRequest `json:"device_keys"` - Timeout int64 `json:"timeout,omitempty"` -} - -type DeviceKeysRequest map[id.UserID]DeviceIDList - -type DeviceIDList []id.DeviceID - -type ReqClaimKeys struct { - OneTimeKeys OneTimeKeysRequest `json:"one_time_keys"` - - Timeout int64 `json:"timeout,omitempty"` -} - -type OneTimeKeysRequest map[id.UserID]map[id.DeviceID]id.KeyAlgorithm - -type ReqSendToDevice struct { - Messages map[id.UserID]map[id.DeviceID]*event.Content `json:"messages"` -} - -type ReqSendEvent struct { - Timestamp int64 - TransactionID string - UnstableDelay time.Duration - UnstableStickyDuration time.Duration - DontEncrypt bool - MeowEventID id.EventID -} - -type ReqDelayedEvents struct { - DelayID id.DelayID `json:"-"` - Status event.DelayStatus `json:"-"` - NextBatch string `json:"-"` -} - -type ReqUpdateDelayedEvent struct { - DelayID id.DelayID `json:"-"` - Action event.DelayAction `json:"action"` -} - -// ReqDeviceInfo is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3devicesdeviceid -type ReqDeviceInfo struct { - DisplayName string `json:"display_name,omitempty"` -} - -// ReqDeleteDevice is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#delete_matrixclientv3devicesdeviceid -type ReqDeleteDevice[UIAType any] struct { - Auth UIAType `json:"auth,omitempty"` -} - -// ReqDeleteDevices is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3delete_devices -type ReqDeleteDevices[UIAType any] struct { - Devices []id.DeviceID `json:"devices"` - Auth UIAType `json:"auth,omitempty"` -} - -type ReqPutPushRule struct { - Before string `json:"-"` - After string `json:"-"` - - Actions []*pushrules.PushAction `json:"actions"` - Conditions []*pushrules.PushCondition `json:"conditions,omitempty"` - Pattern string `json:"pattern,omitempty"` -} - -type ReqBeeperBatchSend struct { - // ForwardIfNoMessages should be set to true if the batch should be forward - // backfilled if there are no messages currently in the room. - ForwardIfNoMessages bool `json:"forward_if_no_messages"` - Forward bool `json:"forward"` - SendNotification bool `json:"send_notification"` - MarkReadBy id.UserID `json:"mark_read_by,omitempty"` - Events []*event.Event `json:"events"` -} - -type ReqSetReadMarkers struct { - Read id.EventID `json:"m.read,omitempty"` - ReadPrivate id.EventID `json:"m.read.private,omitempty"` - FullyRead id.EventID `json:"m.fully_read,omitempty"` - // Allow moving m.fully_read backwards via MSC4446. - AllowBackward bool `json:"com.beeper.allow_backward,omitempty"` - - BeeperReadExtra interface{} `json:"com.beeper.read.extra,omitempty"` - BeeperReadPrivateExtra interface{} `json:"com.beeper.read.private.extra,omitempty"` - BeeperFullyReadExtra interface{} `json:"com.beeper.fully_read.extra,omitempty"` -} - -type BeeperInboxDone struct { - Delta int64 `json:"at_delta"` - AtOrder int64 `json:"at_order"` -} - -type ReqSetBeeperInboxState struct { - MarkedUnread *bool `json:"marked_unread,omitempty"` - Done *BeeperInboxDone `json:"done,omitempty"` - ReadMarkers *ReqSetReadMarkers `json:"read_markers,omitempty"` -} - -type ReqSendReceipt struct { - ThreadID string `json:"thread_id,omitempty"` - // Allow moving m.fully_read backwards via MSC4446. - AllowBackward bool `json:"com.beeper.allow_backward,omitempty"` -} - -type ReqPublicRooms struct { - IncludeAllNetworks bool - Limit int - Since string - ThirdPartyInstanceID string - Server string -} - -func (req *ReqPublicRooms) Query() map[string]string { - query := map[string]string{} - if req == nil { - return query - } - if req.IncludeAllNetworks { - query["include_all_networks"] = "true" - } - if req.Limit > 0 { - query["limit"] = strconv.Itoa(req.Limit) - } - if req.Since != "" { - query["since"] = req.Since - } - if req.ThirdPartyInstanceID != "" { - query["third_party_instance_id"] = req.ThirdPartyInstanceID - } - if req.Server != "" { - query["server"] = req.Server - } - return query -} - -// ReqHierarchy contains the parameters for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy -// -// As it's a GET method, there is no JSON body, so this is only query parameters. -type ReqHierarchy struct { - // A pagination token from a previous Hierarchy call. - // If specified, max_depth and suggested_only cannot be changed from the first request. - From string - // Limit for the maximum number of rooms to include per response. - // The server will apply a default value if a limit isn't provided. - Limit int - // Limit for how far to go into the space. When reached, no further child rooms will be returned. - // The server will apply a default value if a max depth isn't provided. - MaxDepth *int - // Flag to indicate whether the server should only consider suggested rooms. - // Suggested rooms are annotated in their m.space.child event contents. - SuggestedOnly bool -} - -func (req *ReqHierarchy) Query() map[string]string { - query := map[string]string{} - if req == nil { - return query - } - if req.From != "" { - query["from"] = req.From - } - if req.Limit > 0 { - query["limit"] = strconv.Itoa(req.Limit) - } - if req.MaxDepth != nil { - query["max_depth"] = strconv.Itoa(*req.MaxDepth) - } - if req.SuggestedOnly { - query["suggested_only"] = "true" - } - return query -} - -type ReqAppservicePing struct { - TxnID string `json:"transaction_id,omitempty"` -} - -type ReqBeeperMergeRoom struct { - NewRoom ReqCreateRoom `json:"create"` - Key string `json:"key"` - Rooms []id.RoomID `json:"rooms"` - User id.UserID `json:"user_id"` -} - -type BeeperSplitRoomPart struct { - UserID id.UserID `json:"user_id"` - Values []string `json:"values"` - NewRoom ReqCreateRoom `json:"create"` -} - -type ReqBeeperSplitRoom struct { - RoomID id.RoomID `json:"-"` - - Key string `json:"key"` - Parts []BeeperSplitRoomPart `json:"parts"` -} - -type ReqRoomKeysVersionCreate[A any] struct { - Algorithm id.KeyBackupAlgorithm `json:"algorithm"` - AuthData A `json:"auth_data"` -} - -type ReqRoomKeysVersionUpdate[A any] struct { - Algorithm id.KeyBackupAlgorithm `json:"algorithm"` - AuthData A `json:"auth_data"` - Version id.KeyBackupVersion `json:"version,omitempty"` -} - -type ReqKeyBackup struct { - Rooms map[id.RoomID]ReqRoomKeyBackup `json:"rooms"` -} - -type ReqRoomKeyBackup struct { - Sessions map[id.SessionID]ReqKeyBackupData `json:"sessions"` -} - -type ReqKeyBackupData struct { - FirstMessageIndex int `json:"first_message_index"` - ForwardedCount int `json:"forwarded_count"` - IsVerified bool `json:"is_verified"` - SessionData json.RawMessage `json:"session_data"` -} - -type ReqReport struct { - Reason string `json:"reason,omitempty"` - Score int `json:"score,omitempty"` -} - -type ReqGetRelations struct { - RelationType event.RelationType - EventType event.Type - - Dir Direction - From string - To string - Limit int - Recurse bool -} - -func (rgr *ReqGetRelations) PathSuffix() ClientURLPath { - if rgr.RelationType != "" { - if rgr.EventType.Type != "" { - return ClientURLPath{rgr.RelationType, rgr.EventType.Type} - } - return ClientURLPath{rgr.RelationType} - } - return ClientURLPath{} -} - -func (rgr *ReqGetRelations) Query() map[string]string { - query := map[string]string{} - if rgr.Dir != 0 { - query["dir"] = string(rgr.Dir) - } - if rgr.From != "" { - query["from"] = rgr.From - } - if rgr.To != "" { - query["to"] = rgr.To - } - if rgr.Limit > 0 { - query["limit"] = strconv.Itoa(rgr.Limit) - } - if rgr.Recurse { - query["recurse"] = "true" - } - return query -} - -// ReqSuspend is the request body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 -type ReqSuspend struct { - Suspended bool `json:"suspended"` -} - -// ReqLocked is the request body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 -type ReqLocked struct { - Locked bool `json:"locked"` -} - -type ReqSearchWrapper struct { - SearchCategories ReqSearchCategoryWrapper `json:"search_categories"` -} - -type ReqSearchCategoryWrapper struct { - RoomEvents *ReqSearch `json:"room_events"` -} - -type ReqSearch struct { - // This is a query param - NextBatch string `json:"-"` - - SearchTerm string `json:"search_term"` - Filter *FilterPart `json:"filter,omitempty"` - Keys []string `json:"keys,omitempty"` - IncludeState bool `json:"include_state,omitempty"` - OrderBy string `json:"order_by,omitempty"` - - EventContext SearchEventContext `json:"event_context,omitzero"` - Groupings SearchGroupings `json:"groupings,omitzero"` -} - -func (rs *ReqSearch) Query() map[string]string { - query := map[string]string{} - if rs.NextBatch != "" { - query["next_batch"] = rs.NextBatch - } - return query -} - -type SearchEventContext struct { - BeforeLimit int `json:"before_limit,omitempty"` - AfterLimit int `json:"after_limit,omitempty"` - IncludeProfile bool `json:"include_profile,omitempty"` -} - -func (sec SearchEventContext) IsZero() bool { - return sec.BeforeLimit == 0 && sec.AfterLimit == 0 && !sec.IncludeProfile -} - -type SearchGroupings struct { - GroupBy []SearchGroup `json:"group_by,omitempty"` -} - -func (sg SearchGroupings) IsZero() bool { - return len(sg.GroupBy) == 0 -} - -type SearchGroup struct { - Key string `json:"key"` -} diff --git a/mautrix-patched/responses.go b/mautrix-patched/responses.go deleted file mode 100644 index a7e50590..00000000 --- a/mautrix-patched/responses.go +++ /dev/null @@ -1,810 +0,0 @@ -package mautrix - -import ( - "bytes" - "encoding/json" - "maps" - "reflect" - "slices" - "strconv" - "strings" - - "github.com/tidwall/gjson" - "go.mau.fi/util/jsontime" - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// RespWhoami is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3accountwhoami -type RespWhoami struct { - UserID id.UserID `json:"user_id"` - DeviceID id.DeviceID `json:"device_id"` -} - -// RespCreateFilter is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridfilter -type RespCreateFilter struct { - FilterID string `json:"filter_id"` -} - -// RespJoinRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidjoin -type RespJoinRoom struct { - RoomID id.RoomID `json:"room_id"` -} - -// RespKnockRoom is the JSON response for https://spec.matrix.org/v1.13/client-server-api/#post_matrixclientv3knockroomidoralias -type RespKnockRoom struct { - RoomID id.RoomID `json:"room_id"` -} - -// RespLeaveRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidleave -type RespLeaveRoom struct{} - -// RespForgetRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidforget -type RespForgetRoom struct{} - -// RespInviteUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite -type RespInviteUser struct{} - -// RespKickUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick -type RespKickUser struct{} - -// RespBanUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban -type RespBanUser struct{} - -// RespUnbanUser is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban -type RespUnbanUser struct{} - -// RespTyping is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid -type RespTyping struct{} - -// RespPresence is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus -type RespPresence struct { - Presence event.Presence `json:"presence"` - LastActiveAgo int `json:"last_active_ago"` - StatusMsg string `json:"status_msg"` - CurrentlyActive bool `json:"currently_active"` -} - -// RespJoinedRooms is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3joined_rooms -type RespJoinedRooms struct { - JoinedRooms []id.RoomID `json:"joined_rooms"` -} - -// RespJoinedMembers is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidjoined_members -type RespJoinedMembers struct { - Joined map[id.UserID]JoinedMember `json:"joined"` -} - -type JoinedMember struct { - DisplayName string `json:"display_name,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` -} - -// RespMessages is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidmessages -type RespMessages struct { - Start string `json:"start"` - Chunk []*event.Event `json:"chunk"` - State []*event.Event `json:"state"` - End string `json:"end,omitempty"` -} - -// RespContext is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidcontexteventid -type RespContext struct { - End string `json:"end"` - Event *event.Event `json:"event"` - EventsAfter []*event.Event `json:"events_after"` - EventsBefore []*event.Event `json:"events_before"` - Start string `json:"start"` - State []*event.Event `json:"state"` -} - -// RespSendEvent is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid -type RespSendEvent struct { - EventID id.EventID `json:"event_id"` - - UnstableDelayID id.DelayID `json:"delay_id,omitempty"` -} - -type RespUpdateDelayedEvent struct{} - -type RespDelayedEvents struct { - Scheduled []*event.ScheduledDelayedEvent `json:"scheduled,omitempty"` - Finalised []*event.FinalisedDelayedEvent `json:"finalised,omitempty"` - NextBatch string `json:"next_batch,omitempty"` - - // Deprecated: Synapse implementation still returns this - DelayedEvents []*event.ScheduledDelayedEvent `json:"delayed_events,omitempty"` - // Deprecated: Synapse implementation still returns this - FinalisedEvents []*event.FinalisedDelayedEvent `json:"finalised_events,omitempty"` -} - -type RespRedactUserEvents struct { - IsMoreEvents bool `json:"is_more_events"` - RedactedEvents struct { - Total int `json:"total"` - SoftFailed int `json:"soft_failed"` - } `json:"redacted_events"` -} - -// RespMediaConfig is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixmediav3config -type RespMediaConfig struct { - UploadSize int64 `json:"m.upload.size,omitempty"` -} - -// RespMediaUpload is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixmediav3upload -type RespMediaUpload struct { - ContentURI id.ContentURI `json:"content_uri"` -} - -// RespCreateMXC is the JSON response for https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create -type RespCreateMXC struct { - ContentURI id.ContentURI `json:"content_uri"` - UnusedExpiresAt jsontime.UnixMilli `json:"unused_expires_at,omitempty"` - - UnstableUploadURL string `json:"com.beeper.msc3870.upload_url,omitempty"` - - // Beeper extensions for uploading unique media only once - BeeperUniqueID string `json:"com.beeper.unique_id,omitempty"` - BeeperCompletedAt jsontime.UnixMilli `json:"com.beeper.completed_at,omitempty"` -} - -// RespPreviewURL is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixmediav3preview_url -type RespPreviewURL = event.LinkPreview - -// RespUserInteractive is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#user-interactive-authentication-api -type RespUserInteractive struct { - Flows []UIAFlow `json:"flows,omitempty"` - Params map[AuthType]interface{} `json:"params,omitempty"` - Session string `json:"session,omitempty"` - Completed []string `json:"completed,omitempty"` - - ErrCode string `json:"errcode,omitempty"` - Error string `json:"error,omitempty"` -} - -type UIAFlow struct { - Stages []AuthType `json:"stages,omitempty"` -} - -// HasSingleStageFlow returns true if there exists at least 1 Flow with a single stage of stageName. -func (r RespUserInteractive) HasSingleStageFlow(stageName AuthType) bool { - for _, f := range r.Flows { - if len(f.Stages) == 1 && f.Stages[0] == stageName { - return true - } - } - return false -} - -// RespUserDisplayName is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname -type RespUserDisplayName struct { - DisplayName string `json:"displayname"` -} - -type RespUserProfile struct { - DisplayName string `json:"displayname,omitempty"` - AvatarURL id.ContentURI `json:"avatar_url,omitempty"` - Extra map[string]any `json:"-"` -} - -type marshalableUserProfile RespUserProfile - -func (r *RespUserProfile) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, &r.Extra) - if err != nil { - return err - } - r.DisplayName, _ = r.Extra["displayname"].(string) - avatarURL, _ := r.Extra["avatar_url"].(string) - if avatarURL != "" { - r.AvatarURL, _ = id.ParseContentURI(avatarURL) - } - delete(r.Extra, "displayname") - delete(r.Extra, "avatar_url") - return nil -} - -func (r *RespUserProfile) MarshalJSON() ([]byte, error) { - if len(r.Extra) == 0 { - return json.Marshal((*marshalableUserProfile)(r)) - } - marshalMap := maps.Clone(r.Extra) - if r.DisplayName != "" { - marshalMap["displayname"] = r.DisplayName - } else { - delete(marshalMap, "displayname") - } - if !r.AvatarURL.IsEmpty() { - marshalMap["avatar_url"] = r.AvatarURL.String() - } else { - delete(marshalMap, "avatar_url") - } - return json.Marshal(marshalMap) -} - -type RespSearchUserDirectory struct { - Limited bool `json:"limited"` - Results []*UserDirectoryEntry `json:"results"` -} - -type UserDirectoryEntry struct { - RespUserProfile - UserID id.UserID `json:"user_id"` -} - -func (r *UserDirectoryEntry) UnmarshalJSON(data []byte) error { - err := r.RespUserProfile.UnmarshalJSON(data) - if err != nil { - return err - } - userIDStr, _ := r.Extra["user_id"].(string) - r.UserID = id.UserID(userIDStr) - delete(r.Extra, "user_id") - return nil -} - -func (r *UserDirectoryEntry) MarshalJSON() ([]byte, error) { - if r.Extra == nil { - r.Extra = make(map[string]any) - } - r.Extra["user_id"] = r.UserID.String() - return r.RespUserProfile.MarshalJSON() -} - -type RespMutualRooms struct { - Joined []id.RoomID `json:"joined"` - NextBatch string `json:"next_batch,omitempty"` - Count int `json:"count,omitempty"` -} - -type RespRoomSummary struct { - PublicRoomInfo - - Membership event.Membership `json:"membership,omitempty"` - - UnstableRoomVersion id.RoomVersion `json:"im.nheko.summary.room_version,omitempty"` - UnstableRoomVersionOld id.RoomVersion `json:"im.nheko.summary.version,omitempty"` - UnstableEncryption id.Algorithm `json:"im.nheko.summary.encryption,omitempty"` -} - -// RespRegisterAvailable is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv3registeravailable -type RespRegisterAvailable struct { - Available bool `json:"available"` -} - -// RespRegister is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register -type RespRegister struct { - AccessToken string `json:"access_token,omitempty"` - DeviceID id.DeviceID `json:"device_id,omitempty"` - UserID id.UserID `json:"user_id"` - - RefreshToken string `json:"refresh_token,omitempty"` - ExpiresInMS int64 `json:"expires_in_ms,omitempty"` - - // Deprecated: homeserver should be parsed from the user ID - HomeServer string `json:"home_server,omitempty"` -} - -type LoginFlow struct { - Type AuthType `json:"type"` -} - -// RespLoginFlows is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3login -type RespLoginFlows struct { - Flows []LoginFlow `json:"flows"` -} - -func (rlf *RespLoginFlows) FirstFlowOfType(flowTypes ...AuthType) *LoginFlow { - for _, flow := range rlf.Flows { - for _, flowType := range flowTypes { - if flow.Type == flowType { - return &flow - } - } - } - return nil -} - -func (rlf *RespLoginFlows) HasFlow(flowType ...AuthType) bool { - return rlf.FirstFlowOfType(flowType...) != nil -} - -// RespLogin is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login -type RespLogin struct { - AccessToken string `json:"access_token"` - DeviceID id.DeviceID `json:"device_id"` - UserID id.UserID `json:"user_id"` - WellKnown *ClientWellKnown `json:"well_known,omitempty"` - - RefreshToken string `json:"refresh_token,omitempty"` - ExpiresInMS int64 `json:"expires_in_ms,omitempty"` -} - -// RespLogout is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logout -type RespLogout struct{} - -// RespCreateRoom is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom -type RespCreateRoom struct { - RoomID id.RoomID `json:"room_id"` -} - -type RespMembers struct { - Chunk []*event.Event `json:"chunk"` -} - -type LazyLoadSummary struct { - Heroes []id.UserID `json:"m.heroes,omitempty"` - JoinedMemberCount *int `json:"m.joined_member_count,omitempty"` - InvitedMemberCount *int `json:"m.invited_member_count,omitempty"` -} - -func (lls *LazyLoadSummary) IsZero() bool { - return lls == nil || (len(lls.Heroes) == 0 && ptr.Val(lls.JoinedMemberCount) == 0 && ptr.Val(lls.InvitedMemberCount) == 0) -} - -func (lls *LazyLoadSummary) MemberCount() int { - if lls == nil { - return 0 - } - return ptr.Val(lls.JoinedMemberCount) + ptr.Val(lls.InvitedMemberCount) -} - -func (lls *LazyLoadSummary) Equal(other *LazyLoadSummary) bool { - if lls == other { - return true - } else if lls == nil || other == nil { - return false - } - return ptr.Val(lls.JoinedMemberCount) == ptr.Val(other.JoinedMemberCount) && - ptr.Val(lls.InvitedMemberCount) == ptr.Val(other.InvitedMemberCount) && - slices.Equal(lls.Heroes, other.Heroes) -} - -type SyncEventsList struct { - Events []*event.Event `json:"events,omitempty"` -} - -func (sel SyncEventsList) IsZero() bool { - return len(sel.Events) == 0 -} - -type SyncTimeline struct { - SyncEventsList - Limited bool `json:"limited,omitempty"` - PrevBatch string `json:"prev_batch,omitempty"` -} - -func (st SyncTimeline) IsZero() bool { - return len(st.Events) == 0 && !st.Limited && st.PrevBatch == "" -} - -// RespSync is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync -type RespSync struct { - NextBatch string `json:"next_batch"` - - AccountData SyncEventsList `json:"account_data,omitzero"` - Presence SyncEventsList `json:"presence,omitzero"` - ToDevice SyncEventsList `json:"to_device,omitzero"` - - DeviceLists DeviceLists `json:"device_lists,omitzero"` - DeviceOTKCount OTKCount `json:"device_one_time_keys_count,omitzero"` - FallbackKeys []id.KeyAlgorithm `json:"device_unused_fallback_key_types"` - - Rooms RespSyncRooms `json:"rooms,omitzero"` -} - -type RespSyncRooms struct { - Leave map[id.RoomID]*SyncLeftRoom `json:"leave,omitempty"` - Join map[id.RoomID]*SyncJoinedRoom `json:"join,omitempty"` - Invite map[id.RoomID]*SyncInvitedRoom `json:"invite,omitempty"` - Knock map[id.RoomID]*SyncKnockedRoom `json:"knock,omitempty"` -} - -func (rsr RespSyncRooms) IsZero() bool { - return len(rsr.Leave) == 0 && len(rsr.Join) == 0 && len(rsr.Invite) == 0 && len(rsr.Knock) == 0 -} - -type DeviceLists struct { - Changed []id.UserID `json:"changed,omitempty"` - Left []id.UserID `json:"left,omitempty"` -} - -func (dl DeviceLists) IsZero() bool { - return len(dl.Changed) == 0 && len(dl.Left) == 0 -} - -type OTKCount struct { - Curve25519 int `json:"curve25519,omitempty"` - SignedCurve25519 int `json:"signed_curve25519,omitempty"` - - // For appservice OTK counts only: the user ID in question - UserID id.UserID `json:"-"` - DeviceID id.DeviceID `json:"-"` -} - -func (oc OTKCount) IsZero() bool { - return oc.Curve25519 == 0 && oc.SignedCurve25519 == 0 -} - -type SyncLeftRoom struct { - Summary LazyLoadSummary `json:"summary,omitzero"` - State SyncEventsList `json:"state,omitzero"` - StateAfter *SyncEventsList `json:"state_after,omitempty"` - Timeline SyncTimeline `json:"timeline,omitzero"` -} - -type BeeperInboxPreviewEvent struct { - EventID id.EventID `json:"event_id"` - Timestamp jsontime.UnixMilli `json:"origin_server_ts"` - Event *event.Event `json:"event,omitempty"` -} - -type SyncJoinedRoom struct { - Summary LazyLoadSummary `json:"summary,omitzero"` - State SyncEventsList `json:"state,omitzero"` - StateAfter *SyncEventsList `json:"state_after,omitempty"` - Timeline SyncTimeline `json:"timeline,omitzero"` - Ephemeral SyncEventsList `json:"ephemeral,omitzero"` - AccountData SyncEventsList `json:"account_data,omitzero"` - Sticky SyncEventsList `json:"msc4354_sticky,omitzero"` - - UnreadNotifications *UnreadNotificationCounts `json:"unread_notifications,omitempty"` - // https://github.com/matrix-org/matrix-spec-proposals/pull/2654 - MSC2654UnreadCount *int `json:"org.matrix.msc2654.unread_count,omitempty"` - // Beeper extension - BeeperInboxPreview *BeeperInboxPreviewEvent `json:"com.beeper.inbox.preview,omitempty"` -} - -type UnreadNotificationCounts struct { - HighlightCount int `json:"highlight_count"` - NotificationCount int `json:"notification_count"` -} - -type SyncInvitedRoom struct { - State SyncEventsList `json:"invite_state"` -} - -type SyncKnockedRoom struct { - State SyncEventsList `json:"knock_state"` -} - -type RespTurnServer struct { - Username string `json:"username"` - Password string `json:"password"` - TTL int `json:"ttl"` - URIs []string `json:"uris"` -} - -type RespAliasCreate struct{} -type RespAliasDelete struct{} -type RespAliasResolve struct { - RoomID id.RoomID `json:"room_id"` - Servers []string `json:"servers"` -} -type RespAliasList struct { - Aliases []id.RoomAlias `json:"aliases"` -} - -type RespUploadKeys struct { - OneTimeKeyCounts OTKCount `json:"one_time_key_counts"` -} - -type RespQueryKeys struct { - Failures map[string]interface{} `json:"failures,omitempty"` - DeviceKeys map[id.UserID]map[id.DeviceID]DeviceKeys `json:"device_keys"` - MasterKeys map[id.UserID]CrossSigningKeys `json:"master_keys"` - SelfSigningKeys map[id.UserID]CrossSigningKeys `json:"self_signing_keys"` - UserSigningKeys map[id.UserID]CrossSigningKeys `json:"user_signing_keys"` -} - -type RespClaimKeys struct { - Failures map[string]interface{} `json:"failures,omitempty"` - OneTimeKeys map[id.UserID]map[id.DeviceID]map[id.KeyID]OneTimeKey `json:"one_time_keys"` -} - -type RespUploadSignatures struct { - Failures map[string]interface{} `json:"failures,omitempty"` -} - -type RespKeyChanges struct { - Changed []id.UserID `json:"changed"` - Left []id.UserID `json:"left"` -} - -type RespSendToDevice struct{} - -// RespDevicesInfo is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3devices -type RespDevicesInfo struct { - Devices []RespDeviceInfo `json:"devices"` -} - -// RespDeviceInfo is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3devicesdeviceid -type RespDeviceInfo struct { - DeviceID id.DeviceID `json:"device_id"` - DisplayName string `json:"display_name"` - LastSeenIP string `json:"last_seen_ip"` - LastSeenTS int64 `json:"last_seen_ts"` -} - -type RespBeeperBatchSend struct { - EventIDs []id.EventID `json:"event_ids"` -} - -// RespCapabilities is the JSON response for https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3capabilities -type RespCapabilities struct { - RoomVersions *CapRoomVersions `json:"m.room_versions,omitempty"` - ChangePassword *CapBooleanTrue `json:"m.change_password,omitempty"` - SetDisplayname *CapBooleanTrue `json:"m.set_displayname,omitempty"` - SetAvatarURL *CapBooleanTrue `json:"m.set_avatar_url,omitempty"` - ThreePIDChanges *CapBooleanTrue `json:"m.3pid_changes,omitempty"` - GetLoginToken *CapBooleanTrue `json:"m.get_login_token,omitempty"` - UnstableAccountModeration *CapUnstableAccountModeration `json:"uk.timedout.msc4323,omitempty"` - - Custom map[string]interface{} `json:"-"` -} - -type serializableRespCapabilities RespCapabilities - -func (rc *RespCapabilities) UnmarshalJSON(data []byte) error { - res := gjson.GetBytes(data, "capabilities") - if !res.Exists() || !res.IsObject() { - return nil - } - if res.Index > 0 { - data = data[res.Index : res.Index+len(res.Raw)] - } else { - data = []byte(res.Raw) - } - err := json.Unmarshal(data, (*serializableRespCapabilities)(rc)) - if err != nil { - return err - } - err = json.Unmarshal(data, &rc.Custom) - if err != nil { - return err - } - // Remove non-custom capabilities from the custom map so that they don't get overridden when serializing back - for _, field := range reflect.VisibleFields(reflect.TypeOf(rc).Elem()) { - jsonTag := strings.Split(field.Tag.Get("json"), ",")[0] - if jsonTag != "-" && jsonTag != "" { - delete(rc.Custom, jsonTag) - } - } - return nil -} - -func (rc *RespCapabilities) MarshalJSON() ([]byte, error) { - marshalableCopy := make(map[string]interface{}, len(rc.Custom)) - val := reflect.ValueOf(rc).Elem() - for _, field := range reflect.VisibleFields(val.Type()) { - jsonTag := strings.Split(field.Tag.Get("json"), ",")[0] - if jsonTag != "-" && jsonTag != "" { - fieldVal := val.FieldByIndex(field.Index) - if !fieldVal.IsNil() { - marshalableCopy[jsonTag] = fieldVal.Interface() - } - } - } - if rc.Custom != nil { - for key, value := range rc.Custom { - marshalableCopy[key] = value - } - } - var buf bytes.Buffer - buf.WriteString(`{"capabilities":`) - err := json.NewEncoder(&buf).Encode(marshalableCopy) - if err != nil { - return nil, err - } - buf.WriteByte('}') - return buf.Bytes(), nil -} - -type CapBoolean struct { - Enabled bool `json:"enabled"` -} - -type CapBooleanTrue CapBoolean - -// IsEnabled returns true if the capability is either enabled explicitly or not specified (nil) -func (cb *CapBooleanTrue) IsEnabled() bool { - // Default to true when - return cb == nil || cb.Enabled -} - -type CapBooleanFalse CapBoolean - -// IsEnabled returns true if the capability is enabled explicitly. If it's not specified, this returns false. -func (cb *CapBooleanFalse) IsEnabled() bool { - return cb != nil && cb.Enabled -} - -type CapRoomVersionStability string - -const ( - CapRoomVersionStable CapRoomVersionStability = "stable" - CapRoomVersionUnstable CapRoomVersionStability = "unstable" -) - -type CapRoomVersions struct { - Default string `json:"default"` - Available map[string]CapRoomVersionStability `json:"available"` -} - -func (vers *CapRoomVersions) IsStable(version string) bool { - if vers == nil || vers.Available == nil { - val, err := strconv.Atoi(version) - return err == nil && val > 0 - } - return vers.Available[version] == CapRoomVersionStable -} - -func (vers *CapRoomVersions) IsAvailable(version string) bool { - if vers == nil || vers.Available == nil { - return false - } - _, available := vers.Available[version] - return available -} - -type CapUnstableAccountModeration struct { - Suspend bool `json:"suspend"` - Lock bool `json:"lock"` -} - -type RespPublicRooms struct { - Chunk []*PublicRoomInfo `json:"chunk"` - NextBatch string `json:"next_batch,omitempty"` - PrevBatch string `json:"prev_batch,omitempty"` - TotalRoomCountEstimate int `json:"total_room_count_estimate"` -} - -type PublicRoomInfo struct { - RoomID id.RoomID `json:"room_id"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - CanonicalAlias id.RoomAlias `json:"canonical_alias,omitempty"` - GuestCanJoin bool `json:"guest_can_join"` - JoinRule event.JoinRule `json:"join_rule,omitempty"` - Name string `json:"name,omitempty"` - NumJoinedMembers int `json:"num_joined_members"` - RoomType event.RoomType `json:"room_type"` - Topic string `json:"topic,omitempty"` - WorldReadable bool `json:"world_readable"` - - RoomVersion id.RoomVersion `json:"room_version,omitempty"` - Encryption id.Algorithm `json:"encryption,omitempty"` - AllowedRoomIDs []id.RoomID `json:"allowed_room_ids,omitempty"` -} - -// RespHierarchy is the JSON response for https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy -type RespHierarchy struct { - NextBatch string `json:"next_batch,omitempty"` - Rooms []*ChildRoomsChunk `json:"rooms"` -} - -type ChildRoomsChunk struct { - PublicRoomInfo - ChildrenState []*event.Event `json:"children_state"` -} - -type RespAppservicePing struct { - DurationMS int64 `json:"duration_ms"` -} - -type RespBeeperMergeRoom RespCreateRoom - -type RespBeeperSplitRoom struct { - RoomIDs map[string]id.RoomID `json:"room_ids"` -} - -type RespTimestampToEvent struct { - EventID id.EventID `json:"event_id"` - Timestamp jsontime.UnixMilli `json:"origin_server_ts"` -} - -type RespRoomKeysVersionCreate struct { - Version id.KeyBackupVersion `json:"version"` -} - -type RespRoomKeysVersion[A any] struct { - Algorithm id.KeyBackupAlgorithm `json:"algorithm"` - AuthData A `json:"auth_data"` - Count int `json:"count"` - ETag string `json:"etag"` - Version id.KeyBackupVersion `json:"version"` -} - -type RespRoomKeys[S any] struct { - Rooms map[id.RoomID]RespRoomKeyBackup[S] `json:"rooms"` -} - -type RespRoomKeyBackup[S any] struct { - Sessions map[id.SessionID]RespKeyBackupData[S] `json:"sessions"` -} - -type RespKeyBackupData[S any] struct { - FirstMessageIndex int `json:"first_message_index"` - ForwardedCount int `json:"forwarded_count"` - IsVerified bool `json:"is_verified"` - SessionData S `json:"session_data"` -} - -type RespRoomKeysUpdate struct { - Count int `json:"count"` - ETag string `json:"etag"` -} - -type RespOpenIDToken struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - MatrixServerName string `json:"matrix_server_name"` - TokenType string `json:"token_type"` // Always "Bearer" -} - -type RespGetRelations struct { - Chunk []*event.Event `json:"chunk"` - NextBatch string `json:"next_batch,omitempty"` - PrevBatch string `json:"prev_batch,omitempty"` - RecursionDepth int `json:"recursion_depth,omitempty"` -} - -// RespSuspended is the response body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 -type RespSuspended struct { - Suspended bool `json:"suspended"` -} - -// RespLocked is the response body for https://github.com/matrix-org/matrix-spec-proposals/pull/4323 -type RespLocked struct { - Locked bool `json:"locked"` -} - -type ConnectionInfo struct { - IP string `json:"ip,omitempty"` - LastSeen jsontime.UnixMilli `json:"last_seen,omitempty"` - UserAgent string `json:"user_agent,omitempty"` -} - -type SessionInfo struct { - Connections []ConnectionInfo `json:"connections,omitempty"` -} - -type DeviceInfo struct { - Sessions []SessionInfo `json:"sessions,omitempty"` -} - -// RespWhoIs is the response body for https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3adminwhoisuserid -type RespWhoIs struct { - UserID id.UserID `json:"user_id,omitempty"` - Devices map[id.DeviceID]DeviceInfo `json:"devices,omitempty"` -} - -type RespSearchWrapper struct { - SearchCategories RespSearchCategoryWrapper `json:"search_categories"` -} - -type RespSearchCategoryWrapper struct { - RoomEvents *RespSearch `json:"room_events"` -} - -type RespSearch struct { - Count int `json:"count"` - Highlights []string `json:"highlights,omitempty"` - NextBatch string `json:"next_batch,omitempty"` - Groups map[string]map[string]SearchResultGroup `json:"groups,omitempty"` - Results []*SearchResult `json:"results,omitempty"` - State map[id.RoomID][]*event.Event `json:"state,omitempty"` -} - -type SearchResultGroup struct { - NextBatch string `json:"next_batch,omitempty"` - Order int `json:"order"` - Results []string `json:"results"` -} - -type SearchResult struct { - Rank float64 `json:"rank"` - Event *event.Event `json:"result"` - Context *RespContext `json:"context,omitempty"` -} diff --git a/mautrix-patched/responses_test.go b/mautrix-patched/responses_test.go deleted file mode 100644 index c2f64123..00000000 --- a/mautrix-patched/responses_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/canonicaljson" -) - -const sampleData = `{ - "capabilities": { - "m.room_versions": { - "default": "9", - "available": { - "1": "stable", - "2": "stable", - "3": "stable", - "4": "stable", - "5": "stable", - "6": "stable", - "org.matrix.msc2176": "unstable", - "7": "stable", - "8": "stable", - "9": "stable", - "org.matrix.msc2716v3": "unstable", - "org.matrix.msc3787": "unstable", - "10": "stable" - } - }, - "m.change_password": { - "enabled": true - }, - "m.set_displayname": { - "enabled": true - }, - "m.3pid_changes": { - "enabled": false - }, - "fi.mau.custom_field": { - "🐈️": true - } - } -}` - -var sampleObject = mautrix.RespCapabilities{ - RoomVersions: &mautrix.CapRoomVersions{ - Default: "9", - Available: map[string]mautrix.CapRoomVersionStability{ - "1": "stable", - "2": "stable", - "3": "stable", - "4": "stable", - "5": "stable", - "6": "stable", - "org.matrix.msc2176": "unstable", - "7": "stable", - "8": "stable", - "9": "stable", - "org.matrix.msc2716v3": "unstable", - "org.matrix.msc3787": "unstable", - "10": "stable", - }, - }, - ChangePassword: &mautrix.CapBooleanTrue{Enabled: true}, - SetDisplayname: &mautrix.CapBooleanTrue{Enabled: true}, - ThreePIDChanges: &mautrix.CapBooleanTrue{Enabled: false}, - Custom: map[string]interface{}{ - "fi.mau.custom_field": map[string]interface{}{ - "🐈️": true, - }, - }, -} - -func TestRespCapabilities_UnmarshalJSON(t *testing.T) { - var caps mautrix.RespCapabilities - err := json.Unmarshal([]byte(sampleData), &caps) - require.NoError(t, err) - - require.NotNil(t, caps.RoomVersions) - assert.Equal(t, "9", caps.RoomVersions.Default) - assert.True(t, caps.RoomVersions.IsStable("10")) - - // Omitted capabilities still support IsEnabled(), and this one defaults to true - assert.Nil(t, caps.SetAvatarURL) - assert.True(t, caps.SetAvatarURL.IsEnabled()) - - assert.True(t, caps.SetDisplayname.IsEnabled()) - assert.False(t, caps.ThreePIDChanges.IsEnabled()) - - assert.Contains(t, caps.Custom, "fi.mau.custom_field") - assert.NotContains(t, caps.Custom, "m.room_versions") -} - -func TestRespCapabilities_MarshalJSON(t *testing.T) { - marshaled, err := canonicaljson.Marshal(&sampleObject) - require.NoError(t, err) - origData := json.RawMessage(sampleData) - require.NoError(t, canonicaljson.Canonicalize(&origData)) - assert.Equal(t, string(marshaled), string(origData)) - assert.Len(t, sampleObject.Custom, 1) -} diff --git a/mautrix-patched/room.go b/mautrix-patched/room.go deleted file mode 100644 index 4292bff5..00000000 --- a/mautrix-patched/room.go +++ /dev/null @@ -1,52 +0,0 @@ -package mautrix - -import ( - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// Room represents a single Matrix room. -type Room struct { - ID id.RoomID - State RoomStateMap -} - -// UpdateState updates the room's current state with the given Event. This will clobber events based -// on the type/state_key combination. -func (room Room) UpdateState(evt *event.Event) { - _, exists := room.State[evt.Type] - if !exists { - room.State[evt.Type] = make(map[string]*event.Event) - } - room.State[evt.Type][*evt.StateKey] = evt -} - -// GetStateEvent returns the state event for the given type/state_key combo, or nil. -func (room Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event { - stateEventMap := room.State[eventType] - evt := stateEventMap[stateKey] - return evt -} - -// GetMembershipState returns the membership state of the given user ID in this room. If there is -// no entry for this member, 'leave' is returned for consistency with left users. -func (room Room) GetMembershipState(userID id.UserID) event.Membership { - state := event.MembershipLeave - evt := room.GetStateEvent(event.StateMember, string(userID)) - if evt != nil { - membership, ok := evt.Content.Raw["membership"].(string) - if ok { - state = event.Membership(membership) - } - } - return state -} - -// NewRoom creates a new Room with the given ID -func NewRoom(roomID id.RoomID) *Room { - // Init the State map and return a pointer to the Room - return &Room{ - ID: roomID, - State: make(RoomStateMap), - } -} diff --git a/mautrix-patched/sqlstatestore/statestore.go b/mautrix-patched/sqlstatestore/statestore.go deleted file mode 100644 index 11957dfa..00000000 --- a/mautrix-patched/sqlstatestore/statestore.go +++ /dev/null @@ -1,495 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package sqlstatestore - -import ( - "context" - "database/sql" - "embed" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/rs/zerolog" - "go.mau.fi/util/confusable" - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exslices" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -//go:embed *.sql -var rawUpgrades embed.FS - -var UpgradeTable dbutil.UpgradeTable - -func init() { - UpgradeTable.RegisterFS(rawUpgrades) -} - -const VersionTableName = "mx_version" - -type SQLStateStore struct { - *dbutil.Database - IsBridge bool - - DisableNameDisambiguation bool -} - -func NewSQLStateStore(db *dbutil.Database, log dbutil.DatabaseLogger, isBridge bool) *SQLStateStore { - return &SQLStateStore{ - Database: db.Child(VersionTableName, UpgradeTable, log), - IsBridge: isBridge, - } -} - -func (store *SQLStateStore) IsRegistered(ctx context.Context, userID id.UserID) (bool, error) { - var isRegistered bool - err := store. - QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM mx_registrations WHERE user_id=$1)", userID). - Scan(&isRegistered) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return isRegistered, err -} - -func (store *SQLStateStore) MarkRegistered(ctx context.Context, userID id.UserID) error { - if userID == "" { - return fmt.Errorf("user ID is empty") - } - _, err := store.Exec(ctx, "INSERT INTO mx_registrations (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING", userID) - return err -} - -type Member struct { - id.UserID - event.MemberEventContent - NameSkeleton [32]byte -} - -func (store *SQLStateStore) GetRoomMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) (map[id.UserID]*event.MemberEventContent, error) { - args := make([]any, len(memberships)+1) - args[0] = roomID - query := "SELECT user_id, membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1" - if len(memberships) > 0 { - placeholders := make([]string, len(memberships)) - for i, membership := range memberships { - args[i+1] = string(membership) - placeholders[i] = fmt.Sprintf("$%d", i+2) - } - query = fmt.Sprintf("%s AND membership IN (%s)", query, strings.Join(placeholders, ",")) - } - rows, err := store.Query(ctx, query, args...) - members := make(map[id.UserID]*event.MemberEventContent) - return members, dbutil.NewRowIterWithError(rows, func(row dbutil.Scannable) (ret Member, err error) { - err = row.Scan(&ret.UserID, &ret.Membership, &ret.Displayname, &ret.AvatarURL) - return - }, err).Iter(func(m Member) (bool, error) { - members[m.UserID] = &m.MemberEventContent - return true, nil - }) -} - -func (store *SQLStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) (members []id.UserID, err error) { - var memberMap map[id.UserID]*event.MemberEventContent - memberMap, err = store.GetRoomMembers(ctx, roomID, event.MembershipJoin, event.MembershipInvite) - if err != nil { - return - } - members = make([]id.UserID, len(memberMap)) - i := 0 - for userID := range memberMap { - members[i] = userID - i++ - } - return -} - -func (store *SQLStateStore) GetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID) (membership event.Membership, err error) { - err = store. - QueryRow(ctx, "SELECT membership FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID). - Scan(&membership) - if errors.Is(err, sql.ErrNoRows) { - membership = event.MembershipLeave - err = nil - } - return -} - -func (store *SQLStateStore) GetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { - member, err := store.TryGetMember(ctx, roomID, userID) - if member == nil && err == nil { - member = &event.MemberEventContent{Membership: event.MembershipLeave} - } - return member, err -} - -func (store *SQLStateStore) TryGetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { - var member event.MemberEventContent - err := store. - QueryRow(ctx, "SELECT membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID). - Scan(&member.Membership, &member.Displayname, &member.AvatarURL) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - return &member, nil -} - -func (store *SQLStateStore) FindSharedRooms(ctx context.Context, userID id.UserID) ([]id.RoomID, error) { - query := ` - SELECT room_id FROM mx_user_profile - LEFT JOIN portal ON portal.mxid=mx_user_profile.room_id - WHERE mx_user_profile.user_id=$1 AND portal.encrypted=true - ` - if !store.IsBridge { - query = ` - SELECT mx_user_profile.room_id FROM mx_user_profile - LEFT JOIN mx_room_state ON mx_room_state.room_id=mx_user_profile.room_id - WHERE mx_user_profile.user_id=$1 AND mx_room_state.encryption IS NOT NULL - ` - } - rows, err := store.Query(ctx, query, userID) - return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.RoomID], err).AsList() -} - -func (store *SQLStateStore) IsInRoom(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { - return store.IsMembership(ctx, roomID, userID, "join") -} - -func (store *SQLStateStore) IsInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { - return store.IsMembership(ctx, roomID, userID, "join", "invite") -} - -func (store *SQLStateStore) IsMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool { - membership, err := store.GetMembership(ctx, roomID, userID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get membership") - return false - } - for _, allowedMembership := range allowedMemberships { - if allowedMembership == membership { - return true - } - } - return false -} - -func (store *SQLStateStore) SetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } else if userID == "" { - return fmt.Errorf("user ID is empty") - } - _, err := store.Exec(ctx, ` - INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, '', '') - ON CONFLICT (room_id, user_id) DO UPDATE SET membership=excluded.membership - `, roomID, userID, membership) - return err -} - -const insertUserProfileQuery = ` - INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url, name_skeleton) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (room_id, user_id) DO UPDATE - SET membership=excluded.membership, - displayname=excluded.displayname, - avatar_url=excluded.avatar_url, - name_skeleton=excluded.name_skeleton -` - -type userProfileRow struct { - UserID id.UserID - Membership event.Membership - Displayname string - AvatarURL id.ContentURIString - NameSkeleton []byte -} - -func (u *userProfileRow) GetMassInsertValues() [5]any { - return [5]any{u.UserID, u.Membership, u.Displayname, u.AvatarURL, u.NameSkeleton} -} - -var userProfileMassInserter = dbutil.NewMassInsertBuilder[*userProfileRow, [1]any](insertUserProfileQuery, "($1, $%d, $%d, $%d, $%d, $%d)") - -func (store *SQLStateStore) SetMember(ctx context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } else if userID == "" { - return fmt.Errorf("user ID is empty") - } - var nameSkeleton []byte - if !store.DisableNameDisambiguation && len(member.Displayname) > 0 { - nameSkeletonArr := confusable.SkeletonHash(member.Displayname) - nameSkeleton = nameSkeletonArr[:] - } - _, err := store.Exec(ctx, insertUserProfileQuery, roomID, userID, member.Membership, member.Displayname, member.AvatarURL, nameSkeleton) - return err -} - -func (store *SQLStateStore) IsConfusableName(ctx context.Context, roomID id.RoomID, currentUser id.UserID, name string) ([]id.UserID, error) { - if store.DisableNameDisambiguation { - return nil, nil - } - skeleton := confusable.SkeletonHash(name) - rows, err := store.Query(ctx, "SELECT user_id FROM mx_user_profile WHERE room_id=$1 AND name_skeleton=$2 AND user_id<>$3", roomID, skeleton[:], currentUser) - return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList() -} - -const userProfileMassInsertBatchSize = 500 - -func (store *SQLStateStore) ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } - return store.DoTxn(ctx, nil, func(ctx context.Context) error { - err := store.ClearCachedMembers(ctx, roomID, onlyMemberships...) - if err != nil { - return fmt.Errorf("failed to clear cached members: %w", err) - } - rows := make([]*userProfileRow, min(len(evts), userProfileMassInsertBatchSize)) - for _, evtsChunk := range exslices.Chunk(evts, userProfileMassInsertBatchSize) { - rows = rows[:0] - for _, evt := range evtsChunk { - content, ok := evt.Content.Parsed.(*event.MemberEventContent) - if !ok { - continue - } - row := &userProfileRow{ - UserID: id.UserID(*evt.StateKey), - Membership: content.Membership, - Displayname: content.Displayname, - AvatarURL: content.AvatarURL, - } - if !store.DisableNameDisambiguation && len(content.Displayname) > 0 { - nameSkeletonArr := confusable.SkeletonHash(content.Displayname) - row.NameSkeleton = nameSkeletonArr[:] - } - rows = append(rows, row) - } - query, args := userProfileMassInserter.Build([1]any{roomID}, rows) - _, err = store.Exec(ctx, query, args...) - if err != nil { - return fmt.Errorf("failed to insert members: %w", err) - } - } - if len(onlyMemberships) == 0 { - err = store.MarkMembersFetched(ctx, roomID) - if err != nil { - return fmt.Errorf("failed to mark members as fetched: %w", err) - } - } - return nil - }) -} - -func (store *SQLStateStore) ClearCachedMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) error { - query := "DELETE FROM mx_user_profile WHERE room_id=$1" - params := make([]any, len(memberships)+1) - params[0] = roomID - if len(memberships) > 0 { - placeholders := make([]string, len(memberships)) - for i, membership := range memberships { - placeholders[i] = "$" + strconv.Itoa(i+2) - params[i+1] = string(membership) - } - query += fmt.Sprintf(" AND membership IN (%s)", strings.Join(placeholders, ",")) - } - _, err := store.Exec(ctx, query, params...) - if err != nil { - return err - } - _, err = store.Exec(ctx, "UPDATE mx_room_state SET members_fetched=false WHERE room_id=$1", roomID) - return err -} - -func (store *SQLStateStore) HasFetchedMembers(ctx context.Context, roomID id.RoomID) (fetched bool, err error) { - err = store.QueryRow(ctx, "SELECT COALESCE(members_fetched, false) FROM mx_room_state WHERE room_id=$1", roomID).Scan(&fetched) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return -} - -func (store *SQLStateStore) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } - _, err := store.Exec(ctx, ` - INSERT INTO mx_room_state (room_id, members_fetched) VALUES ($1, true) - ON CONFLICT (room_id) DO UPDATE SET members_fetched=true - `, roomID) - return err -} - -type userAndMembership struct { - UserID id.UserID - event.MemberEventContent -} - -func (store *SQLStateStore) GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { - rows, err := store.Query(ctx, "SELECT user_id, membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1", roomID) - if err != nil { - return nil, err - } - output := make(map[id.UserID]*event.MemberEventContent) - err = dbutil.NewRowIterWithError(rows, func(row dbutil.Scannable) (res userAndMembership, err error) { - err = row.Scan(&res.UserID, &res.Membership, &res.Displayname, &res.AvatarURL) - return - }, err).Iter(func(member userAndMembership) (bool, error) { - output[member.UserID] = &member.MemberEventContent - return true, nil - }) - return output, err -} - -func (store *SQLStateStore) SetEncryptionEvent(ctx context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } - contentBytes, err := json.Marshal(content) - if err != nil { - return fmt.Errorf("failed to marshal content JSON: %w", err) - } - _, err = store.Exec(ctx, ` - INSERT INTO mx_room_state (room_id, encryption) VALUES ($1, $2) - ON CONFLICT (room_id) DO UPDATE SET encryption=excluded.encryption - `, roomID, contentBytes) - return err -} - -func (store *SQLStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { - var data []byte - err := store. - QueryRow(ctx, "SELECT encryption FROM mx_room_state WHERE room_id=$1 AND encryption IS NOT NULL", roomID). - Scan(&data) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } else if data == nil { - return nil, nil - } - var content event.EncryptionEventContent - err = json.Unmarshal(data, &content) - if err != nil { - return nil, fmt.Errorf("failed to parse content JSON: %w", err) - } - return &content, nil -} - -func (store *SQLStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { - cfg, err := store.GetEncryptionEvent(ctx, roomID) - return cfg != nil && cfg.Algorithm == id.AlgorithmMegolmV1, err -} - -func (store *SQLStateStore) SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } - _, err := store.Exec(ctx, ` - INSERT INTO mx_room_state (room_id, power_levels) VALUES ($1, $2) - ON CONFLICT (room_id) DO UPDATE SET power_levels=excluded.power_levels - `, roomID, dbutil.JSON{Data: levels}) - return err -} - -func (store *SQLStateStore) GetPowerLevels(ctx context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { - levels = &event.PowerLevelsEventContent{} - err = store. - QueryRow(ctx, "SELECT power_levels, create_event FROM mx_room_state WHERE room_id=$1 AND power_levels IS NOT NULL", roomID). - Scan(&dbutil.JSON{Data: &levels}, &dbutil.JSON{Data: &levels.CreateEvent}) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - if levels.CreateEvent != nil { - err = levels.CreateEvent.Content.ParseRaw(event.StateCreate) - } - return -} - -func (store *SQLStateStore) GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) { - levels, err := store.GetPowerLevels(ctx, roomID) - if err != nil { - return 0, err - } - return levels.GetUserLevel(userID), nil -} - -func (store *SQLStateStore) GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) { - levels, err := store.GetPowerLevels(ctx, roomID) - if err != nil { - return 0, err - } - return levels.GetEventLevel(eventType), nil -} - -func (store *SQLStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) { - levels, err := store.GetPowerLevels(ctx, roomID) - if err != nil { - return false, err - } - return levels.GetUserLevel(userID) >= levels.GetEventLevel(eventType), nil -} - -func (store *SQLStateStore) SetCreate(ctx context.Context, evt *event.Event) error { - if evt.Type != event.StateCreate { - return fmt.Errorf("invalid event type for create event: %s", evt.Type) - } else if evt.RoomID == "" { - return fmt.Errorf("room ID is empty") - } - _, err := store.Exec(ctx, ` - INSERT INTO mx_room_state (room_id, create_event) VALUES ($1, $2) - ON CONFLICT (room_id) DO UPDATE SET create_event=excluded.create_event - `, evt.RoomID, dbutil.JSON{Data: evt}) - return err -} - -func (store *SQLStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (evt *event.Event, err error) { - err = store. - QueryRow(ctx, "SELECT create_event FROM mx_room_state WHERE room_id=$1 AND create_event IS NOT NULL", roomID). - Scan(&dbutil.JSON{Data: &evt}) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { - return nil, err - } - if evt != nil { - err = evt.Content.ParseRaw(event.StateCreate) - } - return -} - -func (store *SQLStateStore) SetJoinRules(ctx context.Context, roomID id.RoomID, rules *event.JoinRulesEventContent) error { - if roomID == "" { - return fmt.Errorf("room ID is empty") - } - _, err := store.Exec(ctx, ` - INSERT INTO mx_room_state (room_id, join_rules) VALUES ($1, $2) - ON CONFLICT (room_id) DO UPDATE SET join_rules=excluded.join_rules - `, roomID, dbutil.JSON{Data: rules}) - return err -} - -func (store *SQLStateStore) GetJoinRules(ctx context.Context, roomID id.RoomID) (levels *event.JoinRulesEventContent, err error) { - levels = &event.JoinRulesEventContent{} - err = store. - QueryRow(ctx, "SELECT join_rules FROM mx_room_state WHERE room_id=$1 AND join_rules IS NOT NULL", roomID). - Scan(&dbutil.JSON{Data: &levels}) - if errors.Is(err, sql.ErrNoRows) { - levels = nil - err = nil - } - return -} diff --git a/mautrix-patched/sqlstatestore/v00-latest-revision.sql b/mautrix-patched/sqlstatestore/v00-latest-revision.sql deleted file mode 100644 index 4679f1c6..00000000 --- a/mautrix-patched/sqlstatestore/v00-latest-revision.sql +++ /dev/null @@ -1,32 +0,0 @@ --- v0 -> v10 (compatible with v3+): Latest revision - -CREATE TABLE mx_registrations ( - user_id TEXT PRIMARY KEY -); - --- only: postgres -CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock'); - -CREATE TABLE mx_user_profile ( - room_id TEXT, - user_id TEXT, - membership membership NOT NULL, - displayname TEXT NOT NULL DEFAULT '', - avatar_url TEXT NOT NULL DEFAULT '', - - name_skeleton bytea, - - PRIMARY KEY (room_id, user_id) -); - -CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership); -CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton); - -CREATE TABLE mx_room_state ( - room_id TEXT PRIMARY KEY, - power_levels jsonb, - encryption jsonb, - create_event jsonb, - join_rules jsonb, - members_fetched BOOLEAN NOT NULL DEFAULT false -); diff --git a/mautrix-patched/sqlstatestore/v02-membership-enum.sql b/mautrix-patched/sqlstatestore/v02-membership-enum.sql deleted file mode 100644 index cee3e693..00000000 --- a/mautrix-patched/sqlstatestore/v02-membership-enum.sql +++ /dev/null @@ -1,6 +0,0 @@ --- v2: Use enum for membership field on Postgres --- only: postgres - -CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock'); -UPDATE mx_user_profile SET membership='leave' WHERE LOWER(membership) NOT IN ('join', 'leave', 'invite', 'ban', 'knock'); -ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership USING LOWER(membership)::membership; diff --git a/mautrix-patched/sqlstatestore/v03-no-null.sql b/mautrix-patched/sqlstatestore/v03-no-null.sql deleted file mode 100644 index 60600099..00000000 --- a/mautrix-patched/sqlstatestore/v03-no-null.sql +++ /dev/null @@ -1,10 +0,0 @@ --- v3: Disable nulls in mx_user_profile - -UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL; -UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL; - --- only: postgres for next 4 lines -ALTER TABLE mx_user_profile ALTER COLUMN displayname SET DEFAULT ''; -ALTER TABLE mx_user_profile ALTER COLUMN displayname SET NOT NULL; -ALTER TABLE mx_user_profile ALTER COLUMN avatar_url SET DEFAULT ''; -ALTER TABLE mx_user_profile ALTER COLUMN avatar_url SET NOT NULL; diff --git a/mautrix-patched/sqlstatestore/v04-encryption-info.sql b/mautrix-patched/sqlstatestore/v04-encryption-info.sql deleted file mode 100644 index dc46bf3e..00000000 --- a/mautrix-patched/sqlstatestore/v04-encryption-info.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v4: Store room encryption configuration -ALTER TABLE mx_room_state ADD COLUMN encryption jsonb; diff --git a/mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go b/mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go deleted file mode 100644 index b7f2b1c2..00000000 --- a/mautrix-patched/sqlstatestore/v05-mark-encryption-state-resync.go +++ /dev/null @@ -1,30 +0,0 @@ -package sqlstatestore - -import ( - "context" - "fmt" - - "go.mau.fi/util/dbutil" -) - -func init() { - UpgradeTable.Register(-1, 5, 0, "Mark rooms that need crypto state event resynced", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) error { - portalExists, err := db.TableExists(ctx, "portal") - if err != nil { - return fmt.Errorf("failed to check if portal table exists") - } - if portalExists { - _, err = db.Exec(ctx, ` - INSERT INTO mx_room_state (room_id, encryption) - SELECT portal.mxid, '{"resync":true}' FROM portal WHERE portal.encrypted=true AND portal.mxid IS NOT NULL - ON CONFLICT (room_id) DO UPDATE - SET encryption=excluded.encryption - WHERE mx_room_state.encryption IS NULL - `) - if err != nil { - return err - } - } - return nil - }) -} diff --git a/mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go b/mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go deleted file mode 100644 index d0d1d502..00000000 --- a/mautrix-patched/sqlstatestore/v06-displayname-disambiguation.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package sqlstatestore - -import ( - "context" - - "go.mau.fi/util/confusable" - "go.mau.fi/util/dbutil" - - "maunium.net/go/mautrix/id" -) - -type roomUserName struct { - RoomID id.RoomID - UserID id.UserID - Name string -} - -func init() { - UpgradeTable.Register(-1, 6, 3, "Add disambiguation column for user profiles", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) error { - _, err := db.Exec(ctx, ` - ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea; - CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership); - CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton); - `) - if err != nil { - return err - } - const ChunkSize = 1000 - const GetEntriesChunkQuery = "SELECT room_id, user_id, displayname FROM mx_user_profile WHERE displayname<>'' LIMIT $1 OFFSET $2" - const SetSkeletonHashQuery = `UPDATE mx_user_profile SET name_skeleton = $3 WHERE room_id = $1 AND user_id = $2` - for offset := 0; ; offset += ChunkSize { - entries, err := dbutil.NewSimpleReflectRowIter[roomUserName](db.Query(ctx, GetEntriesChunkQuery, ChunkSize, offset)).AsList() - if err != nil { - return err - } - for _, entry := range entries { - skel := confusable.SkeletonHash(entry.Name) - _, err = db.Exec(ctx, SetSkeletonHashQuery, entry.RoomID, entry.UserID, skel[:]) - if err != nil { - return err - } - } - if len(entries) < ChunkSize { - break - } - } - return nil - }) -} diff --git a/mautrix-patched/sqlstatestore/v07-full-member-flag.sql b/mautrix-patched/sqlstatestore/v07-full-member-flag.sql deleted file mode 100644 index 32f2ef6c..00000000 --- a/mautrix-patched/sqlstatestore/v07-full-member-flag.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v7 (compatible with v3+): Add flag for whether the full member list has been fetched -ALTER TABLE mx_room_state ADD COLUMN members_fetched BOOLEAN NOT NULL DEFAULT false; diff --git a/mautrix-patched/sqlstatestore/v08-create-event.sql b/mautrix-patched/sqlstatestore/v08-create-event.sql deleted file mode 100644 index 9f1b55c9..00000000 --- a/mautrix-patched/sqlstatestore/v08-create-event.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v8 (compatible with v3+): Add create event to room state table -ALTER TABLE mx_room_state ADD COLUMN create_event jsonb; diff --git a/mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql b/mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql deleted file mode 100644 index ca951068..00000000 --- a/mautrix-patched/sqlstatestore/v09-clear-empty-room-ids.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v9 (compatible with v3+): Clear invalid rows -DELETE FROM mx_room_state WHERE room_id=''; -DELETE FROM mx_user_profile WHERE room_id='' OR user_id=''; diff --git a/mautrix-patched/sqlstatestore/v10-join-rules.sql b/mautrix-patched/sqlstatestore/v10-join-rules.sql deleted file mode 100644 index 3074c46a..00000000 --- a/mautrix-patched/sqlstatestore/v10-join-rules.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v10 (compatible with v3+): Add join rules to room state table -ALTER TABLE mx_room_state ADD COLUMN join_rules jsonb; diff --git a/mautrix-patched/statestore.go b/mautrix-patched/statestore.go deleted file mode 100644 index 2c3fa9f8..00000000 --- a/mautrix-patched/statestore.go +++ /dev/null @@ -1,392 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix - -import ( - "context" - "maps" - "sync" - - "github.com/rs/zerolog" - "go.mau.fi/util/exerrors" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// StateStore is an interface for storing basic room state information. -type StateStore interface { - IsInRoom(ctx context.Context, roomID id.RoomID, userID id.UserID) bool - IsInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) bool - IsMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool - GetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) - TryGetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) - SetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error - SetMember(ctx context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error - IsConfusableName(ctx context.Context, roomID id.RoomID, currentUser id.UserID, name string) ([]id.UserID, error) - ClearCachedMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) error - ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error - - SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error - GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error) - - SetCreate(ctx context.Context, evt *event.Event) error - GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) - - GetJoinRules(ctx context.Context, roomID id.RoomID) (*event.JoinRulesEventContent, error) - SetJoinRules(ctx context.Context, roomID id.RoomID, content *event.JoinRulesEventContent) error - - HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error) - MarkMembersFetched(ctx context.Context, roomID id.RoomID) error - GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) - - SetEncryptionEvent(ctx context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error - IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) - - GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) -} - -type StateStoreUpdater interface { - UpdateState(ctx context.Context, evt *event.Event) -} - -func UpdateStateStore(ctx context.Context, store StateStore, evt *event.Event) { - if store == nil || evt == nil || evt.StateKey == nil { - return - } - if directUpdater, ok := store.(StateStoreUpdater); ok { - directUpdater.UpdateState(ctx, evt) - return - } - // We only care about events without a state key (power levels, encryption) or member events with state key - if evt.Type != event.StateMember && evt.GetStateKey() != "" { - return - } - var err error - switch content := evt.Content.Parsed.(type) { - case *event.MemberEventContent: - err = store.SetMember(ctx, evt.RoomID, id.UserID(evt.GetStateKey()), content) - case *event.PowerLevelsEventContent: - err = store.SetPowerLevels(ctx, evt.RoomID, content) - case *event.EncryptionEventContent: - err = store.SetEncryptionEvent(ctx, evt.RoomID, content) - case *event.CreateEventContent: - err = store.SetCreate(ctx, evt) - case *event.JoinRulesEventContent: - err = store.SetJoinRules(ctx, evt.RoomID, content) - default: - switch evt.Type { - case event.StateMember, event.StatePowerLevels, event.StateEncryption, event.StateCreate, event.StateJoinRules: - zerolog.Ctx(ctx).Warn(). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.Type). - Type("content_type", evt.Content.Parsed). - Msg("Got known event type with unknown content type in UpdateStateStore") - } - } - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.Type). - Msg("Failed to update state store") - } -} - -// StateStoreSyncHandler can be added as an event handler in the syncer to update the state store automatically. -// -// client.Syncer.(mautrix.ExtensibleSyncer).OnEvent(client.StateStoreSyncHandler) -// -// DefaultSyncer.ParseEventContent must also be true for this to work (which it is by default). -func (cli *Client) StateStoreSyncHandler(ctx context.Context, evt *event.Event) { - UpdateStateStore(ctx, cli.StateStore, evt) -} - -type MemoryStateStore struct { - Registrations map[id.UserID]bool `json:"registrations"` - Members map[id.RoomID]map[id.UserID]*event.MemberEventContent `json:"memberships"` - MembersFetched map[id.RoomID]bool `json:"members_fetched"` - PowerLevels map[id.RoomID]*event.PowerLevelsEventContent `json:"power_levels"` - Encryption map[id.RoomID]*event.EncryptionEventContent `json:"encryption"` - Create map[id.RoomID]*event.Event `json:"create"` - JoinRules map[id.RoomID]*event.JoinRulesEventContent `json:"join_rules"` - - registrationsLock sync.RWMutex - membersLock sync.RWMutex - powerLevelsLock sync.RWMutex - encryptionLock sync.RWMutex - joinRulesLock sync.RWMutex -} - -func NewMemoryStateStore() StateStore { - return &MemoryStateStore{ - Registrations: make(map[id.UserID]bool), - Members: make(map[id.RoomID]map[id.UserID]*event.MemberEventContent), - MembersFetched: make(map[id.RoomID]bool), - PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent), - Encryption: make(map[id.RoomID]*event.EncryptionEventContent), - Create: make(map[id.RoomID]*event.Event), - JoinRules: make(map[id.RoomID]*event.JoinRulesEventContent), - } -} - -func (store *MemoryStateStore) IsRegistered(_ context.Context, userID id.UserID) (bool, error) { - store.registrationsLock.RLock() - defer store.registrationsLock.RUnlock() - registered, ok := store.Registrations[userID] - return ok && registered, nil -} - -func (store *MemoryStateStore) MarkRegistered(_ context.Context, userID id.UserID) error { - store.registrationsLock.Lock() - defer store.registrationsLock.Unlock() - store.Registrations[userID] = true - return nil -} - -func (store *MemoryStateStore) GetRoomMembers(_ context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { - store.membersLock.RLock() - members, ok := store.Members[roomID] - store.membersLock.RUnlock() - if !ok { - members = make(map[id.UserID]*event.MemberEventContent) - store.membersLock.Lock() - store.Members[roomID] = members - store.membersLock.Unlock() - } - return members, nil -} - -func (store *MemoryStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) { - members, err := store.GetRoomMembers(ctx, roomID) - if err != nil { - return nil, err - } - ids := make([]id.UserID, 0, len(members)) - for id := range members { - ids = append(ids, id) - } - return ids, nil -} - -func (store *MemoryStateStore) GetMembership(ctx context.Context, roomID id.RoomID, userID id.UserID) (event.Membership, error) { - return exerrors.Must(store.GetMember(ctx, roomID, userID)).Membership, nil -} - -func (store *MemoryStateStore) GetMember(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error) { - member, err := store.TryGetMember(ctx, roomID, userID) - if member == nil && err == nil { - member = &event.MemberEventContent{Membership: event.MembershipLeave} - } - return member, err -} - -func (store *MemoryStateStore) IsConfusableName(ctx context.Context, roomID id.RoomID, currentUser id.UserID, name string) ([]id.UserID, error) { - // TODO implement? - return nil, nil -} - -func (store *MemoryStateStore) TryGetMember(_ context.Context, roomID id.RoomID, userID id.UserID) (member *event.MemberEventContent, err error) { - store.membersLock.RLock() - defer store.membersLock.RUnlock() - members, membersOk := store.Members[roomID] - if !membersOk { - return - } - member = members[userID] - return -} - -func (store *MemoryStateStore) IsInRoom(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { - return store.IsMembership(ctx, roomID, userID, "join") -} - -func (store *MemoryStateStore) IsInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) bool { - return store.IsMembership(ctx, roomID, userID, "join", "invite") -} - -func (store *MemoryStateStore) IsMembership(ctx context.Context, roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool { - membership := exerrors.Must(store.GetMembership(ctx, roomID, userID)) - for _, allowedMembership := range allowedMemberships { - if allowedMembership == membership { - return true - } - } - return false -} - -func (store *MemoryStateStore) SetMembership(_ context.Context, roomID id.RoomID, userID id.UserID, membership event.Membership) error { - store.membersLock.Lock() - members, ok := store.Members[roomID] - if !ok { - members = map[id.UserID]*event.MemberEventContent{ - userID: {Membership: membership}, - } - } else { - member, ok := members[userID] - if !ok { - members[userID] = &event.MemberEventContent{Membership: membership} - } else { - member.Membership = membership - members[userID] = member - } - } - store.Members[roomID] = members - store.membersLock.Unlock() - return nil -} - -func (store *MemoryStateStore) SetMember(_ context.Context, roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) error { - store.membersLock.Lock() - members, ok := store.Members[roomID] - if !ok { - members = map[id.UserID]*event.MemberEventContent{ - userID: member, - } - } else { - members[userID] = member - } - store.Members[roomID] = members - store.membersLock.Unlock() - return nil -} - -func (store *MemoryStateStore) ClearCachedMembers(_ context.Context, roomID id.RoomID, memberships ...event.Membership) error { - store.membersLock.Lock() - defer store.membersLock.Unlock() - members, ok := store.Members[roomID] - if !ok { - return nil - } - for userID, member := range members { - for _, membership := range memberships { - if membership == member.Membership { - delete(members, userID) - break - } - } - } - store.MembersFetched[roomID] = false - return nil -} - -func (store *MemoryStateStore) HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error) { - store.membersLock.RLock() - defer store.membersLock.RUnlock() - return store.MembersFetched[roomID], nil -} - -func (store *MemoryStateStore) MarkMembersFetched(ctx context.Context, roomID id.RoomID) error { - store.membersLock.Lock() - defer store.membersLock.Unlock() - store.MembersFetched[roomID] = true - return nil -} - -func (store *MemoryStateStore) ReplaceCachedMembers(ctx context.Context, roomID id.RoomID, evts []*event.Event, onlyMemberships ...event.Membership) error { - _ = store.ClearCachedMembers(ctx, roomID, onlyMemberships...) - for _, evt := range evts { - UpdateStateStore(ctx, store, evt) - } - if len(onlyMemberships) == 0 { - _ = store.MarkMembersFetched(ctx, roomID) - } - return nil -} - -func (store *MemoryStateStore) GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { - store.membersLock.RLock() - defer store.membersLock.RUnlock() - return maps.Clone(store.Members[roomID]), nil -} - -func (store *MemoryStateStore) SetPowerLevels(_ context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error { - store.powerLevelsLock.Lock() - store.PowerLevels[roomID] = levels - store.powerLevelsLock.Unlock() - return nil -} - -func (store *MemoryStateStore) GetPowerLevels(_ context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) { - store.powerLevelsLock.RLock() - levels = store.PowerLevels[roomID] - if levels != nil && levels.CreateEvent == nil { - levels.CreateEvent = store.Create[roomID] - } - store.powerLevelsLock.RUnlock() - return -} - -func (store *MemoryStateStore) GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) { - return exerrors.Must(store.GetPowerLevels(ctx, roomID)).GetUserLevel(userID), nil -} - -func (store *MemoryStateStore) GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) { - return exerrors.Must(store.GetPowerLevels(ctx, roomID)).GetEventLevel(eventType), nil -} - -func (store *MemoryStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) { - return exerrors.Must(store.GetPowerLevel(ctx, roomID, userID)) >= exerrors.Must(store.GetPowerLevelRequirement(ctx, roomID, eventType)), nil -} - -func (store *MemoryStateStore) SetCreate(ctx context.Context, evt *event.Event) error { - store.powerLevelsLock.Lock() - store.Create[evt.RoomID] = evt - if pls, ok := store.PowerLevels[evt.RoomID]; ok && pls.CreateEvent == nil { - pls.CreateEvent = evt - } - store.powerLevelsLock.Unlock() - return nil -} - -func (store *MemoryStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) { - store.powerLevelsLock.RLock() - evt := store.Create[roomID] - store.powerLevelsLock.RUnlock() - return evt, nil -} - -func (store *MemoryStateStore) SetEncryptionEvent(_ context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error { - store.encryptionLock.Lock() - store.Encryption[roomID] = content - store.encryptionLock.Unlock() - return nil -} - -func (store *MemoryStateStore) GetEncryptionEvent(_ context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { - store.encryptionLock.RLock() - defer store.encryptionLock.RUnlock() - return store.Encryption[roomID], nil -} - -func (store *MemoryStateStore) SetJoinRules(ctx context.Context, roomID id.RoomID, content *event.JoinRulesEventContent) error { - store.joinRulesLock.Lock() - store.JoinRules[roomID] = content - store.joinRulesLock.Unlock() - return nil -} - -func (store *MemoryStateStore) GetJoinRules(ctx context.Context, roomID id.RoomID) (*event.JoinRulesEventContent, error) { - store.joinRulesLock.RLock() - defer store.joinRulesLock.RUnlock() - return store.JoinRules[roomID], nil -} - -func (store *MemoryStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { - cfg, err := store.GetEncryptionEvent(ctx, roomID) - return cfg != nil && cfg.Algorithm == id.AlgorithmMegolmV1, err -} - -func (store *MemoryStateStore) FindSharedRooms(ctx context.Context, userID id.UserID) (rooms []id.RoomID, err error) { - store.membersLock.RLock() - defer store.membersLock.RUnlock() - for roomID, members := range store.Members { - if _, ok := members[userID]; ok { - rooms = append(rooms, roomID) - } - } - return rooms, nil -} diff --git a/mautrix-patched/synapseadmin/client.go b/mautrix-patched/synapseadmin/client.go deleted file mode 100644 index 6925ca7d..00000000 --- a/mautrix-patched/synapseadmin/client.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package synapseadmin - -import ( - "maunium.net/go/mautrix" -) - -// Client is a wrapper for the mautrix.Client struct that includes methods for accessing the Synapse admin API. -// -// https://matrix-org.github.io/synapse/latest/usage/administration/admin_api/index.html -type Client struct { - Client *mautrix.Client -} - -func (cli *Client) BuildAdminURL(path ...any) string { - return cli.Client.BuildURL(mautrix.SynapseAdminURLPath(path)) -} diff --git a/mautrix-patched/synapseadmin/register.go b/mautrix-patched/synapseadmin/register.go deleted file mode 100644 index 05e0729a..00000000 --- a/mautrix-patched/synapseadmin/register.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package synapseadmin - -import ( - "context" - "crypto/hmac" - "crypto/sha1" - "encoding/hex" - "fmt" - "net/http" - - "maunium.net/go/mautrix" -) - -type respGetRegisterNonce struct { - Nonce string `json:"nonce"` -} - -// ReqSharedSecretRegister is the request content for Client.SharedSecretRegister. -type ReqSharedSecretRegister struct { - // The username to register. Required. - Username string `json:"username"` - // The new password for the user. Required. - Password string `json:"password"` - - // Initial displayname for the user. By default, the server will use the username as the displayname. - Displayname string `json:"displayname,omitempty"` - // The type of user to register. Defaults to empty (normal user). - // Officially allowed values are "support" or "bot". - // - // Currently, the only effect is that synapse skips consent requirements for those two user types, - // other than that the user type does absolutely nothing. - UserType string `json:"user_type,omitempty"` - // Whether the created user should be a server admin. - Admin bool `json:"admin"` - // Disable generating a new device along with the registration. - // If true, the access_token and device_id fields in the response will be empty. - InhibitLogin bool `json:"inhibit_login,omitempty"` - - // A single-use nonce from GetRegisterNonce. This is automatically filled by Client.SharedSecretRegister if left empty. - Nonce string `json:"nonce"` - // The checksum for the request. This is automatically generated by Client.SharedSecretRegister using Sign. - SHA1Checksum string `json:"mac"` -} - -func (req *ReqSharedSecretRegister) Sign(secret string) string { - signer := hmac.New(sha1.New, []byte(secret)) - signer.Write([]byte(req.Nonce)) - signer.Write([]byte{0}) - signer.Write([]byte(req.Username)) - signer.Write([]byte{0}) - signer.Write([]byte(req.Password)) - signer.Write([]byte{0}) - if req.Admin { - signer.Write([]byte("admin")) - } else { - signer.Write([]byte("notadmin")) - } - if req.UserType != "" { - signer.Write([]byte{0}) - signer.Write([]byte(req.UserType)) - } - return hex.EncodeToString(signer.Sum(nil)) -} - -// GetRegisterNonce gets a nonce that can be used for SharedSecretRegister. -// -// This does not need to be called manually as SharedSecretRegister will automatically call this if no nonce is provided. -func (cli *Client) GetRegisterNonce(ctx context.Context) (string, error) { - var resp respGetRegisterNonce - _, err := cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "register"), nil, &resp) - if err != nil { - return "", err - } - return resp.Nonce, nil -} - -// SharedSecretRegister creates a new account using a shared secret. -// -// https://matrix-org.github.io/synapse/latest/admin_api/register_api.html -func (cli *Client) SharedSecretRegister(ctx context.Context, sharedSecret string, req ReqSharedSecretRegister) (*mautrix.RespRegister, error) { - var err error - if req.Nonce == "" { - req.Nonce, err = cli.GetRegisterNonce(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get nonce: %w", err) - } - } - req.SHA1Checksum = req.Sign(sharedSecret) - var resp mautrix.RespRegister - _, err = cli.Client.MakeRequest(ctx, http.MethodPost, cli.BuildAdminURL("v1", "register"), &req, &resp) - if err != nil { - return nil, err - } - return &resp, nil -} diff --git a/mautrix-patched/synapseadmin/roomapi.go b/mautrix-patched/synapseadmin/roomapi.go deleted file mode 100644 index 0925b748..00000000 --- a/mautrix-patched/synapseadmin/roomapi.go +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package synapseadmin - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type ReqListRoom struct { - SearchTerm string - OrderBy string - Direction mautrix.Direction - From int - Limit int -} - -func (req *ReqListRoom) BuildQuery() map[string]string { - query := map[string]string{ - "from": strconv.Itoa(req.From), - } - if req.SearchTerm != "" { - query["search_term"] = req.SearchTerm - } - if req.OrderBy != "" { - query["order_by"] = req.OrderBy - } - if req.Direction != 0 { - query["dir"] = string(req.Direction) - } - if req.Limit != 0 { - query["limit"] = strconv.Itoa(req.Limit) - } - return query -} - -type RoomInfo struct { - RoomID id.RoomID `json:"room_id"` - Name string `json:"name"` - CanonicalAlias id.RoomAlias `json:"canonical_alias"` - JoinedMembers int `json:"joined_members"` - JoinedLocalMembers int `json:"joined_local_members"` - Version string `json:"version"` - Creator id.UserID `json:"creator"` - Encryption id.Algorithm `json:"encryption"` - Federatable bool `json:"federatable"` - Public bool `json:"public"` - JoinRules event.JoinRule `json:"join_rules"` - GuestAccess event.GuestAccess `json:"guest_access"` - HistoryVisibility event.HistoryVisibility `json:"history_visibility"` - StateEvents int `json:"state_events"` - RoomType event.RoomType `json:"room_type"` -} - -type RespListRooms struct { - Rooms []RoomInfo `json:"rooms"` - Offset int `json:"offset"` - TotalRooms int `json:"total_rooms"` - NextBatch int `json:"next_batch"` - PrevBatch int `json:"prev_batch"` -} - -// ListRooms returns a list of rooms on the server. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#list-room-api -func (cli *Client) ListRooms(ctx context.Context, req ReqListRoom) (RespListRooms, error) { - var resp RespListRooms - reqURL := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms"}, req.BuildQuery()) - _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) - return resp, err -} - -func (cli *Client) RoomInfo(ctx context.Context, roomID id.RoomID) (resp *RoomInfo, err error) { - reqURL := cli.BuildAdminURL("v1", "rooms", roomID) - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) - return -} - -type RespRoomMessages = mautrix.RespMessages - -// RoomMessages returns a list of messages in a room. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#room-messages-api -func (cli *Client) RoomMessages(ctx context.Context, roomID id.RoomID, from, to string, dir mautrix.Direction, filter *mautrix.FilterPart, limit int) (resp *RespRoomMessages, err error) { - query := map[string]string{ - "from": from, - "dir": string(dir), - } - if filter != nil { - filterJSON, err := json.Marshal(filter) - if err != nil { - return nil, err - } - query["filter"] = string(filterJSON) - } - if to != "" { - query["to"] = to - } - if limit != 0 { - query["limit"] = strconv.Itoa(limit) - } - urlPath := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "rooms", roomID, "messages"}, query) - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) - return resp, err -} - -type ReqDeleteRoom struct { - Purge bool `json:"purge,omitempty"` - ForcePurge bool `json:"force_purge,omitempty"` - Block bool `json:"block,omitempty"` - Message string `json:"message,omitempty"` - RoomName string `json:"room_name,omitempty"` - NewRoomUserID id.UserID `json:"new_room_user_id,omitempty"` -} - -type RespDeleteRoom struct { - DeleteID string `json:"delete_id"` -} - -type RespDeleteRoomResult struct { - KickedUsers []id.UserID `json:"kicked_users,omitempty"` - FailedToKickUsers []id.UserID `json:"failed_to_kick_users,omitempty"` - LocalAliases []id.RoomAlias `json:"local_aliases,omitempty"` - NewRoomID id.RoomID `json:"new_room_id,omitempty"` -} - -type RespDeleteRoomStatus struct { - Status string `json:"status,omitempty"` - Error string `json:"error,omitempty"` - ShutdownRoom RespDeleteRoomResult `json:"shutdown_room,omitempty"` -} - -// DeleteRoom deletes a room from the server, optionally blocking it and/or purging all data from the database. -// -// This calls the async version of the endpoint, which will return immediately and delete the room in the background. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#version-2-new-version -func (cli *Client) DeleteRoom(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (RespDeleteRoom, error) { - reqURL := cli.BuildAdminURL("v2", "rooms", roomID) - var resp RespDeleteRoom - _, err := cli.Client.MakeRequest(ctx, http.MethodDelete, reqURL, &req, &resp) - return resp, err -} - -func (cli *Client) DeleteRoomStatus(ctx context.Context, deleteID string) (resp RespDeleteRoomStatus, err error) { - reqURL := cli.BuildAdminURL("v2", "rooms", "delete_status", deleteID) - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) - return -} - -// DeleteRoomSync deletes a room from the server, optionally blocking it and/or purging all data from the database. -// -// This calls the synchronous version of the endpoint, which will block until the room is deleted. -// -// https://element-hq.github.io/synapse/latest/admin_api/rooms.html#version-1-old-version -func (cli *Client) DeleteRoomSync(ctx context.Context, roomID id.RoomID, req ReqDeleteRoom) (resp RespDeleteRoomResult, err error) { - reqURL := cli.BuildAdminURL("v1", "rooms", roomID) - httpClient := &http.Client{} - _, err = cli.Client.MakeFullRequest(ctx, mautrix.FullRequest{ - Method: http.MethodDelete, - URL: reqURL, - RequestJSON: &req, - ResponseJSON: &resp, - MaxAttempts: 1, - // Use a fresh HTTP client without timeouts - Client: httpClient, - }) - httpClient.CloseIdleConnections() - return -} - -type RespRoomsMembers struct { - Members []id.UserID `json:"members"` - Total int `json:"total"` -} - -// RoomMembers gets the full list of members in a room. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#room-members-api -func (cli *Client) RoomMembers(ctx context.Context, roomID id.RoomID) (RespRoomsMembers, error) { - reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "members") - var resp RespRoomsMembers - _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) - return resp, err -} - -type ReqMakeRoomAdmin struct { - UserID id.UserID `json:"user_id"` -} - -// MakeRoomAdmin promotes a user to admin in a room. This requires that a local user has permission to promote users in the room. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#make-room-admin-api -func (cli *Client) MakeRoomAdmin(ctx context.Context, roomIDOrAlias string, req ReqMakeRoomAdmin) error { - reqURL := cli.BuildAdminURL("v1", "rooms", roomIDOrAlias, "make_room_admin") - _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) - return err -} - -type ReqJoinUserToRoom struct { - UserID id.UserID `json:"user_id"` -} - -// JoinUserToRoom makes a local user join the given room. -// -// https://matrix-org.github.io/synapse/latest/admin_api/room_membership.html -func (cli *Client) JoinUserToRoom(ctx context.Context, roomID id.RoomID, req ReqJoinUserToRoom) error { - reqURL := cli.BuildAdminURL("v1", "join", roomID) - _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) - return err -} - -type ReqBlockRoom struct { - Block bool `json:"block"` -} - -// BlockRoom blocks or unblocks a room. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#block-room-api -func (cli *Client) BlockRoom(ctx context.Context, roomID id.RoomID, req ReqBlockRoom) error { - reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "block") - _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) - return err -} - -// RoomsBlockResponse represents the response containing wether a room is blocked or not -type RoomsBlockResponse struct { - Block bool `json:"block"` - UserID id.UserID `json:"user_id"` -} - -// GetRoomBlockStatus gets whether a room is currently blocked. -// -// https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#get-block-status -func (cli *Client) GetRoomBlockStatus(ctx context.Context, roomID id.RoomID) (RoomsBlockResponse, error) { - var resp RoomsBlockResponse - reqURL := cli.BuildAdminURL("v1", "rooms", roomID, "block") - _, err := cli.Client.MakeRequest(ctx, http.MethodGet, reqURL, nil, &resp) - return resp, err -} diff --git a/mautrix-patched/synapseadmin/userapi.go b/mautrix-patched/synapseadmin/userapi.go deleted file mode 100644 index 8c525239..00000000 --- a/mautrix-patched/synapseadmin/userapi.go +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) 2023 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package synapseadmin - -import ( - "context" - "fmt" - "net/http" - - "go.mau.fi/util/jsontime" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" -) - -// ReqResetPassword is the request content for Client.ResetPassword. -type ReqResetPassword struct { - // The user whose password to reset. - UserID id.UserID `json:"-"` - // The new password for the user. Required. - NewPassword string `json:"new_password"` - // Whether all the user's existing devices should be logged out after the password change. - LogoutDevices bool `json:"logout_devices"` -} - -// ResetPassword changes the password of another user using -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#reset-password -func (cli *Client) ResetPassword(ctx context.Context, req ReqResetPassword) error { - reqURL := cli.BuildAdminURL("v1", "reset_password", req.UserID) - _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) - return err -} - -// UsernameAvailable checks if a username is valid and available for registration on the server using the admin API. -// -// The response format is the same as mautrix.Client.RegisterAvailable, -// but it works even if registration is disabled on the server. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#check-username-availability -func (cli *Client) UsernameAvailable(ctx context.Context, username string) (resp *mautrix.RespRegisterAvailable, err error) { - u := cli.Client.BuildURLWithQuery(mautrix.SynapseAdminURLPath{"v1", "username_available"}, map[string]string{"username": username}) - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, u, nil, &resp) - if err == nil && !resp.Available { - err = fmt.Errorf(`request returned OK status without "available": true`) - } - return -} - -type DeviceInfo struct { - mautrix.RespDeviceInfo - LastSeenUserAgent string `json:"last_seen_user_agent"` -} - -type RespListDevices struct { - Devices []DeviceInfo `json:"devices"` - Total int `json:"total"` -} - -// ListDevices gets information about all the devices of a specific user. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#list-all-devices -func (cli *Client) ListDevices(ctx context.Context, userID id.UserID) (resp *RespListDevices, err error) { - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID, "devices"), nil, &resp) - return -} - -type RespUserInfo struct { - UserID id.UserID `json:"name"` - DisplayName string `json:"displayname"` - AvatarURL id.ContentURIString `json:"avatar_url"` - Guest bool `json:"is_guest"` - Admin bool `json:"admin"` - Deactivated bool `json:"deactivated"` - Erased bool `json:"erased"` - ShadowBanned bool `json:"shadow_banned"` - CreationTS jsontime.Unix `json:"creation_ts"` - AppserviceID string `json:"appservice_id"` - UserType string `json:"user_type"` - - // TODO: consent fields, threepids, external IDs -} - -// GetUserInfo gets information about a specific user account. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#query-user-account -func (cli *Client) GetUserInfo(ctx context.Context, userID id.UserID) (resp *RespUserInfo, err error) { - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v2", "users", userID), nil, &resp) - return -} - -type ReqDeleteUser struct { - Erase bool `json:"erase"` -} - -// DeactivateAccount deactivates a specific local user account. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account -func (cli *Client) DeactivateAccount(ctx context.Context, userID id.UserID, req ReqDeleteUser) error { - reqURL := cli.BuildAdminURL("v1", "deactivate", userID) - _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) - return err -} - -type ReqSuspendUser struct { - Suspend bool `json:"suspend"` -} - -// SuspendAccount suspends or unsuspends a specific local user account. -// -// https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#suspendunsuspend-account -func (cli *Client) SuspendAccount(ctx context.Context, userID id.UserID, req ReqSuspendUser) error { - reqURL := cli.BuildAdminURL("v1", "suspend", userID) - _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) - return err -} - -type ReqCreateOrModifyAccount struct { - Password string `json:"password,omitempty"` - LogoutDevices *bool `json:"logout_devices,omitempty"` - - Deactivated *bool `json:"deactivated,omitempty"` - Admin *bool `json:"admin,omitempty"` - Locked *bool `json:"locked,omitempty"` - - Displayname string `json:"displayname,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - UserType string `json:"user_type,omitempty"` -} - -// CreateOrModifyAccount creates or modifies an account on the server. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#create-or-modify-account -func (cli *Client) CreateOrModifyAccount(ctx context.Context, userID id.UserID, req ReqCreateOrModifyAccount) error { - reqURL := cli.BuildAdminURL("v2", "users", userID) - _, err := cli.Client.MakeRequest(ctx, http.MethodPut, reqURL, &req, nil) - return err -} - -type RatelimitOverride struct { - MessagesPerSecond int `json:"messages_per_second"` - BurstCount int `json:"burst_count"` -} - -type ReqSetRatelimit = RatelimitOverride - -// SetUserRatelimit overrides the message sending ratelimit for a specific user. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#set-ratelimit -func (cli *Client) SetUserRatelimit(ctx context.Context, userID id.UserID, req ReqSetRatelimit) error { - reqURL := cli.BuildAdminURL("v1", "users", userID, "override_ratelimit") - _, err := cli.Client.MakeRequest(ctx, http.MethodPost, reqURL, &req, nil) - return err -} - -type RespUserRatelimit = RatelimitOverride - -// GetUserRatelimit gets the ratelimit override for the given user. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#get-status-of-ratelimit -func (cli *Client) GetUserRatelimit(ctx context.Context, userID id.UserID) (resp RespUserRatelimit, err error) { - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, &resp) - return -} - -// DeleteUserRatelimit deletes the ratelimit override for the given user, returning them to the default ratelimits. -// -// https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#delete-ratelimit -func (cli *Client) DeleteUserRatelimit(ctx context.Context, userID id.UserID) (err error) { - _, err = cli.Client.MakeRequest(ctx, http.MethodDelete, cli.BuildAdminURL("v1", "users", userID, "override_ratelimit"), nil, nil) - return -} - -type ReqRedactUser struct { - // A list of rooms to redact the user’s events in. - // If an empty list is provided all events in all rooms the user is a member of will be redacted. - Rooms []id.RoomID `json:"rooms"` - // Reason the redaction is being requested, ie “spam”, “abuse”, etc. This will be included in each redaction event. - Reason string `json:"reason,omitempty"` - // a limit on the number of the user’s events to search for ones that can be redacted (events are redacted newest to - // oldest) in each room, defaults to 1000 if not provided. - Limit int `json:"limit,omitempty"` - // If set to true, the admin user is used to issue the redactions, rather than puppeting the user - UseAdmin bool `json:"use_admin,omitempty"` -} - -type RespRedactUser struct { - RedactID string `json:"redact_id"` -} - -// RedactUser puppets the target user to redact their events. -// If the supplied room list is empty, all rooms the user is a member of are used. -// If the user is a remote user, the invoking admin user will be used to issue redactions. -func (cli *Client) RedactUser(ctx context.Context, userID id.UserID, req ReqRedactUser) (resp RespRedactUser, err error) { - if req.Rooms == nil { - req.Rooms = []id.RoomID{} - } - _, err = cli.Client.MakeRequest(ctx, http.MethodPost, cli.BuildAdminURL("v1", "user", userID, "redact"), &req, &resp) - return -} - -type RespRedactUserStatus struct { - Status string `json:"status"` - FailedRedactions map[id.EventID]string `json:"failed_redactions"` -} - -func (cli *Client) RedactUserStatus(ctx context.Context, redactID string) (resp RespRedactUserStatus, err error) { - _, err = cli.Client.MakeRequest(ctx, http.MethodGet, cli.BuildAdminURL("v1", "user", "redact_status", redactID), nil, &resp) - return -} diff --git a/mautrix-patched/sync.go b/mautrix-patched/sync.go deleted file mode 100644 index 5b19b9ad..00000000 --- a/mautrix-patched/sync.go +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) 2024 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix - -import ( - "context" - "errors" - "fmt" - "runtime/debug" - "time" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// EventHandler handles a single event from a sync response. -type EventHandler = func(ctx context.Context, evt *event.Event) - -// SyncHandler handles a whole sync response. If the return value is false, handling will be stopped completely. -type SyncHandler func(ctx context.Context, resp *RespSync, since string) bool - -// Syncer is an interface that must be satisfied in order to do /sync requests on a client. -type Syncer interface { - // ProcessResponse processes the /sync response. The since parameter is the since= value that was used to produce the response. - // This is useful for detecting the very first sync (since=""). If an error is return, Syncing will be stopped permanently. - ProcessResponse(ctx context.Context, resp *RespSync, since string) error - // OnFailedSync returns either the time to wait before retrying or an error to stop syncing permanently. - OnFailedSync(res *RespSync, err error) (time.Duration, error) - // GetFilterJSON for the given user ID. NOT the filter ID. - GetFilterJSON(userID id.UserID) *Filter -} - -type ExtensibleSyncer interface { - OnSync(callback SyncHandler) - OnEvent(callback EventHandler) - OnEventType(eventType event.Type, callback EventHandler) -} - -type DispatchableSyncer interface { - Dispatch(ctx context.Context, evt *event.Event) -} - -// DefaultSyncer is the default syncing implementation. You can either write your own syncer, or selectively -// replace parts of this default syncer (e.g. the ProcessResponse method). The default syncer uses the observer -// pattern to notify callers about incoming events. See DefaultSyncer.OnEventType for more information. -type DefaultSyncer struct { - // syncListeners want the whole sync response, e.g. the crypto machine - syncListeners []SyncHandler - // globalListeners want all events - globalListeners []EventHandler - // listeners want a specific event type - listeners map[event.Type][]EventHandler - // ParseEventContent determines whether or not event content should be parsed before passing to handlers. - ParseEventContent bool - // ParseErrorHandler is called when event.Content.ParseRaw returns an error. - // If it returns false, the event will not be forwarded to listeners. - ParseErrorHandler func(evt *event.Event, err error) bool - // FilterJSON is used when the client starts syncing and doesn't get an existing filter ID from SyncStore's LoadFilterID. - FilterJSON *Filter -} - -var _ Syncer = (*DefaultSyncer)(nil) -var _ ExtensibleSyncer = (*DefaultSyncer)(nil) - -// NewDefaultSyncer returns an instantiated DefaultSyncer -func NewDefaultSyncer() *DefaultSyncer { - return &DefaultSyncer{ - listeners: make(map[event.Type][]EventHandler), - syncListeners: []SyncHandler{}, - globalListeners: []EventHandler{}, - ParseEventContent: true, - ParseErrorHandler: func(evt *event.Event, err error) bool { - // By default, drop known events that can't be parsed, but let unknown events through - return errors.Is(err, event.ErrUnsupportedContentType) || - // Also allow events that had their content already parsed by some other function - errors.Is(err, event.ErrContentAlreadyParsed) - }, - } -} - -// ProcessResponse processes the /sync response in a way suitable for bots. "Suitable for bots" means a stream of -// unrepeating events. Returns a fatal error if a listener panics. -func (s *DefaultSyncer) ProcessResponse(ctx context.Context, res *RespSync, since string) (err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("ProcessResponse panicked! since=%s panic=%s\n%s", since, r, debug.Stack()) - } - }() - ctx = context.WithValue(ctx, SyncTokenContextKey, since) - - for _, listener := range s.syncListeners { - if !listener(ctx, res, since) { - return - } - } - - s.processSyncEvents(ctx, "", res.ToDevice.Events, event.SourceToDevice, false) - s.processSyncEvents(ctx, "", res.Presence.Events, event.SourcePresence, false) - s.processSyncEvents(ctx, "", res.AccountData.Events, event.SourceAccountData, false) - - for roomID, roomData := range res.Rooms.Join { - s.processSyncEvents(ctx, roomID, roomData.Sticky.Events, event.SourceJoin|event.SourceSticky, false) - if roomData.StateAfter == nil { - s.processSyncEvents(ctx, roomID, roomData.State.Events, event.SourceJoin|event.SourceState, false) - s.processSyncEvents(ctx, roomID, roomData.Timeline.Events, event.SourceJoin|event.SourceTimeline, false) - } else { - s.processSyncEvents(ctx, roomID, roomData.Timeline.Events, event.SourceJoin|event.SourceTimeline, true) - s.processSyncEvents(ctx, roomID, roomData.StateAfter.Events, event.SourceJoin|event.SourceState, false) - } - s.processSyncEvents(ctx, roomID, roomData.Ephemeral.Events, event.SourceJoin|event.SourceEphemeral, false) - s.processSyncEvents(ctx, roomID, roomData.AccountData.Events, event.SourceJoin|event.SourceAccountData, false) - } - for roomID, roomData := range res.Rooms.Invite { - s.processSyncEvents(ctx, roomID, roomData.State.Events, event.SourceInvite|event.SourceState, false) - } - for roomID, roomData := range res.Rooms.Leave { - s.processSyncEvents(ctx, roomID, roomData.State.Events, event.SourceLeave|event.SourceState, false) - s.processSyncEvents(ctx, roomID, roomData.Timeline.Events, event.SourceLeave|event.SourceTimeline, false) - } - return -} - -func (s *DefaultSyncer) processSyncEvents(ctx context.Context, roomID id.RoomID, events []*event.Event, source event.Source, ignoreState bool) { - for _, evt := range events { - s.processSyncEvent(ctx, roomID, evt, source, ignoreState) - } -} - -func (s *DefaultSyncer) processSyncEvent(ctx context.Context, roomID id.RoomID, evt *event.Event, source event.Source, ignoreState bool) { - evt.RoomID = roomID - - // Ensure the type class is correct. It's safe to mutate the class since the event type is not a pointer. - // Listeners are keyed by type structs, which means only the correct class will pass. - switch { - case evt.StateKey != nil: - evt.Type.Class = event.StateEventType - case source == event.SourcePresence, source&event.SourceEphemeral != 0: - evt.Type.Class = event.EphemeralEventType - case source&event.SourceAccountData != 0: - evt.Type.Class = event.AccountDataEventType - case source == event.SourceToDevice: - evt.Type.Class = event.ToDeviceEventType - default: - evt.Type.Class = event.MessageEventType - } - - if s.ParseEventContent { - err := evt.Content.ParseRaw(evt.Type) - if err != nil && !s.ParseErrorHandler(evt, err) { - return - } - } - - evt.Mautrix.EventSource = source - evt.Mautrix.IgnoreState = ignoreState - s.Dispatch(ctx, evt) -} - -func (s *DefaultSyncer) Dispatch(ctx context.Context, evt *event.Event) { - for _, fn := range s.globalListeners { - fn(ctx, evt) - } - listeners, exists := s.listeners[evt.Type] - if exists { - for _, fn := range listeners { - fn(ctx, evt) - } - } -} - -// OnEventType allows callers to be notified when there are new events for the given event type. -// There are no duplicate checks. -func (s *DefaultSyncer) OnEventType(eventType event.Type, callback EventHandler) { - _, exists := s.listeners[eventType] - if !exists { - s.listeners[eventType] = []EventHandler{} - } - s.listeners[eventType] = append(s.listeners[eventType], callback) -} - -func (s *DefaultSyncer) OnSync(callback SyncHandler) { - s.syncListeners = append(s.syncListeners, callback) -} - -func (s *DefaultSyncer) OnEvent(callback EventHandler) { - s.globalListeners = append(s.globalListeners, callback) -} - -// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. -func (s *DefaultSyncer) OnFailedSync(res *RespSync, err error) (time.Duration, error) { - if errors.Is(err, MUnknownToken) { - return 0, err - } - return 10 * time.Second, nil -} - -var defaultFilter = Filter{ - Room: &RoomFilter{ - Timeline: &FilterPart{ - Limit: 50, - }, - }, -} - -// GetFilterJSON returns a filter with a timeline limit of 50. -func (s *DefaultSyncer) GetFilterJSON(userID id.UserID) *Filter { - if s.FilterJSON == nil { - defaultFilterCopy := defaultFilter - s.FilterJSON = &defaultFilterCopy - } - return s.FilterJSON -} - -// DontProcessOldEvents is a sync handler that removes rooms that the user just joined. -// It's meant for bots to ignore events from before the bot joined the room. -// -// To use it, register it with your Syncer, e.g.: -// -// cli.Syncer.(mautrix.ExtensibleSyncer).OnSync(cli.DontProcessOldEvents) -func (cli *Client) DontProcessOldEvents(_ context.Context, resp *RespSync, since string) bool { - return dontProcessOldEvents(cli.UserID, resp, since) -} - -var _ SyncHandler = (*Client)(nil).DontProcessOldEvents - -func dontProcessOldEvents(userID id.UserID, resp *RespSync, since string) bool { - if since == "" { - return false - } - // This is a horrible hack because /sync will return the most recent messages for a room - // as soon as you /join it. We do NOT want to process those events in that particular room - // because they may have already been processed (if you toggle the bot in/out of the room). - // - // Work around this by inspecting each room's timeline and seeing if an m.room.member event for us - // exists and is "join" and then discard processing that room entirely if so. - // TODO: We probably want to process messages from after the last join event in the timeline. - for roomID, roomData := range resp.Rooms.Join { - for i := len(roomData.Timeline.Events) - 1; i >= 0; i-- { - evt := roomData.Timeline.Events[i] - if evt.Type == event.StateMember && evt.GetStateKey() == string(userID) { - membership, _ := evt.Content.Raw["membership"].(string) - if membership == "join" { - _, ok := resp.Rooms.Join[roomID] - if !ok { - continue - } - delete(resp.Rooms.Join, roomID) // don't re-process messages - delete(resp.Rooms.Invite, roomID) // don't re-process invites - break - } - } - } - } - return true -} - -// MoveInviteState is a sync handler that moves events from the state event list to the InviteRoomState in the invite event. -// -// To use it, register it with your Syncer, e.g.: -// -// cli.Syncer.(mautrix.ExtensibleSyncer).OnSync(cli.MoveInviteState) -func (cli *Client) MoveInviteState(ctx context.Context, resp *RespSync, _ string) bool { - for _, meta := range resp.Rooms.Invite { - var inviteState []*event.Event - var inviteEvt *event.Event - for _, evt := range meta.State.Events { - if evt.Type == event.StateMember && evt.GetStateKey() == cli.UserID.String() { - inviteEvt = evt - } else { - evt.Type.Class = event.StateEventType - _ = evt.Content.ParseRaw(evt.Type) - inviteState = append(inviteState, evt) - } - } - if inviteEvt != nil { - inviteEvt.Unsigned.InviteRoomState = inviteState - meta.State.Events = []*event.Event{inviteEvt} - } - } - return true -} - -var _ SyncHandler = (*Client)(nil).MoveInviteState diff --git a/mautrix-patched/syncstore.go b/mautrix-patched/syncstore.go deleted file mode 100644 index 6c7fc9ee..00000000 --- a/mautrix-patched/syncstore.go +++ /dev/null @@ -1,175 +0,0 @@ -package mautrix - -import ( - "context" - "errors" - "fmt" - - "maunium.net/go/mautrix/id" -) - -var _ SyncStore = (*MemorySyncStore)(nil) -var _ SyncStore = (*AccountDataStore)(nil) - -// SyncStore is an interface which must be satisfied to store client data. -// -// You can either write a struct which persists this data to disk, or you can use the -// provided "MemorySyncStore" which just keeps data around in-memory which is lost on -// restarts. -type SyncStore interface { - SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error - LoadFilterID(ctx context.Context, userID id.UserID) (string, error) - SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error - LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) -} - -// Deprecated: renamed to SyncStore -type Storer = SyncStore - -// MemorySyncStore implements the Storer interface. -// -// Everything is persisted in-memory as maps. It is not safe to load/save filter IDs -// or next batch tokens on any goroutine other than the syncing goroutine: the one -// which called Client.Sync(). -type MemorySyncStore struct { - Filters map[id.UserID]string - NextBatch map[id.UserID]string -} - -// SaveFilterID to memory. -func (s *MemorySyncStore) SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error { - s.Filters[userID] = filterID - return nil -} - -// LoadFilterID from memory. -func (s *MemorySyncStore) LoadFilterID(ctx context.Context, userID id.UserID) (string, error) { - return s.Filters[userID], nil -} - -// SaveNextBatch to memory. -func (s *MemorySyncStore) SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error { - s.NextBatch[userID] = nextBatchToken - return nil -} - -// LoadNextBatch from memory. -func (s *MemorySyncStore) LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) { - return s.NextBatch[userID], nil -} - -// NewMemorySyncStore constructs a new MemorySyncStore. -func NewMemorySyncStore() *MemorySyncStore { - return &MemorySyncStore{ - Filters: make(map[id.UserID]string), - NextBatch: make(map[id.UserID]string), - } -} - -// AccountDataStore uses account data to store the next batch token, and stores the filter ID in memory -// (as filters can be safely recreated every startup). -type AccountDataStore struct { - FilterID string - EventType string - client *Client - nextBatch string -} - -type accountData struct { - NextBatch string `json:"next_batch"` -} - -func (s *AccountDataStore) SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error { - if userID.String() != s.client.UserID.String() { - panic("AccountDataStore must only be used with a single account") - } - s.FilterID = filterID - return nil -} - -func (s *AccountDataStore) LoadFilterID(ctx context.Context, userID id.UserID) (string, error) { - if userID.String() != s.client.UserID.String() { - panic("AccountDataStore must only be used with a single account") - } - return s.FilterID, nil -} - -func (s *AccountDataStore) SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error { - if userID.String() != s.client.UserID.String() { - panic("AccountDataStore must only be used with a single account") - } else if nextBatchToken == s.nextBatch { - return nil - } - - data := accountData{ - NextBatch: nextBatchToken, - } - - err := s.client.SetAccountData(ctx, s.EventType, data) - if err != nil { - return fmt.Errorf("failed to save next batch token to account data: %w", err) - } else { - s.client.Log.Debug(). - Str("old_token", s.nextBatch). - Str("new_token", nextBatchToken). - Msg("Saved next batch token") - s.nextBatch = nextBatchToken - } - return nil -} - -func (s *AccountDataStore) LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) { - if userID.String() != s.client.UserID.String() { - panic("AccountDataStore must only be used with a single account") - } - - data := &accountData{} - - err := s.client.GetAccountData(ctx, s.EventType, data) - if err != nil { - if errors.Is(err, MNotFound) { - s.client.Log.Debug().Msg("No next batch token found in account data") - return "", nil - } else { - return "", fmt.Errorf("failed to load next batch token from account data: %w", err) - } - } - s.nextBatch = data.NextBatch - s.client.Log.Debug().Str("next_batch", data.NextBatch).Msg("Loaded next batch token from account data") - - return s.nextBatch, nil -} - -// NewAccountDataStore returns a new AccountDataStore, which stores -// the next_batch token as a custom event in account data in the -// homeserver. -// -// AccountDataStore is only appropriate for bots, not appservices. -// -// The event type should be a reversed DNS name like tld.domain.sub.internal and -// must be unique for a client. The data stored in it is considered internal -// and must not be modified through outside means. You should also add a filter -// for account data changes of this event type, to avoid ending up in a sync -// loop: -// -// filter := mautrix.Filter{ -// AccountData: mautrix.FilterPart{ -// Limit: 20, -// NotTypes: []event.Type{ -// event.NewEventType(eventType), -// }, -// }, -// } -// // If you use a custom Syncer, set the filter there, not like this -// client.Syncer.(*mautrix.DefaultSyncer).FilterJSON = &filter -// client.Store = mautrix.NewAccountDataStore("com.example.mybot.store", client) -// go func() { -// err := client.Sync() -// // don't forget to check err -// }() -func NewAccountDataStore(eventType string, client *Client) *AccountDataStore { - return &AccountDataStore{ - EventType: eventType, - client: client, - } -} diff --git a/mautrix-patched/url.go b/mautrix-patched/url.go deleted file mode 100644 index 91b3d49d..00000000 --- a/mautrix-patched/url.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix - -import ( - "fmt" - "net/url" - "strconv" - "strings" -) - -func ParseAndNormalizeBaseURL(homeserverURL string) (*url.URL, error) { - hsURL, err := url.Parse(homeserverURL) - if err != nil { - return nil, err - } - if hsURL.Scheme == "" { - hsURL.Scheme = "https" - fixedURL := hsURL.String() - hsURL, err = url.Parse(fixedURL) - if err != nil { - return nil, fmt.Errorf("failed to parse fixed URL '%s': %v", fixedURL, err) - } - } - hsURL.RawPath = hsURL.EscapedPath() - return hsURL, nil -} - -// BuildURL builds a URL with the given path parts -func BuildURL(baseURL *url.URL, path ...any) *url.URL { - createdURL := *baseURL - rawParts := make([]string, len(path)+1) - rawParts[0] = strings.TrimSuffix(createdURL.RawPath, "/") - parts := make([]string, len(path)+1) - parts[0] = strings.TrimSuffix(createdURL.Path, "/") - for i, part := range path { - switch casted := part.(type) { - case string: - parts[i+1] = casted - case int: - parts[i+1] = strconv.Itoa(casted) - case fmt.Stringer: - parts[i+1] = casted.String() - default: - parts[i+1] = fmt.Sprint(casted) - } - rawParts[i+1] = url.PathEscape(parts[i+1]) - } - createdURL.Path = strings.Join(parts, "/") - createdURL.RawPath = strings.Join(rawParts, "/") - return &createdURL -} - -// BuildURL builds a URL with the Client's homeserver and appservice user ID set already. -func (cli *Client) BuildURL(urlPath PrefixableURLPath) string { - return cli.BuildURLWithFullQuery(urlPath, nil) -} - -// BuildClientURL builds a URL with the Client's homeserver and appservice user ID set already. -// This method also automatically prepends the client API prefix (/_matrix/client). -func (cli *Client) BuildClientURL(urlPath ...any) string { - return cli.BuildURLWithFullQuery(ClientURLPath(urlPath), nil) -} - -type PrefixableURLPath interface { - FullPath() []any -} - -type BaseURLPath []any - -func (bup BaseURLPath) FullPath() []any { - return bup -} - -type ClientURLPath []any - -func (cup ClientURLPath) FullPath() []any { - return append([]any{"_matrix", "client"}, []any(cup)...) -} - -type MediaURLPath []any - -func (mup MediaURLPath) FullPath() []any { - return append([]any{"_matrix", "media"}, []any(mup)...) -} - -type SynapseAdminURLPath []any - -func (saup SynapseAdminURLPath) FullPath() []any { - return append([]any{"_synapse", "admin"}, []any(saup)...) -} - -// BuildURLWithQuery builds a URL with query parameters in addition to the Client's homeserver -// and appservice user ID set already. -func (cli *Client) BuildURLWithQuery(urlPath PrefixableURLPath, urlQuery map[string]string) string { - return cli.BuildURLWithFullQuery(urlPath, func(q url.Values) { - for k, v := range urlQuery { - q.Set(k, v) - } - }) -} - -// BuildURLWithQuery builds a URL with query parameters in addition to the Client's homeserver -// and appservice user ID set already. -func (cli *Client) BuildURLWithFullQuery(urlPath PrefixableURLPath, fn func(q url.Values)) string { - if cli == nil { - return "client is nil" - } - hsURL := *BuildURL(cli.HomeserverURL, urlPath.FullPath()...) - query := hsURL.Query() - if cli.SetAppServiceUserID { - query.Set("user_id", string(cli.UserID)) - } - if cli.SetAppServiceDeviceID && cli.DeviceID != "" { - query.Set("device_id", string(cli.DeviceID)) - query.Set("org.matrix.msc3202.device_id", string(cli.DeviceID)) - } - if fn != nil { - fn(query) - } - hsURL.RawQuery = query.Encode() - return hsURL.String() -} diff --git a/mautrix-patched/url_test.go b/mautrix-patched/url_test.go deleted file mode 100644 index 5ce3548c..00000000 --- a/mautrix-patched/url_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix" -) - -func TestClient_BuildURL(t *testing.T) { - cli, err := mautrix.NewClient("https://example.com", "", "") - assert.NoError(t, err) - assert.Equal(t, cli.HomeserverURL.Scheme, "https") - assert.Equal(t, cli.HomeserverURL.Host, "example.com") - assert.Equal(t, cli.HomeserverURL.Path, "") - built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") - assert.Equal(t, "https://example.com/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) -} - -func TestClient_BuildURL_HTTP(t *testing.T) { - cli, err := mautrix.NewClient("http://example.com", "", "") - assert.NoError(t, err) - assert.Equal(t, cli.HomeserverURL.Scheme, "http") - assert.Equal(t, cli.HomeserverURL.Host, "example.com") - assert.Equal(t, cli.HomeserverURL.Path, "") - built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") - assert.Equal(t, "http://example.com/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) -} - -func TestClient_BuildURL_MissingScheme(t *testing.T) { - cli, err := mautrix.NewClient("example.com", "", "") - assert.NoError(t, err) - assert.Equal(t, cli.HomeserverURL.Scheme, "https") - assert.Equal(t, cli.HomeserverURL.Host, "example.com") - assert.Equal(t, cli.HomeserverURL.Path, "") - built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") - assert.Equal(t, "https://example.com/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) -} - -func TestClient_BuildURL_WithPath(t *testing.T) { - cli, err := mautrix.NewClient("https://example.com/base", "", "") - assert.NoError(t, err) - assert.Equal(t, cli.HomeserverURL.Scheme, "https") - assert.Equal(t, cli.HomeserverURL.Host, "example.com") - assert.Equal(t, cli.HomeserverURL.Path, "/base") - built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") - assert.Equal(t, "https://example.com/base/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) -} - -func TestClient_BuildURL_MissingSchemeWithPath(t *testing.T) { - cli, err := mautrix.NewClient("example.com/base", "", "") - assert.NoError(t, err) - assert.Equal(t, cli.HomeserverURL.Scheme, "https") - assert.Equal(t, cli.HomeserverURL.Host, "example.com") - assert.Equal(t, cli.HomeserverURL.Path, "/base") - built := cli.BuildClientURL("v3", "foo/bar%2F🐈 1", "hello", "world") - assert.Equal(t, "https://example.com/base/_matrix/client/v3/foo%2Fbar%252F%F0%9F%90%88%201/hello/world", built) -} diff --git a/mautrix-patched/version.go b/mautrix-patched/version.go deleted file mode 100644 index 844558bb..00000000 --- a/mautrix-patched/version.go +++ /dev/null @@ -1,41 +0,0 @@ -package mautrix - -import ( - "fmt" - "regexp" - "runtime" - "runtime/debug" - "strings" -) - -const Version = "v0.28.1" - -var GoModVersion = "" -var Commit = "" -var VersionWithCommit = Version - -var DefaultUserAgent = "mautrix-go/" + Version + " go/" + strings.TrimPrefix(runtime.Version(), "go") - -func init() { - if GoModVersion == "" { - info, _ := debug.ReadBuildInfo() - if info != nil { - for _, mod := range info.Deps { - if mod.Path == "maunium.net/go/mautrix" { - GoModVersion = mod.Version - break - } - } - } - } - if GoModVersion != "" { - match := regexp.MustCompile(`v.+\d{14}-([0-9a-f]{12})`).FindStringSubmatch(GoModVersion) - if match != nil { - Commit = match[1] - } - } - if Commit != "" { - VersionWithCommit = fmt.Sprintf("%s+dev.%s", Version, Commit[:8]) - DefaultUserAgent = strings.Replace(DefaultUserAgent, "mautrix-go/"+Version, "mautrix-go/"+VersionWithCommit, 1) - } -} diff --git a/mautrix-patched/versions.go b/mautrix-patched/versions.go deleted file mode 100644 index ecc63c8c..00000000 --- a/mautrix-patched/versions.go +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix - -import ( - "fmt" - "regexp" - "strconv" -) - -// RespVersions is the JSON response for https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientversions -type RespVersions struct { - Versions []SpecVersion `json:"versions"` - UnstableFeatures map[string]bool `json:"unstable_features"` -} - -func (versions *RespVersions) ContainsFunc(match func(found SpecVersion) bool) bool { - if versions == nil { - return false - } - for _, found := range versions.Versions { - if match(found) { - return true - } - } - return false -} - -func (versions *RespVersions) Contains(version SpecVersion) bool { - return versions.ContainsFunc(func(found SpecVersion) bool { - return found == version - }) -} - -func (versions *RespVersions) ContainsGreaterOrEqual(version SpecVersion) bool { - return versions.ContainsFunc(func(found SpecVersion) bool { - return found.GreaterThan(version) || found == version - }) -} - -func (versions *RespVersions) GetLatest() (latest SpecVersion) { - if versions == nil { - return - } - for _, ver := range versions.Versions { - if ver.GreaterThan(latest) { - latest = ver - } - } - return -} - -type UnstableFeature struct { - UnstableFlag string - SpecVersion SpecVersion -} - -var ( - FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17} - FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} - FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} - FeatureUnstableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"} - FeatureStableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms.stable" /*, SpecVersion: SpecV119*/} - FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"} - FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"} - FeatureUnstableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"} - FeatureStableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323.stable", SpecVersion: SpecV118} - FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} - FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} - FeatureRedactSendAsEvent = UnstableFeature{UnstableFlag: "com.beeper.msc4169"} - FeatureUnstableReplaceProfile = UnstableFeature{UnstableFlag: "com.beeper.msc4437"} - FeatureStableReplaceProfile = UnstableFeature{UnstableFlag: "com.beeper.msc4437.stable"} - FeatureFullyReadBackward = UnstableFeature{UnstableFlag: "com.beeper.msc4446"} - - BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} - BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} - BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"} - BeeperFeatureAutojoinInvites = UnstableFeature{UnstableFlag: "com.beeper.room_create_autojoin_invites"} - BeeperFeatureArbitraryProfileMeta = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_profile_meta"} - BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} - BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} - BeeperFeatureArbitraryMemberChange = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_member_change"} -) - -func (versions *RespVersions) Supports(feature UnstableFeature) bool { - if versions == nil { - return false - } - return versions.UnstableFeatures[feature.UnstableFlag] || - (!feature.SpecVersion.IsEmpty() && versions.ContainsGreaterOrEqual(feature.SpecVersion)) -} - -type SpecVersionFormat int - -const ( - SpecVersionFormatUnknown SpecVersionFormat = iota - SpecVersionFormatR - SpecVersionFormatV -) - -var ( - SpecR000 = MustParseSpecVersion("r0.0.0") - SpecR001 = MustParseSpecVersion("r0.0.1") - SpecR010 = MustParseSpecVersion("r0.1.0") - SpecR020 = MustParseSpecVersion("r0.2.0") - SpecR030 = MustParseSpecVersion("r0.3.0") - SpecR040 = MustParseSpecVersion("r0.4.0") - SpecR050 = MustParseSpecVersion("r0.5.0") - SpecR060 = MustParseSpecVersion("r0.6.0") - SpecR061 = MustParseSpecVersion("r0.6.1") - SpecV11 = MustParseSpecVersion("v1.1") - SpecV12 = MustParseSpecVersion("v1.2") - SpecV13 = MustParseSpecVersion("v1.3") - SpecV14 = MustParseSpecVersion("v1.4") - SpecV15 = MustParseSpecVersion("v1.5") - SpecV16 = MustParseSpecVersion("v1.6") - SpecV17 = MustParseSpecVersion("v1.7") - SpecV18 = MustParseSpecVersion("v1.8") - SpecV19 = MustParseSpecVersion("v1.9") - SpecV110 = MustParseSpecVersion("v1.10") - SpecV111 = MustParseSpecVersion("v1.11") - SpecV112 = MustParseSpecVersion("v1.12") - SpecV113 = MustParseSpecVersion("v1.13") - SpecV114 = MustParseSpecVersion("v1.14") - SpecV115 = MustParseSpecVersion("v1.15") - SpecV116 = MustParseSpecVersion("v1.16") - SpecV117 = MustParseSpecVersion("v1.17") - SpecV118 = MustParseSpecVersion("v1.18") -) - -func (svf SpecVersionFormat) String() string { - switch svf { - case SpecVersionFormatR: - return "r" - case SpecVersionFormatV: - return "v" - default: - return "" - } -} - -type SpecVersion struct { - Format SpecVersionFormat - Major int - Minor int - Patch int - - Raw string -} - -var legacyVersionRegex = regexp.MustCompile(`^r(\d+)\.(\d+)\.(\d+)$`) -var modernVersionRegex = regexp.MustCompile(`^v(\d+)\.(\d+)$`) - -func MustParseSpecVersion(version string) SpecVersion { - sv, err := ParseSpecVersion(version) - if err != nil { - panic(err) - } - return sv -} - -func ParseSpecVersion(version string) (sv SpecVersion, err error) { - sv.Raw = version - if parts := modernVersionRegex.FindStringSubmatch(version); parts != nil { - sv.Major, _ = strconv.Atoi(parts[1]) - sv.Minor, _ = strconv.Atoi(parts[2]) - sv.Format = SpecVersionFormatV - } else if parts = legacyVersionRegex.FindStringSubmatch(version); parts != nil { - sv.Major, _ = strconv.Atoi(parts[1]) - sv.Minor, _ = strconv.Atoi(parts[2]) - sv.Patch, _ = strconv.Atoi(parts[3]) - sv.Format = SpecVersionFormatR - } else { - err = fmt.Errorf("version '%s' doesn't match either known syntax", version) - } - return -} - -func (sv *SpecVersion) UnmarshalText(version []byte) error { - *sv, _ = ParseSpecVersion(string(version)) - return nil -} - -func (sv *SpecVersion) MarshalText() ([]byte, error) { - return []byte(sv.String()), nil -} - -func (sv SpecVersion) String() string { - switch sv.Format { - case SpecVersionFormatR: - return fmt.Sprintf("r%d.%d.%d", sv.Major, sv.Minor, sv.Patch) - case SpecVersionFormatV: - return fmt.Sprintf("v%d.%d", sv.Major, sv.Minor) - default: - return sv.Raw - } -} - -func (sv SpecVersion) IsEmpty() bool { - return sv.Format == SpecVersionFormatUnknown && sv.Raw == "" -} - -func (sv SpecVersion) LessThan(other SpecVersion) bool { - return sv != other && !sv.GreaterThan(other) -} - -func (sv SpecVersion) GreaterThan(other SpecVersion) bool { - return sv.Format > other.Format || - (sv.Format == other.Format && sv.Major > other.Major) || - (sv.Format == other.Format && sv.Major == other.Major && sv.Minor > other.Minor) || - (sv.Format == other.Format && sv.Major == other.Major && sv.Minor == other.Minor && sv.Patch > other.Patch) -} diff --git a/mautrix-patched/versions_test.go b/mautrix-patched/versions_test.go deleted file mode 100644 index fbcced9f..00000000 --- a/mautrix-patched/versions_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2022 Tulir Asokan -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package mautrix_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - - "maunium.net/go/mautrix" -) - -const sampleVersions = `{ - "versions": [ - "r0.0.1", - "r0.1.0", - "r0.2.0", - "r0.3.0", - "r0.4.0", - "r0.5.0", - "r0.6.0", - "r0.6.1", - "v1.1", - "v1.2" - ], - "unstable_features": { - "org.matrix.label_based_filtering": true, - "org.matrix.e2e_cross_signing": true, - "org.matrix.msc2432": true, - "uk.half-shot.msc2666.mutual_rooms": true, - "io.element.e2ee_forced.public": false, - "io.element.e2ee_forced.private": false, - "io.element.e2ee_forced.trusted_private": false, - "org.matrix.msc3026.busy_presence": false, - "org.matrix.msc2285": true, - "org.matrix.msc2716": false, - "org.matrix.msc3030": false, - "org.matrix.msc3440.stable": true, - "fi.mau.msc2815": false - } -}` - -func TestRespVersions_UnmarshalJSON(t *testing.T) { - var resp mautrix.RespVersions - err := json.Unmarshal([]byte(sampleVersions), &resp) - assert.NoError(t, err) - assert.True(t, resp.ContainsGreaterOrEqual(mautrix.SpecV11)) - assert.True(t, resp.Contains(mautrix.SpecV12)) - assert.True(t, resp.Contains(mautrix.SpecR061)) - assert.True(t, resp.ContainsGreaterOrEqual(mautrix.MustParseSpecVersion("r0.0.0"))) - assert.True(t, !resp.ContainsGreaterOrEqual(mautrix.MustParseSpecVersion("v123.456"))) -} - -func TestParseSpecVersion(t *testing.T) { - assert.Equal(t, - mautrix.SpecVersion{mautrix.SpecVersionFormatR, 0, 1, 0, "r0.1.0"}, - mautrix.MustParseSpecVersion("r0.1.0")) - assert.Equal(t, - mautrix.SpecVersion{mautrix.SpecVersionFormatV, 1, 1, 0, "v1.1"}, - mautrix.MustParseSpecVersion("v1.1")) - assert.Equal(t, - mautrix.SpecVersion{mautrix.SpecVersionFormatV, 123, 456, 0, "v123.456"}, - mautrix.MustParseSpecVersion("v123.456")) - - invalidVer, err := mautrix.ParseSpecVersion("not a version") - assert.Error(t, err) - assert.Equal(t, mautrix.SpecVersion{Raw: "not a version"}, invalidVer) - - // v syntax doesn't allow patch versions - invalidVer, err = mautrix.ParseSpecVersion("v1.2.3") - assert.Error(t, err) - assert.Equal(t, mautrix.SpecVersion{Raw: "v1.2.3"}, invalidVer) - - invalidVer, err = mautrix.ParseSpecVersion("r0.6") - assert.Error(t, err) - assert.Equal(t, mautrix.SpecVersion{Raw: "r0.6"}, invalidVer) -} - -func TestSpecVersion_String(t *testing.T) { - assert.Equal(t, "r0.1.0", (&mautrix.SpecVersion{mautrix.SpecVersionFormatR, 0, 1, 0, ""}).String()) - assert.Equal(t, "v1.2", (&mautrix.SpecVersion{mautrix.SpecVersionFormatV, 1, 2, 0, ""}).String()) - assert.Equal(t, "v567.890", (&mautrix.SpecVersion{mautrix.SpecVersionFormatV, 567, 890, 0, ""}).String()) - assert.Equal(t, "invalid version", (&mautrix.SpecVersion{Raw: "invalid version"}).String()) -} - -func TestSpecVersion_GreaterThan(t *testing.T) { - assert.True(t, mautrix.MustParseSpecVersion("r0.1.0").GreaterThan(mautrix.MustParseSpecVersion("r0.0.0"))) - assert.True(t, mautrix.MustParseSpecVersion("r0.6.0").GreaterThan(mautrix.MustParseSpecVersion("r0.1.0"))) - assert.True(t, mautrix.MustParseSpecVersion("r0.6.1").GreaterThan(mautrix.MustParseSpecVersion("r0.1.0"))) - assert.True(t, mautrix.MustParseSpecVersion("v1.1").GreaterThan(mautrix.MustParseSpecVersion("r0.6.1"))) - assert.True(t, mautrix.MustParseSpecVersion("v11.11").GreaterThan(mautrix.MustParseSpecVersion("v1.23"))) - assert.True(t, mautrix.MustParseSpecVersion("v1.123").GreaterThan(mautrix.MustParseSpecVersion("v1.1"))) - assert.True(t, !mautrix.MustParseSpecVersion("v1.23").GreaterThan(mautrix.MustParseSpecVersion("v2.31"))) - assert.True(t, !mautrix.MustParseSpecVersion("r0.6.0").GreaterThan(mautrix.MustParseSpecVersion("r0.6.1"))) - assert.True(t, !mautrix.MustParseSpecVersion("r0.6.0").GreaterThan(mautrix.MustParseSpecVersion("r0.6.0"))) - assert.True(t, !mautrix.MustParseSpecVersion("r0.6.0").LessThan(mautrix.MustParseSpecVersion("r0.6.0"))) -} From c32084002ba9d3cc1cbd673a6e62e6d43f71ba07 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 02:08:35 -0700 Subject: [PATCH 12/15] Move relay webhook helpers out of Matrix handlers --- pkg/connector/handlematrix.go | 339 ---------------- pkg/connector/relaywebhook.go | 372 ++++++++++++++++++ ...dlematrix_test.go => relaywebhook_test.go} | 0 3 files changed, 372 insertions(+), 339 deletions(-) create mode 100644 pkg/connector/relaywebhook.go rename pkg/connector/{handlematrix_test.go => relaywebhook_test.go} (100%) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index f9344cd1..e4ebaf9d 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -17,18 +17,13 @@ package connector import ( - "bytes" "context" "errors" "fmt" "maps" "math" - "net/http" - "net/url" - "regexp" "strings" "time" - "unicode/utf8" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" @@ -55,11 +50,8 @@ type contextKey int const ( contextKeyChannel contextKey = iota - relayWebhookName = "mau bridge" ) -var discordWebhookUsernameWord = regexp.MustCompile(`(?i)discord`) - type SendAttempt struct { At time.Time ChannelType discordgo.ChannelType @@ -208,260 +200,6 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M }, nil } -func relayWebhookFlags(flags *int) discordgo.MessageFlags { - if flags == nil { - return 0 - } - return discordgo.MessageFlags(*flags) & discordgo.MessageFlagsSuppressEmbeds -} - -func prependReplyEmbed(embeds []*discordgo.MessageEmbed, guildID, channelID, messageID string) []*discordgo.MessageEmbed { - if channelID == "" || messageID == "" { - return embeds - } - guildPart := guildID - if guildPart == "" { - guildPart = "@me" - } - replyEmbed := &discordgo.MessageEmbed{ - Description: fmt.Sprintf("[Replying to message](https://discord.com/channels/%s/%s/%s)", guildPart, channelID, messageID), - } - return append([]*discordgo.MessageEmbed{replyEmbed}, embeds...) -} - -func (d *DiscordClient) executeRelayWebhook( - ctx context.Context, - portal *bridgev2.Portal, - channelID string, - threadID string, - params *discordgo.WebhookParams, - refererOpt discordgo.RequestOption, -) (*discordgo.Message, error) { - webhookID, webhookToken, err := d.getRelayWebhook(ctx, portal, channelID, refererOpt) - if err != nil { - return nil, d.tryWrappingError(ctx, err) - } - sentMsg, err := d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) - if err == nil || !isStaleWebhookError(err) { - return sentMsg, err - } - zerolog.Ctx(ctx).Warn().Err(err).Msg("Relay webhook failed, clearing cached credentials and retrying once") - if clearErr := d.clearRelayWebhook(ctx, portal); clearErr != nil { - zerolog.Ctx(ctx).Warn().Err(clearErr).Msg("Failed to clear stale relay webhook metadata") - } - webhookID, webhookToken, err = d.getRelayWebhook(ctx, portal, channelID, refererOpt) - if err != nil { - return nil, d.tryWrappingError(ctx, err) - } - return d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) -} - -func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Portal, channelID string, refererOpt discordgo.RequestOption) (id, token string, err error) { - meta := portal.Metadata.(*discordid.PortalMetadata) - if meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" { - return meta.RelayWebhookID, meta.RelayWebhookToken, nil - } - - webhooks, err := d.Session.ChannelWebhooks(channelID, refererOpt, discordgo.WithContext(ctx)) - if err != nil { - return "", "", err - } - for _, webhook := range webhooks { - if webhook != nil && webhook.Name == relayWebhookName && webhook.Token != "" { - meta.RelayWebhookID = webhook.ID - meta.RelayWebhookToken = webhook.Token - if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") - } - return webhook.ID, webhook.Token, nil - } - } - - webhook, err := d.Session.WebhookCreate(channelID, relayWebhookName, "", refererOpt, discordgo.WithContext(ctx)) - if err != nil { - return "", "", err - } - meta.RelayWebhookID = webhook.ID - meta.RelayWebhookToken = webhook.Token - if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") - } - return webhook.ID, webhook.Token, nil -} - -func (d *DiscordClient) clearRelayWebhook(ctx context.Context, portal *bridgev2.Portal) error { - meta := portal.Metadata.(*discordid.PortalMetadata) - meta.RelayWebhookID = "" - meta.RelayWebhookToken = "" - return d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) -} - -func isStaleWebhookError(err error) bool { - var restErr *discordgo.RESTError - if !errors.As(err, &restErr) { - return false - } - if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusUnauthorized || restErr.Response.StatusCode == http.StatusNotFound) { - return true - } - return restErr.Message != nil && (restErr.Message.Code == discordgo.ErrCodeUnknownWebhook || restErr.Message.Code == discordgo.ErrCodeInvalidWebhookTokenProvided) -} - -func (d *DiscordClient) relayWebhookProfile(ctx context.Context, portal *bridgev2.Portal, sender *bridgev2.OrigSender) (username, avatarURL string) { - log := zerolog.Ctx(ctx) - username = relayWebhookUsername(sender) - if sender.User != nil { - if login, _, err := portal.FindPreferredLogin(ctx, sender.User, false); err != nil { - zerolog.Ctx(ctx).Debug().Err(err).Stringer("sender_mxid", sender.UserID).Msg("No explicit Discord login for relayed sender in portal") - } else if login != nil { - if user := d.userCache.Resolve(ctx, discordid.ParseUserLoginID(login.ID)); user != nil { - log.Debug(). - Stringer("sender_mxid", sender.UserID). - Str("login_id", string(login.ID)). - Str("webhook_username", user.DisplayName()). - Bool("has_avatar_url", user.AvatarURL("256") != ""). - Msg("Resolved relay webhook profile from explicit Discord login") - return user.DisplayName(), user.AvatarURL("256") - } - } - } - if ghostID, ok := d.UserLogin.Bridge.Matrix.ParseGhostMXID(sender.UserID); ok { - if user := d.userCache.Resolve(ctx, discordid.ParseUserID(ghostID)); user != nil { - log.Debug(). - Stringer("sender_mxid", sender.UserID). - Str("ghost_id", string(ghostID)). - Str("webhook_username", user.DisplayName()). - Bool("has_avatar_url", user.AvatarURL("256") != ""). - Msg("Resolved relay webhook profile from ghost MXID") - return user.DisplayName(), user.AvatarURL("256") - } - } - if profile := d.resolveRelayGhostByDisplayName(ctx, username); profile != nil { - log.Debug(). - Stringer("sender_mxid", sender.UserID). - Str("display_name", username). - Str("webhook_username", profile.username). - Bool("has_avatar_url", profile.avatarURL != ""). - Msg("Resolved relay webhook profile from matching Discord ghost display name") - return profile.username, profile.avatarURL - } - log.Debug(). - Stringer("sender_mxid", sender.UserID). - Str("display_name", username). - Msg("Falling back to Matrix relay webhook profile") - return username, "" -} - -func sanitizeRelayWebhookUsername(username, fallback string) string { - username = strings.TrimSpace(username) - if username == "" { - username = strings.TrimSpace(fallback) - } - username = strings.Map(func(r rune) rune { - if r < 0x20 || r == 0x7f { - return -1 - } - return r - }, username) - username = strings.TrimSpace(discordWebhookUsernameWord.ReplaceAllString(username, "dscord")) - if username == "" { - username = "Matrix user" - } - const maxWebhookUsernameRunes = 80 - if utf8.RuneCountInString(username) > maxWebhookUsernameRunes { - runes := []rune(username) - username = string(runes[:maxWebhookUsernameRunes]) - } - return username -} - -func relayWebhookUsername(sender *bridgev2.OrigSender) string { - if sender.PerMessageProfile.Displayname != "" { - return sender.PerMessageProfile.Displayname - } - if sender.MemberEventContent.Displayname != "" { - return sender.MemberEventContent.Displayname - } - return sender.DisambiguatedName -} - -type relayWebhookProfileMatch struct { - username string - avatarURL string -} - -type relayWebhookGhostMatch struct { - userID string - name string - avatarURL string -} - -func chooseRelayWebhookGhostMatch(matches []relayWebhookGhostMatch) *relayWebhookGhostMatch { - var realAvatarMatches []relayWebhookGhostMatch - for _, match := range matches { - if !strings.Contains(match.avatarURL, "cdn.discordapp.com/embed/avatars/") { - realAvatarMatches = append(realAvatarMatches, match) - } - } - switch { - case len(realAvatarMatches) == 1: - return &realAvatarMatches[0] - case len(realAvatarMatches) > 1: - return nil - case len(matches) == 1: - return &matches[0] - default: - return nil - } -} - -func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *relayWebhookProfileMatch { - displayName = strings.TrimSpace(displayName) - if displayName == "" { - return nil - } - - rows, err := d.UserLogin.Bridge.DB.Query(ctx, ` - SELECT id, name, avatar_id FROM ghost - WHERE bridge_id=$1 AND ( - name=$2 OR - name=$3 OR - name=$4 - ) - `, d.UserLogin.Bridge.DB.BridgeID, displayName, displayName+" (Discord)", displayName+" (bot) (Discord)") - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to resolve relay sender against Discord ghosts") - return nil - } - defer rows.Close() - - var matches []relayWebhookGhostMatch - for rows.Next() { - var match relayWebhookGhostMatch - if err = rows.Scan(&match.userID, &match.name, &match.avatarURL); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to scan relay ghost match") - return nil - } - matches = append(matches, match) - } - if err = rows.Err(); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") - return nil - } - matched := chooseRelayWebhookGhostMatch(matches) - if matched == nil { - zerolog.Ctx(ctx).Debug(). - Str("display_name", displayName). - Int("match_count", len(matches)). - Msg("Skipping relay webhook profile match due to ambiguous Discord ghost display names") - return nil - } - return &relayWebhookProfileMatch{ - username: matched.name, - avatarURL: matched.avatarURL, - } -} - var errCannotDMStranger = errors.New("can't direct message a stranger") func (d *DiscordClient) screenOutgoingMessage(ctx context.Context, destCh *discordgo.Channel) error { @@ -573,83 +311,6 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr return nil } -func (d *DiscordClient) populateRelayWebhookEditMedia(ctx context.Context, edit *discordgo.WebhookEdit, content *event.MessageEventContent) error { - switch content.MsgType { - case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: - default: - return nil - } - mediaData, err := d.connector.MsgConv.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to download Matrix attachment for relay webhook edit") - return bridgev2.ErrMediaDownloadFailed - } - filename := content.Body - if content.FileName != "" { - filename = content.FileName - } - if filename == "" { - filename = "attachment" - } - contentType := "" - if content.Info != nil { - contentType = content.Info.MimeType - } - edit.Files = []*discordgo.File{{ - Name: filename, - ContentType: contentType, - Reader: bytes.NewReader(mediaData), - }} - attachments := []*discordgo.MessageAttachment{} - edit.Attachments = &attachments - return nil -} - -func (d *DiscordClient) webhookMessageEditThread(ctx context.Context, webhookID, token, messageID, threadID string, data *discordgo.WebhookEdit) (*discordgo.Message, error) { - uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) - var response []byte - var err error - if len(data.Files) > 0 { - contentType, body, encodeErr := discordgo.MultipartBodyWithJSON(data, data.Files) - if encodeErr != nil { - return nil, encodeErr - } - response, err = d.Session.RequestRaw("PATCH", uri, contentType, body, uri, 0, discordgo.WithContext(ctx)) - } else { - response, err = d.Session.RequestWithBucketID("PATCH", uri, data, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) - } - if err != nil { - return nil, err - } - var edited *discordgo.Message - err = discordgo.Unmarshal(response, &edited) - return edited, err -} - -func (d *DiscordClient) webhookMessageDeleteThread(ctx context.Context, webhookID, token, messageID, threadID string) error { - uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) - _, err := d.Session.RequestWithBucketID("DELETE", uri, nil, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) - return err -} - -func webhookMessageThreadURI(webhookID, token, messageID, threadID string) string { - uri := discordgo.EndpointWebhookMessage(webhookID, token, messageID) - if threadID == "" { - return uri - } - v := url.Values{} - v.Set("thread_id", threadID) - return uri + "?" + v.Encode() -} - -func (d *DiscordClient) isRelayWebhookMessage(portal *bridgev2.Portal, msg *database.Message) bool { - if msg == nil { - return false - } - meta := portal.Metadata.(*discordid.PortalMetadata) - return meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" && string(msg.SenderID) == meta.RelayWebhookID -} - func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { if !d.IsLoggedIn() { return bridgev2.MatrixReactionPreResponse{}, bridgev2.ErrNotLoggedIn diff --git a/pkg/connector/relaywebhook.go b/pkg/connector/relaywebhook.go new file mode 100644 index 00000000..3367d5ff --- /dev/null +++ b/pkg/connector/relaywebhook.go @@ -0,0 +1,372 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + + "go.mau.fi/mautrix-discord/pkg/discordid" +) + +const relayWebhookName = "mau bridge" + +var discordWebhookUsernameWord = regexp.MustCompile(`(?i)discord`) + +func relayWebhookFlags(flags *int) discordgo.MessageFlags { + if flags == nil { + return 0 + } + return discordgo.MessageFlags(*flags) & discordgo.MessageFlagsSuppressEmbeds +} + +func prependReplyEmbed(embeds []*discordgo.MessageEmbed, guildID, channelID, messageID string) []*discordgo.MessageEmbed { + if channelID == "" || messageID == "" { + return embeds + } + guildPart := guildID + if guildPart == "" { + guildPart = "@me" + } + replyEmbed := &discordgo.MessageEmbed{ + Description: fmt.Sprintf("[Replying to message](https://discord.com/channels/%s/%s/%s)", guildPart, channelID, messageID), + } + return append([]*discordgo.MessageEmbed{replyEmbed}, embeds...) +} + +func (d *DiscordClient) executeRelayWebhook( + ctx context.Context, + portal *bridgev2.Portal, + channelID string, + threadID string, + params *discordgo.WebhookParams, + refererOpt discordgo.RequestOption, +) (*discordgo.Message, error) { + webhookID, webhookToken, err := d.getRelayWebhook(ctx, portal, channelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + sentMsg, err := d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) + if err == nil || !isStaleWebhookError(err) { + return sentMsg, err + } + zerolog.Ctx(ctx).Warn().Err(err).Msg("Relay webhook failed, clearing cached credentials and retrying once") + if clearErr := d.clearRelayWebhook(ctx, portal); clearErr != nil { + zerolog.Ctx(ctx).Warn().Err(clearErr).Msg("Failed to clear stale relay webhook metadata") + } + webhookID, webhookToken, err = d.getRelayWebhook(ctx, portal, channelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + return d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) +} + +func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Portal, channelID string, refererOpt discordgo.RequestOption) (id, token string, err error) { + meta := portal.Metadata.(*discordid.PortalMetadata) + if meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" { + return meta.RelayWebhookID, meta.RelayWebhookToken, nil + } + + webhooks, err := d.Session.ChannelWebhooks(channelID, refererOpt, discordgo.WithContext(ctx)) + if err != nil { + return "", "", err + } + for _, webhook := range webhooks { + if webhook != nil && webhook.Name == relayWebhookName && webhook.Token != "" { + meta.RelayWebhookID = webhook.ID + meta.RelayWebhookToken = webhook.Token + if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") + } + return webhook.ID, webhook.Token, nil + } + } + + webhook, err := d.Session.WebhookCreate(channelID, relayWebhookName, "", refererOpt, discordgo.WithContext(ctx)) + if err != nil { + return "", "", err + } + meta.RelayWebhookID = webhook.ID + meta.RelayWebhookToken = webhook.Token + if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") + } + return webhook.ID, webhook.Token, nil +} + +func (d *DiscordClient) clearRelayWebhook(ctx context.Context, portal *bridgev2.Portal) error { + meta := portal.Metadata.(*discordid.PortalMetadata) + meta.RelayWebhookID = "" + meta.RelayWebhookToken = "" + return d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) +} + +func isStaleWebhookError(err error) bool { + var restErr *discordgo.RESTError + if !errors.As(err, &restErr) { + return false + } + if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusUnauthorized || restErr.Response.StatusCode == http.StatusNotFound) { + return true + } + return restErr.Message != nil && (restErr.Message.Code == discordgo.ErrCodeUnknownWebhook || restErr.Message.Code == discordgo.ErrCodeInvalidWebhookTokenProvided) +} + +func (d *DiscordClient) relayWebhookProfile(ctx context.Context, portal *bridgev2.Portal, sender *bridgev2.OrigSender) (username, avatarURL string) { + log := zerolog.Ctx(ctx) + username = relayWebhookUsername(sender) + if sender.User != nil { + if login, _, err := portal.FindPreferredLogin(ctx, sender.User, false); err != nil { + zerolog.Ctx(ctx).Debug().Err(err).Stringer("sender_mxid", sender.UserID).Msg("No explicit Discord login for relayed sender in portal") + } else if login != nil { + if user := d.userCache.Resolve(ctx, discordid.ParseUserLoginID(login.ID)); user != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("login_id", string(login.ID)). + Str("webhook_username", user.DisplayName()). + Bool("has_avatar_url", user.AvatarURL("256") != ""). + Msg("Resolved relay webhook profile from explicit Discord login") + return user.DisplayName(), user.AvatarURL("256") + } + } + } + if ghostID, ok := d.UserLogin.Bridge.Matrix.ParseGhostMXID(sender.UserID); ok { + if user := d.userCache.Resolve(ctx, discordid.ParseUserID(ghostID)); user != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("ghost_id", string(ghostID)). + Str("webhook_username", user.DisplayName()). + Bool("has_avatar_url", user.AvatarURL("256") != ""). + Msg("Resolved relay webhook profile from ghost MXID") + return user.DisplayName(), user.AvatarURL("256") + } + } + if profile := d.resolveRelayGhostByDisplayName(ctx, username); profile != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("display_name", username). + Str("webhook_username", profile.username). + Bool("has_avatar_url", profile.avatarURL != ""). + Msg("Resolved relay webhook profile from matching Discord ghost display name") + return profile.username, profile.avatarURL + } + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("display_name", username). + Msg("Falling back to Matrix relay webhook profile") + return username, "" +} + +func sanitizeRelayWebhookUsername(username, fallback string) string { + username = strings.TrimSpace(username) + if username == "" { + username = strings.TrimSpace(fallback) + } + username = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, username) + username = strings.TrimSpace(discordWebhookUsernameWord.ReplaceAllString(username, "dscord")) + if username == "" { + username = "Matrix user" + } + const maxWebhookUsernameRunes = 80 + if utf8.RuneCountInString(username) > maxWebhookUsernameRunes { + runes := []rune(username) + username = string(runes[:maxWebhookUsernameRunes]) + } + return username +} + +func relayWebhookUsername(sender *bridgev2.OrigSender) string { + if sender.PerMessageProfile.Displayname != "" { + return sender.PerMessageProfile.Displayname + } + if sender.MemberEventContent.Displayname != "" { + return sender.MemberEventContent.Displayname + } + return sender.DisambiguatedName +} + +type relayWebhookProfileMatch struct { + username string + avatarURL string +} + +type relayWebhookGhostMatch struct { + userID string + name string + avatarURL string +} + +func chooseRelayWebhookGhostMatch(matches []relayWebhookGhostMatch) *relayWebhookGhostMatch { + var realAvatarMatches []relayWebhookGhostMatch + for _, match := range matches { + if !strings.Contains(match.avatarURL, "cdn.discordapp.com/embed/avatars/") { + realAvatarMatches = append(realAvatarMatches, match) + } + } + switch { + case len(realAvatarMatches) == 1: + return &realAvatarMatches[0] + case len(realAvatarMatches) > 1: + return nil + case len(matches) == 1: + return &matches[0] + default: + return nil + } +} + +func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *relayWebhookProfileMatch { + displayName = strings.TrimSpace(displayName) + if displayName == "" { + return nil + } + + rows, err := d.UserLogin.Bridge.DB.Query(ctx, ` + SELECT id, name, avatar_id FROM ghost + WHERE bridge_id=$1 AND ( + name=$2 OR + name=$3 OR + name=$4 + ) + `, d.UserLogin.Bridge.DB.BridgeID, displayName, displayName+" (Discord)", displayName+" (bot) (Discord)") + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to resolve relay sender against Discord ghosts") + return nil + } + defer rows.Close() + + var matches []relayWebhookGhostMatch + for rows.Next() { + var match relayWebhookGhostMatch + if err = rows.Scan(&match.userID, &match.name, &match.avatarURL); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to scan relay ghost match") + return nil + } + matches = append(matches, match) + } + if err = rows.Err(); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") + return nil + } + matched := chooseRelayWebhookGhostMatch(matches) + if matched == nil { + zerolog.Ctx(ctx).Debug(). + Str("display_name", displayName). + Int("match_count", len(matches)). + Msg("Skipping relay webhook profile match due to ambiguous Discord ghost display names") + return nil + } + return &relayWebhookProfileMatch{ + username: matched.name, + avatarURL: matched.avatarURL, + } +} + +func (d *DiscordClient) populateRelayWebhookEditMedia(ctx context.Context, edit *discordgo.WebhookEdit, content *event.MessageEventContent) error { + switch content.MsgType { + case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: + default: + return nil + } + mediaData, err := d.connector.MsgConv.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to download Matrix attachment for relay webhook edit") + return bridgev2.ErrMediaDownloadFailed + } + filename := content.Body + if content.FileName != "" { + filename = content.FileName + } + if filename == "" { + filename = "attachment" + } + contentType := "" + if content.Info != nil { + contentType = content.Info.MimeType + } + edit.Files = []*discordgo.File{{ + Name: filename, + ContentType: contentType, + Reader: bytes.NewReader(mediaData), + }} + attachments := []*discordgo.MessageAttachment{} + edit.Attachments = &attachments + return nil +} + +func (d *DiscordClient) webhookMessageEditThread(ctx context.Context, webhookID, token, messageID, threadID string, data *discordgo.WebhookEdit) (*discordgo.Message, error) { + uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) + var response []byte + var err error + if len(data.Files) > 0 { + contentType, body, encodeErr := discordgo.MultipartBodyWithJSON(data, data.Files) + if encodeErr != nil { + return nil, encodeErr + } + response, err = d.Session.RequestRaw("PATCH", uri, contentType, body, uri, 0, discordgo.WithContext(ctx)) + } else { + response, err = d.Session.RequestWithBucketID("PATCH", uri, data, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) + } + if err != nil { + return nil, err + } + var edited *discordgo.Message + err = discordgo.Unmarshal(response, &edited) + return edited, err +} + +func (d *DiscordClient) webhookMessageDeleteThread(ctx context.Context, webhookID, token, messageID, threadID string) error { + uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) + _, err := d.Session.RequestWithBucketID("DELETE", uri, nil, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) + return err +} + +func webhookMessageThreadURI(webhookID, token, messageID, threadID string) string { + uri := discordgo.EndpointWebhookMessage(webhookID, token, messageID) + if threadID == "" { + return uri + } + v := url.Values{} + v.Set("thread_id", threadID) + return uri + "?" + v.Encode() +} + +func (d *DiscordClient) isRelayWebhookMessage(portal *bridgev2.Portal, msg *database.Message) bool { + if msg == nil { + return false + } + meta := portal.Metadata.(*discordid.PortalMetadata) + return meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" && string(msg.SenderID) == meta.RelayWebhookID +} diff --git a/pkg/connector/handlematrix_test.go b/pkg/connector/relaywebhook_test.go similarity index 100% rename from pkg/connector/handlematrix_test.go rename to pkg/connector/relaywebhook_test.go From d04624ad13de1637cface77972c391cb5c27afb2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 03:17:16 -0700 Subject: [PATCH 13/15] Show relayed reactions with sender profile --- pkg/connector/dbmeta.go | 3 ++ pkg/connector/handlematrix.go | 52 +++++++++++++++++++++++++++--- pkg/connector/relaywebhook.go | 6 ++++ pkg/connector/relaywebhook_test.go | 11 +++++++ pkg/discordid/dbmeta.go | 4 +++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 6d8fbd5f..71c8bcd7 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -30,5 +30,8 @@ func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { UserLogin: func() any { return &discordid.UserLoginMetadata{} }, + Reaction: func() any { + return &discordid.ReactionMetadata{} + }, } } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index e4ebaf9d..2f2e37a5 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -317,6 +317,7 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b } emojiID := reaction.Content.RelatesTo.Key + emojiDisplay := emojiID // Figure out if this is a custom emoji or not. if strings.HasPrefix(emojiID, "mxc://") { @@ -329,17 +330,20 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b } emojiID = fmt.Sprintf("%s:%s", customEmoji.Name, customEmoji.ID) + emojiDisplay = fmt.Sprintf(":%s:", customEmoji.Name) } else { emojiID = variationselector.FullyQualify(emojiID) + emojiDisplay = emojiID } - // Relayed reactions have to use the relay login on Discord. Discord only - // allows one reaction per user/emoji, so multiple Matrix users reacting - // with the same emoji intentionally collapse into the relay user's one - // Discord reaction. + senderID := discordid.UserLoginIDToUserID(d.UserLogin.ID) + if reaction.OrigSender != nil { + senderID = relayReactionSenderID(reaction.OrigSender) + } return bridgev2.MatrixReactionPreResponse{ - SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID), + SenderID: senderID, EmojiID: discordid.MakeEmojiID(emojiID), + Emoji: emojiDisplay, }, nil } @@ -363,6 +367,33 @@ func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *brid } } + if reaction.OrigSender != nil { + username, avatarURL := d.relayWebhookProfile(ctx, portal, reaction.OrigSender) + if username == "" { + username = reaction.OrigSender.UserID.String() + } + username = sanitizeRelayWebhookUsername(username, reaction.OrigSender.UserID.String()) + params := &discordgo.WebhookParams{ + Content: fmt.Sprintf("reacted %s", reaction.PreHandleResp.Emoji), + Username: username, + AvatarURL: avatarURL, + Embeds: prependReplyEmbed(nil, meta.GuildID, channelID, discordid.ParseMessageID(reaction.TargetMessage.ID)), + AllowedMentions: &discordgo.MessageAllowedMentions{Parse: []discordgo.AllowedMentionType{}}, + } + sent, err := d.executeRelayWebhook(ctx, portal, parentChannelID, threadChannelID, params, makeDiscordReferer(meta.GuildID, parentChannelID, threadChannelID)) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + return &database.Reaction{ + SenderID: reaction.PreHandleResp.SenderID, + EmojiID: reaction.PreHandleResp.EmojiID, + Emoji: reaction.PreHandleResp.Emoji, + Metadata: &discordid.ReactionMetadata{ + RelayWebhookMessageID: sent.ID, + }, + }, nil + } + return nil, d.tryWrappingError(ctx, d.Session.MessageReactionAddUser( meta.GuildID, channelID, @@ -397,6 +428,17 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal } } + if meta, _ := removing.Metadata.(*discordid.ReactionMetadata); meta != nil && meta.RelayWebhookMessageID != "" { + portalMeta := removal.Portal.Metadata.(*discordid.PortalMetadata) + return d.tryWrappingError(ctx, d.webhookMessageDeleteThread( + ctx, + portalMeta.RelayWebhookID, + portalMeta.RelayWebhookToken, + meta.RelayWebhookMessageID, + threadChannelID, + )) + } + return d.tryWrappingError(ctx, d.Session.MessageReactionRemoveUser( guildID, channelID, diff --git a/pkg/connector/relaywebhook.go b/pkg/connector/relaywebhook.go index 3367d5ff..05777478 100644 --- a/pkg/connector/relaywebhook.go +++ b/pkg/connector/relaywebhook.go @@ -31,12 +31,14 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-discord/pkg/discordid" ) const relayWebhookName = "mau bridge" +const relayReactionSenderIDPrefix = "matrix:" var discordWebhookUsernameWord = regexp.MustCompile(`(?i)discord`) @@ -217,6 +219,10 @@ func relayWebhookUsername(sender *bridgev2.OrigSender) string { return sender.DisambiguatedName } +func relayReactionSenderID(sender *bridgev2.OrigSender) networkid.UserID { + return networkid.UserID(relayReactionSenderIDPrefix + sender.UserID.String()) +} + type relayWebhookProfileMatch struct { username string avatarURL string diff --git a/pkg/connector/relaywebhook_test.go b/pkg/connector/relaywebhook_test.go index e0fd883a..e5dd6003 100644 --- a/pkg/connector/relaywebhook_test.go +++ b/pkg/connector/relaywebhook_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/bwmarrin/discordgo" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/id" ) func TestChooseRelayWebhookGhostMatch(t *testing.T) { @@ -101,6 +103,15 @@ func TestRelayWebhookFlagsOnlyAllowsSuppressEmbeds(t *testing.T) { } } +func TestRelayReactionSenderIDUsesOriginalMatrixUser(t *testing.T) { + sender := &bridgev2.OrigSender{UserID: id.UserID("@keith:beeper.com")} + got := relayReactionSenderID(sender) + want := "matrix:@keith:beeper.com" + if string(got) != want { + t.Fatalf("unexpected relay reaction sender ID: got %q, want %q", got, want) + } +} + func TestSanitizeRelayWebhookUsername(t *testing.T) { got := sanitizeRelayWebhookUsername(" Discord\nBridge ", "@user:example.com") if strings.Contains(strings.ToLower(got), "discord") { diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go index f68cc5fd..a0d0c6d4 100644 --- a/pkg/discordid/dbmeta.go +++ b/pkg/discordid/dbmeta.go @@ -44,6 +44,10 @@ type UserLoginMetadata struct { BridgedGuildIDs map[string]bool `json:"bridged_guild_ids,omitempty"` } +type ReactionMetadata struct { + RelayWebhookMessageID string `json:"relay_webhook_message_id,omitempty"` +} + var _ database.MetaMerger = (*UserLoginMetadata)(nil) func (ulm *UserLoginMetadata) CopyFrom(incoming any) { From b8e193f076605ab448067258de06193499b17f61 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 08:02:36 -0700 Subject: [PATCH 14/15] Revert "Show relayed reactions with sender profile" This reverts commit d04624ad13de1637cface77972c391cb5c27afb2. --- pkg/connector/dbmeta.go | 3 -- pkg/connector/handlematrix.go | 52 +++--------------------------- pkg/connector/relaywebhook.go | 6 ---- pkg/connector/relaywebhook_test.go | 11 ------- pkg/discordid/dbmeta.go | 4 --- 5 files changed, 5 insertions(+), 71 deletions(-) diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 71c8bcd7..6d8fbd5f 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -30,8 +30,5 @@ func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { UserLogin: func() any { return &discordid.UserLoginMetadata{} }, - Reaction: func() any { - return &discordid.ReactionMetadata{} - }, } } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 2f2e37a5..e4ebaf9d 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -317,7 +317,6 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b } emojiID := reaction.Content.RelatesTo.Key - emojiDisplay := emojiID // Figure out if this is a custom emoji or not. if strings.HasPrefix(emojiID, "mxc://") { @@ -330,20 +329,17 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b } emojiID = fmt.Sprintf("%s:%s", customEmoji.Name, customEmoji.ID) - emojiDisplay = fmt.Sprintf(":%s:", customEmoji.Name) } else { emojiID = variationselector.FullyQualify(emojiID) - emojiDisplay = emojiID } - senderID := discordid.UserLoginIDToUserID(d.UserLogin.ID) - if reaction.OrigSender != nil { - senderID = relayReactionSenderID(reaction.OrigSender) - } + // Relayed reactions have to use the relay login on Discord. Discord only + // allows one reaction per user/emoji, so multiple Matrix users reacting + // with the same emoji intentionally collapse into the relay user's one + // Discord reaction. return bridgev2.MatrixReactionPreResponse{ - SenderID: senderID, + SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID), EmojiID: discordid.MakeEmojiID(emojiID), - Emoji: emojiDisplay, }, nil } @@ -367,33 +363,6 @@ func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *brid } } - if reaction.OrigSender != nil { - username, avatarURL := d.relayWebhookProfile(ctx, portal, reaction.OrigSender) - if username == "" { - username = reaction.OrigSender.UserID.String() - } - username = sanitizeRelayWebhookUsername(username, reaction.OrigSender.UserID.String()) - params := &discordgo.WebhookParams{ - Content: fmt.Sprintf("reacted %s", reaction.PreHandleResp.Emoji), - Username: username, - AvatarURL: avatarURL, - Embeds: prependReplyEmbed(nil, meta.GuildID, channelID, discordid.ParseMessageID(reaction.TargetMessage.ID)), - AllowedMentions: &discordgo.MessageAllowedMentions{Parse: []discordgo.AllowedMentionType{}}, - } - sent, err := d.executeRelayWebhook(ctx, portal, parentChannelID, threadChannelID, params, makeDiscordReferer(meta.GuildID, parentChannelID, threadChannelID)) - if err != nil { - return nil, d.tryWrappingError(ctx, err) - } - return &database.Reaction{ - SenderID: reaction.PreHandleResp.SenderID, - EmojiID: reaction.PreHandleResp.EmojiID, - Emoji: reaction.PreHandleResp.Emoji, - Metadata: &discordid.ReactionMetadata{ - RelayWebhookMessageID: sent.ID, - }, - }, nil - } - return nil, d.tryWrappingError(ctx, d.Session.MessageReactionAddUser( meta.GuildID, channelID, @@ -428,17 +397,6 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal } } - if meta, _ := removing.Metadata.(*discordid.ReactionMetadata); meta != nil && meta.RelayWebhookMessageID != "" { - portalMeta := removal.Portal.Metadata.(*discordid.PortalMetadata) - return d.tryWrappingError(ctx, d.webhookMessageDeleteThread( - ctx, - portalMeta.RelayWebhookID, - portalMeta.RelayWebhookToken, - meta.RelayWebhookMessageID, - threadChannelID, - )) - } - return d.tryWrappingError(ctx, d.Session.MessageReactionRemoveUser( guildID, channelID, diff --git a/pkg/connector/relaywebhook.go b/pkg/connector/relaywebhook.go index 05777478..3367d5ff 100644 --- a/pkg/connector/relaywebhook.go +++ b/pkg/connector/relaywebhook.go @@ -31,14 +31,12 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-discord/pkg/discordid" ) const relayWebhookName = "mau bridge" -const relayReactionSenderIDPrefix = "matrix:" var discordWebhookUsernameWord = regexp.MustCompile(`(?i)discord`) @@ -219,10 +217,6 @@ func relayWebhookUsername(sender *bridgev2.OrigSender) string { return sender.DisambiguatedName } -func relayReactionSenderID(sender *bridgev2.OrigSender) networkid.UserID { - return networkid.UserID(relayReactionSenderIDPrefix + sender.UserID.String()) -} - type relayWebhookProfileMatch struct { username string avatarURL string diff --git a/pkg/connector/relaywebhook_test.go b/pkg/connector/relaywebhook_test.go index e5dd6003..e0fd883a 100644 --- a/pkg/connector/relaywebhook_test.go +++ b/pkg/connector/relaywebhook_test.go @@ -7,8 +7,6 @@ import ( "testing" "github.com/bwmarrin/discordgo" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/id" ) func TestChooseRelayWebhookGhostMatch(t *testing.T) { @@ -103,15 +101,6 @@ func TestRelayWebhookFlagsOnlyAllowsSuppressEmbeds(t *testing.T) { } } -func TestRelayReactionSenderIDUsesOriginalMatrixUser(t *testing.T) { - sender := &bridgev2.OrigSender{UserID: id.UserID("@keith:beeper.com")} - got := relayReactionSenderID(sender) - want := "matrix:@keith:beeper.com" - if string(got) != want { - t.Fatalf("unexpected relay reaction sender ID: got %q, want %q", got, want) - } -} - func TestSanitizeRelayWebhookUsername(t *testing.T) { got := sanitizeRelayWebhookUsername(" Discord\nBridge ", "@user:example.com") if strings.Contains(strings.ToLower(got), "discord") { diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go index a0d0c6d4..f68cc5fd 100644 --- a/pkg/discordid/dbmeta.go +++ b/pkg/discordid/dbmeta.go @@ -44,10 +44,6 @@ type UserLoginMetadata struct { BridgedGuildIDs map[string]bool `json:"bridged_guild_ids,omitempty"` } -type ReactionMetadata struct { - RelayWebhookMessageID string `json:"relay_webhook_message_id,omitempty"` -} - var _ database.MetaMerger = (*UserLoginMetadata)(nil) func (ulm *UserLoginMetadata) CopyFrom(incoming any) { From b43df737d28335b0ba3f0172b991b6956daf26d8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 08:05:31 -0700 Subject: [PATCH 15/15] Ignore incoming relay webhook echoes --- pkg/connector/handlediscord.go | 34 +++++++++++++++++++++++++++++ pkg/connector/relaywebhook_test.go | 35 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 6bc77f15..09225c07 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -724,6 +724,32 @@ func (d *DiscordClient) channelIsBridged(ctx context.Context, channelID string) return existingPortal != nil && existingPortal.MXID != "", route } +func (d *DiscordClient) isOwnRelayWebhookMessage(ctx context.Context, msg *discordgo.Message, route *router.Route) bool { + if msg == nil || route == nil { + return false + } + portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, route.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to look up portal when checking relay webhook message") + return false + } + if portal == nil { + return false + } + meta := portal.Metadata.(*discordid.PortalMetadata) + return isRelayWebhookDiscordMessage(msg, meta.RelayWebhookID) +} + +func isRelayWebhookDiscordMessage(msg *discordgo.Message, relayWebhookID string) bool { + if msg == nil || relayWebhookID == "" { + return false + } + if msg.WebhookID == relayWebhookID { + return true + } + return msg.WebhookID != "" && msg.Author != nil && msg.Author.ID == relayWebhookID +} + func (d *DiscordClient) handleUserGuildSettingsUpdate(ctx context.Context, evt *discordgo.UserGuildSettingsUpdate) { log := zerolog.Ctx(ctx) log.Debug().Msg("Handling user guild settings update") @@ -1010,6 +1036,10 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { } return } + if d.isOwnRelayWebhookMessage(ctx, evt.Message, route) { + log.Debug().Msg("Dropping message from own relay webhook") + return + } if evt.Message.Type == discordgo.MessageTypeGuildMemberJoin { d.userCache.UpdateWithMessage(evt.Message) @@ -1030,6 +1060,10 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { if !bridged { return } + if d.isOwnRelayWebhookMessage(ctx, evt.Message, route) { + log.Debug().Msg("Dropping message update from own relay webhook") + return + } if err := d.upsertThreadInfoFromMessage(ctx, evt.Message); err != nil { log.Err(err).Str("message_id", evt.ID).Msg("Failed to persist thread info from message update") diff --git a/pkg/connector/relaywebhook_test.go b/pkg/connector/relaywebhook_test.go index e0fd883a..52476949 100644 --- a/pkg/connector/relaywebhook_test.go +++ b/pkg/connector/relaywebhook_test.go @@ -130,6 +130,41 @@ func TestWebhookMessageThreadURI(t *testing.T) { } } +func TestIsRelayWebhookDiscordMessage(t *testing.T) { + tests := []struct { + name string + msg *discordgo.Message + want bool + }{ + { + name: "webhook id match", + msg: &discordgo.Message{WebhookID: "relay-webhook", Author: &discordgo.User{ID: "other"}}, + want: true, + }, + { + name: "author id match with webhook marker", + msg: &discordgo.Message{WebhookID: "some-webhook", Author: &discordgo.User{ID: "relay-webhook"}}, + want: true, + }, + { + name: "author match without webhook marker", + msg: &discordgo.Message{Author: &discordgo.User{ID: "relay-webhook"}}, + }, + { + name: "different webhook", + msg: &discordgo.Message{WebhookID: "other", Author: &discordgo.User{ID: "other"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isRelayWebhookDiscordMessage(tc.msg, "relay-webhook"); got != tc.want { + t.Fatalf("unexpected relay webhook classification: got %t, want %t", got, tc.want) + } + }) + } +} + func TestIsStaleWebhookError(t *testing.T) { tests := []struct { name string