diff --git a/README.md b/README.md index b796285..ac6b5d7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ require bridge updates. - Direct messages and group chats, including starting new ones from Beeper. - Text and media send and receive. - Replies, reactions, read receipts, and typing notifications. +- Block and unblock users from DMs, with Reddit block-list synchronization. ## Configuration @@ -31,6 +32,11 @@ the Matrix-leave switch. DMs are hidden for the owner, while groups are left. Group invite/kick capabilities remain rejected in DMs; Reddit enforces group permissions and invitation limits. Ban/unban and role changes are unsupported. +Blocking from a DM applies to the Reddit account, including posts and comments. +It requires a saved Reddit login session. Changes made in Reddit are checked on +connect and approximately once per minute while chat sync is running. Blocking +does not leave shared groups. Reporting spam alongside blocking is unsupported. + ## Setup Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager): @@ -127,3 +133,4 @@ pkg/redditchat/ Reddit chat client library | New DMs and groups | ✅ | ✅ Username search | | Message requests | ✅ | ✅ Accept or ignore | | Conversation deletion | ✅ Hidden and left chats | ✅ Hides DMs, leaves groups | +| User blocking | ✅ Polled block list | ✅ Block/unblock from DMs | diff --git a/go.mod b/go.mod index 4014c40..41f8edc 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/beeper/reddit -go 1.26 +go 1.26.0 tool go.mau.fi/util/cmd/maubuild require ( github.com/google/uuid v1.6.0 github.com/rs/zerolog v1.35.1 - go.mau.fi/util v0.10.0 + go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.30.0 + maunium.net/go/mautrix v0.31.1-0.20261005140053-291f5e87ccea ) require ( @@ -19,23 +19,23 @@ 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.20 // indirect - github.com/mattn/go-sqlite3 v1.14.49 // indirect - github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b // indirect + github.com/mattn/go-sqlite3 v1.14.52 // indirect + github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect github.com/rs/xid v1.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/yuin/goldmark v1.8.5 // indirect + github.com/yuin/goldmark v1.8.6 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect - golang.org/x/mod v0.40.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.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 4bcbe63..cd57600 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= @@ -16,20 +14,18 @@ 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.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.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= -github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= -github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b h1:sS7HLzwS+dO+gxATgQfeZDEdUZe2pKAB3nGoUwP5zU0= -github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b/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/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= +github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= 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= @@ -40,27 +36,27 @@ 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.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= -github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk= -go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 h1:PxaO4u8ti7Eef1WKPAINXrcUkqKshE+n239UtbbJOn4= +go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= 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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= -golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= 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= @@ -69,5 +65,5 @@ 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= -maunium.net/go/mautrix v0.30.0 h1:bad+q7w5tLqiHpr+oUxVI+8m8ePbV3AvoFKg2jQzPyo= -maunium.net/go/mautrix v0.30.0/go.mod h1:bb0gjxbTFOqTaAYKGw5E7j9XROUR2Sl1Etm3IbmYXbo= +maunium.net/go/mautrix v0.31.1-0.20261005140053-291f5e87ccea h1:nQXBmLL6BUm1qxIWRFrLZ6qdo4AXs+TOYftI0BdIGj4= +maunium.net/go/mautrix v0.31.1-0.20261005140053-291f5e87ccea/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk= diff --git a/pkg/connector/blocking.go b/pkg/connector/blocking.go new file mode 100644 index 0000000..7d3132c --- /dev/null +++ b/pkg/connector/blocking.go @@ -0,0 +1,130 @@ +package connector + +import ( + "context" + "errors" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" +) + +var _ bridgev2.UserBlockingNetworkAPI = (*RedditClient)(nil) + +func (r *RedditClient) HandleMatrixBlockUser(ctx context.Context, msg *bridgev2.MatrixBlockUser) error { + if msg.Content.ReportSpam { + return errors.New("spam reporting is not supported") + } + if r.session == nil { + return errors.New("blocking requires a saved Reddit session; sign in again") + } + userID := msg.Portal.OtherUserID + if userID == "" || userID == r.userID { + return errors.New("could not identify the other Reddit user in this DM") + } + // Serialize native snapshots with mutations so an older response cannot + // overwrite the new state in the account's in-memory block list. + r.blockNetMu.Lock() + defer r.blockNetMu.Unlock() + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := r.session.SetUserBlocked(ctx, string(userID), msg.Content.Block); err != nil { + return err + } + r.blockMu.Lock() + if r.blockedUsers != nil { + r.blockedUsers[userID] = msg.Content.Block + } + r.blockMu.Unlock() + // bridgev2 updates and persists this portal only after this call succeeds. + return nil +} + +func (r *RedditClient) userBlocked(userID networkid.UserID) *bool { + r.blockMu.Lock() + defer r.blockMu.Unlock() + if r.blockedUsers == nil || userID == "" { + return nil + } + blocked := r.blockedUsers[userID] + return &blocked +} + +func (r *RedditClient) refreshBlockedUsers(ctx context.Context) ([]string, error) { + r.blockNetMu.Lock() + defer r.blockNetMu.Unlock() + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + users, err := r.session.GetBlockedUsers(ctx) + if err != nil { + return nil, err + } + blocked := make(map[networkid.UserID]bool, len(users)) + for _, user := range users { + blocked[networkid.UserID(user)] = true + } + r.blockMu.Lock() + r.blockedUsers = blocked + r.blockMu.Unlock() + return users, nil +} + +// Reddit's block list is outside Matrix /sync. Poll it on the existing sync +// loop and reconcile persisted DM flags, including unblocks missed offline. +func (r *RedditClient) syncBlockedUsers(ctx context.Context) error { + users, err := r.refreshBlockedUsers(ctx) + if err != nil { + return err + } + portals := r.main.Bridge.DB.Portal + keys, err := portals.GetAllBlockedDMsOf(ctx, r.userLogin.ID) + if err != nil { + return err + } + targets := make(map[networkid.PortalKey]networkid.UserID) + for _, key := range keys { + portal, err := portals.GetByKey(ctx, key) + if err != nil { + return err + } + if portal != nil { + targets[key] = portal.OtherUserID + } + } + for _, user := range users { + dms, err := portals.GetAllDMsWith(ctx, networkid.UserID(user)) + if err != nil { + return err + } + for _, dm := range dms { + if dm.Receiver == r.userLogin.ID { + targets[dm.PortalKey] = dm.OtherUserID + } + } + } + for key, userID := range targets { + r.userLogin.QueueRemoteEvent(&blockListChange{ + EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatInfoChange, PortalKey: key}, + client: r, + userID: userID, + }) + } + return nil +} + +type blockListChange struct { + simplevent.EventMeta + client *RedditClient + userID networkid.UserID +} + +var _ bridgev2.RemoteChatInfoChange = (*blockListChange)(nil) + +func (evt *blockListChange) GetChatInfoChange(context.Context) (*bridgev2.ChatInfoChange, error) { + // Read at handling time: a Beeper block/unblock may have completed while + // this native snapshot was waiting in the portal's event queue. + return &bridgev2.ChatInfoChange{ChatInfo: &bridgev2.ChatInfo{ + UserBlocked: evt.client.userBlocked(evt.userID), + }}, nil +} diff --git a/pkg/connector/blocking_test.go b/pkg/connector/blocking_test.go new file mode 100644 index 0000000..00dd742 --- /dev/null +++ b/pkg/connector/blocking_test.go @@ -0,0 +1,110 @@ +package connector + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/reddit/pkg/redditchat" +) + +// Catches sending a portal/Matrix ID instead of the native Reddit recipient. +func TestBlockUserRecipientAndNativeRejection(t *testing.T) { + for _, accepted := range []bool{true, false} { + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + var body struct { + Variables struct { + Input struct { + RedditorID string `json:"redditorId"` + } `json:"input"` + } `json:"variables"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Variables.Input.RedditorID != "t2_b" { + t.Errorf("wrong target: %q", body.Variables.Input.RedditorID) + } + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"updateRedditorBlockState": map[string]bool{"ok": accepted}}}) + }) + r.session, _ = redditchat.NewRedditSession(nil, r.meta.Credentials.Homeserver, "") + r.session.CSRFToken = "test-csrf" + r.blockedUsers = map[networkid.UserID]bool{} + msg := &bridgev2.MatrixBlockUser{ + Portal: &bridgev2.Portal{Portal: &database.Portal{OtherUserID: "t2_b"}}, + Content: &event.BeeperBlockUserEventContent{Block: true}, + } + err := r.HandleMatrixBlockUser(t.Context(), msg) + if (err == nil) != accepted || *r.userBlocked("t2_b") != accepted { + t.Fatalf("native result not preserved: accepted=%v error=%v", accepted, err) + } + } +} + +// Catches sharing another login's block list, setting group block flags, or +// clearing a known block when Reddit returns an invalid snapshot. +func TestBlockListNativeProjection(t *testing.T) { + body := `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[{"node":{"id":"t2_b"}}]}}}}` + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { io.WriteString(w, body) }) + r.session, _ = redditchat.NewRedditSession(nil, r.meta.Credentials.Homeserver, "") + r.session.CSRFToken = "test-csrf" + state := &RoomState{Type: "direct", Members: map[id.UserID]RoomMember{ + "@t2_a:reddit.com": {Membership: event.MembershipJoin}, + "@t2_b:reddit.com": {Membership: event.MembershipJoin}, + }} + if info := r.chatInfoFromState(state); info.UserBlocked != nil { + t.Fatal("unknown list must not clear saved portal state") + } + if _, err := r.refreshBlockedUsers(t.Context()); err != nil { + t.Fatal(err) + } + if info := r.chatInfoFromState(state); info.UserBlocked == nil || !*info.UserBlocked { + t.Fatal("native block missing from DM projection") + } + change := &blockListChange{client: r, userID: "t2_b"} + other := testClient(t, func(http.ResponseWriter, *http.Request) {}) + if other.chatInfoFromState(state).UserBlocked != nil { + t.Fatal("block state leaked across logins") + } + state.Type = "private_group" + if r.chatInfoFromState(state).UserBlocked != nil { + t.Fatal("group was marked blocked") + } + body = `{"data":{"identity":null}}` + if _, err := r.refreshBlockedUsers(t.Context()); err == nil || !*r.userBlocked("t2_b") { + t.Fatal("invalid native snapshot cleared block state") + } + body = `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[]}}}}` + if _, err := r.refreshBlockedUsers(t.Context()); err != nil || *r.userBlocked("t2_b") { + t.Fatal("native unblock did not clear cached state") + } + if info, err := change.GetChatInfoChange(t.Context()); err != nil || *info.ChatInfo.UserBlocked { + t.Fatal("block-list conversion used an outdated snapshot") + } +} + +func TestBlockUserNativeSupportLimits(t *testing.T) { + r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Error("unsupported action reached Reddit") }) + dm := &bridgev2.Portal{Portal: &database.Portal{RoomType: database.RoomTypeDM, OtherUserID: "t2_b"}} + group := &bridgev2.Portal{Portal: &database.Portal{RoomType: database.RoomTypeGroupDM}} + if r.GetCapabilities(t.Context(), dm).BlockUser { + t.Fatal("blocking advertised without cookie session") + } + r.session, _ = redditchat.NewRedditSession(nil, r.meta.Credentials.Homeserver, "") + r.session.CSRFToken = "test-csrf" + if !r.GetCapabilities(t.Context(), dm).BlockUser || r.GetCapabilities(t.Context(), group).BlockUser { + t.Fatal("blocking must be advertised for DMs only") + } + if err := r.HandleMatrixBlockUser(t.Context(), &bridgev2.MatrixBlockUser{ + Portal: dm, Content: &event.BeeperBlockUserEventContent{Block: true, ReportSpam: true}, + }); err == nil { + t.Fatal("unsupported spam report silently ignored") + } +} diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 43316e5..49d4117 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -69,9 +69,9 @@ var stateCaps = event.StateFeatureMap{ event.StateRoomName.Type: {Level: event.CapLevelFullySupported}, } -func (*RedditClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { +func (r *RedditClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { caps := (&event.RoomFeatures{ - ID: "com.beeper.reddit.capabilities.v10", + ID: "com.beeper.reddit.capabilities.v11", File: fileCaps, MaxTextLength: MaxTextLength, LocationMessage: event.CapLevelDropped, @@ -98,6 +98,10 @@ func (*RedditClient) GetCapabilities(ctx context.Context, portal *bridgev2.Porta }).Clone() if portal != nil && portal.RoomType == database.RoomTypeDM { caps.ID += ".dm" + if r.session != nil { + caps.ID += ".blocking" + caps.BlockUser = true + } caps.State[event.StateRoomName.Type].Level = event.CapLevelRejected caps.MemberActions[event.MemberActionInvite] = event.CapLevelRejected caps.MemberActions[event.MemberActionKick] = event.CapLevelRejected diff --git a/pkg/connector/chatsync.go b/pkg/connector/chatsync.go index 174bfb5..a116f32 100644 --- a/pkg/connector/chatsync.go +++ b/pkg/connector/chatsync.go @@ -35,11 +35,18 @@ func (r *RedditClient) runSync(ctx context.Context, done chan struct{}) { timeoutMS := r.main.Config.PollTimeoutMS() backoff := time.Duration(r.main.Config.ErrorBackoffSeconds()) * time.Second + var nextBlockSync time.Time for { if err := ctx.Err(); err != nil { return } + if r.session != nil && time.Now().After(nextBlockSync) { + if err := r.syncBlockedUsers(ctx); err != nil && ctx.Err() == nil { + log.Warn().Err(err).Msg("Failed to synchronize Reddit block list") + } + nextBlockSync = time.Now().Add(time.Minute) + } since := r.meta.NextBatch if r.meta.RoomStateVersion < 4 { since = "" @@ -317,7 +324,10 @@ func (r *RedditClient) joinedRoomEvent(state *RoomState, key networkid.PortalKey info := r.chatInfoFromState(state) return &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatResync, PortalKey: key, CreatePortal: true}, - ChatInfo: info, + GetChatInfoFunc: func(context.Context, *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + // Account block state may change while this room snapshot is queued. + return r.chatInfoFromState(state), nil + }, CheckNeedsBackfillFunc: func(_ context.Context, latest *database.Message) (bool, error) { return info.CanBackfill && (limited || latest == nil), nil }, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 1bbccef..b5c6b85 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -39,6 +39,13 @@ type RedditClient struct { roomMu sync.RWMutex rooms map[id.RoomID]*RoomState + // blockNetMu serializes block-list requests so a refresh snapshot cannot + // overwrite a newer mutation. blockMu guards only blockedUsers and is never + // held across I/O, since chat info reads it on the sync path. + blockNetMu sync.Mutex + blockMu sync.Mutex + blockedUsers map[networkid.UserID]bool + typingMu sync.Mutex typing map[networkid.PortalKey][]id.UserID } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9fffd41..8266e0d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -48,7 +48,7 @@ func (rc *RedditConnector) GetName() bridgev2.BridgeName { } func (rc *RedditConnector) GetBridgeInfoVersion() (info, capabilities int) { - return 3, 9 + return 3, 10 } func (rc *RedditConnector) SetMaxFileSize(size int64) { rc.mediaUploadLimit.Store(size) } diff --git a/pkg/connector/requests.go b/pkg/connector/requests.go index 983c552..a8ee775 100644 --- a/pkg/connector/requests.go +++ b/pkg/connector/requests.go @@ -143,7 +143,9 @@ func (r *RedditClient) pendingRequestEvent(state *RoomState, key networkid.Porta info := r.chatInfoFromState(state) return &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatResync, PortalKey: key, CreatePortal: true}, - ChatInfo: info, + GetChatInfoFunc: func(context.Context, *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + return r.chatInfoFromState(state), nil + }, CheckNeedsBackfillFunc: func(_ context.Context, latest *database.Message) (bool, error) { // Populate an existing empty request room too. Subsequent resyncs // must not repeatedly fetch the same preview; older history has its diff --git a/pkg/connector/roomstate.go b/pkg/connector/roomstate.go index 9a03d5a..20fa754 100644 --- a/pkg/connector/roomstate.go +++ b/pkg/connector/roomstate.go @@ -244,6 +244,9 @@ func (r *RedditClient) chatInfoFromState(state *RoomState) *bridgev2.ChatInfo { members.IsFull = members.TotalMemberCount == len(members.MemberMap) } info.Members = members + if state.Type == "direct" { + info.UserBlocked = r.userBlocked(members.OtherUserID) + } info.ExtraUpdates = func(ctx context.Context, portal *bridgev2.Portal) bool { meta, ok := portal.Metadata.(*PortalMetadata) if !ok || meta == nil { diff --git a/pkg/redditchat/blocking.go b/pkg/redditchat/blocking.go new file mode 100644 index 0000000..e13532a --- /dev/null +++ b/pkg/redditchat/blocking.go @@ -0,0 +1,120 @@ +package redditchat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" +) + +var redditorIDPattern = regexp.MustCompile(`^t2_[a-z0-9]+$`) + +// SetUserBlocked updates the account-wide Reddit block list, not room membership. +// The integrated chat UI uses the Reddit session cookie and CSRF token here, +// rather than the Matrix chat access token. +func (s *RedditSession) SetUserBlocked(ctx context.Context, redditorID string, blocked bool) error { + if !redditorIDPattern.MatchString(redditorID) { + return errors.New("reddit blocking: invalid redditor ID") + } + state := "NONE" + if blocked { + state = "BLOCKED" + } + var data struct { + UpdateRedditorBlockState struct { + OK bool `json:"ok"` + } `json:"updateRedditorBlockState"` + } + err := s.chatGraphQL(ctx, "ChatUpdateRedditorBlockState", map[string]any{ + "input": map[string]string{"redditorId": redditorID, "blockState": state}, + }, &data) + if err != nil { + return err + } + if !data.UpdateRedditorBlockState.OK { + return errors.New("reddit blocking: Reddit rejected the block list update") + } + return nil +} + +// GetBlockedUsers returns native t2_ IDs. An absent or malformed list is an +// error: treating it as empty would incorrectly clear existing Beeper blocks. +func (s *RedditSession) GetBlockedUsers(ctx context.Context) ([]string, error) { + var data struct { + Identity struct { + BlockedRedditorsInfo struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"blockedRedditorsInfo"` + } `json:"identity"` + } + if err := s.chatGraphQL(ctx, "ChatBlockedRedditors", map[string]any{}, &data); err != nil { + return nil, err + } + edges := data.Identity.BlockedRedditorsInfo.Edges + if edges == nil { + return nil, errors.New("reddit blocking: response did not include the blocked user list") + } + users := make([]string, 0, len(edges)) + for _, edge := range edges { + if !redditorIDPattern.MatchString(edge.Node.ID) { + return nil, errors.New("reddit blocking: invalid redditor ID in block list") + } + users = append(users, edge.Node.ID) + } + return users, nil +} + +func (s *RedditSession) chatGraphQL(ctx context.Context, operation string, variables any, out any) error { + if s == nil || s.csrfToken() == "" { + return ErrSessionUnavailable + } + body, err := json.Marshal(struct { + Operation string `json:"operation"` + Variables any `json:"variables"` + CSRFToken string `json:"csrf_token"` + }{operation, variables, s.csrfToken()}) + if err != nil { + return err + } + req, err := s.newRequest(ctx, http.MethodPost, "/svc/shreddit/graphql", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", s.BaseURL) + req.Header.Set("Referer", s.BaseURL+"/chat/") + resp, body, err := s.do(req) + if err != nil { + return fmt.Errorf("reddit %s: %w", operation, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("reddit %s: HTTP %d", operation, resp.StatusCode) + } + var result struct { + Data json.RawMessage `json:"data"` + Errors []json.RawMessage `json:"errors"` + } + if err = json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("reddit %s: invalid JSON response", operation) + } + // GraphQL failures can arrive with HTTP 200, and even with partial data. + // Do not expose arbitrary server error bodies in logs or Matrix notices. + if len(result.Errors) > 0 { + return fmt.Errorf("reddit %s: Reddit rejected the request", operation) + } + if len(result.Data) == 0 || bytes.Equal(result.Data, []byte("null")) { + return fmt.Errorf("reddit %s: response did not include data", operation) + } + if err = json.Unmarshal(result.Data, out); err != nil { + return fmt.Errorf("reddit %s: invalid response data", operation) + } + return nil +} diff --git a/pkg/redditchat/blocking_test.go b/pkg/redditchat/blocking_test.go new file mode 100644 index 0000000..60bbec1 --- /dev/null +++ b/pkg/redditchat/blocking_test.go @@ -0,0 +1,138 @@ +package redditchat + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +func blockingSession(t *testing.T, handler http.HandlerFunc) *RedditSession { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + session, err := NewRedditSession(srv.Client(), srv.URL, "test-agent") + if err != nil { + t.Fatal(err) + } + u, _ := url.Parse(srv.URL) + session.Client.Jar.SetCookies(u, []*http.Cookie{{Name: "csrf_token", Value: "test-csrf"}, {Name: "reddit_session", Value: "test-session"}}) + return session +} + +// Catches accidentally using Matrix auth/IDs or the legacy GraphQL shape. +func TestSetUserBlockedNativeWire(t *testing.T) { + for _, blocked := range []bool{true, false} { + t.Run(map[bool]string{true: "block", false: "unblock"}[blocked], func(t *testing.T) { + s := blockingSession(t, func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost || req.URL.Path != "/svc/shreddit/graphql" { + t.Errorf("wrong endpoint: %s %s", req.Method, req.URL.Path) + } + for header, want := range map[string]string{"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "test-agent", "Authorization": ""} { + if got := req.Header.Get(header); got != want { + t.Errorf("%s = %q, want %q", header, got, want) + } + } + if req.Header.Get("Referer") != "http://"+req.Host+"/chat/" || req.Header.Get("Origin") != "http://"+req.Host { + t.Error("missing Reddit origin/referer") + } + cookie, err := req.Cookie("reddit_session") + if err != nil || cookie.Value != "test-session" { + t.Error("missing session cookie") + } + var got map[string]any + if err = json.NewDecoder(req.Body).Decode(&got); err != nil { + t.Fatal(err) + } + state := "NONE" + if blocked { + state = "BLOCKED" + } + want := map[string]any{"operation": "ChatUpdateRedditorBlockState", "csrf_token": "test-csrf", "variables": map[string]any{"input": map[string]any{"redditorId": "t2_b", "blockState": state}}} + if !reflect.DeepEqual(got, want) { + t.Errorf("request = %#v, want %#v", got, want) + } + io.WriteString(w, `{"data":{"updateRedditorBlockState":{"ok":true}},"errors":[],"operation":"ChatUpdateRedditorBlockState"}`) + }) + if err := s.SetUserBlocked(t.Context(), "t2_b", blocked); err != nil { + t.Fatal(err) + } + }) + } +} + +// Catches HTTP-200 GraphQL failures being acknowledged as successful blocks. +func TestSetUserBlockedRejectsFailures(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + }{ + {"http", "private-server-body", 429}, + {"graphql", `{"data":{"updateRedditorBlockState":{"ok":true}},"errors":[{"message":"private-server-body"}]}`, 200}, + {"rejected", `{"data":{"updateRedditorBlockState":{"ok":false}}}`, 200}, + {"missing result", `{"data":{}}`, 200}, + {"null data", `{"data":null}`, 200}, + {"missing data", `{}`, 200}, + {"malformed", `private-server-body`, 200}, + {"wrong type", `{"data":{"updateRedditorBlockState":{"ok":"private-server-body"}}}`, 200}, + } { + t.Run(tc.name, func(t *testing.T) { + s := blockingSession(t, func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + }) + err := s.SetUserBlocked(t.Context(), "t2_b", true) + if err == nil || strings.Contains(err.Error(), "private-server-body") { + t.Fatalf("failure not safely propagated: %v", err) + } + }) + } + s := blockingSession(t, func(http.ResponseWriter, *http.Request) { t.Error("invalid input reached Reddit") }) + for _, user := range []string{"", "name", "@t2_b:reddit.com", "t2_b/path"} { + if err := s.SetUserBlocked(t.Context(), user, true); err == nil { + t.Errorf("invalid user accepted: %s", user) + } + } + var missing *RedditSession + if err := missing.SetUserBlocked(t.Context(), "t2_b", true); err != ErrSessionUnavailable { + t.Fatalf("missing session: %v", err) + } +} + +// Catches malformed snapshots being interpreted as an authoritative unblock-all. +func TestGetBlockedUsersNativeResponse(t *testing.T) { + for _, tc := range []struct { + name, body string + want []string + }{ + {"populated", `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[{"node":{"id":"t2_b","name":"counterpart"}},{"node":{"id":"t2_c"}}]}}},"errors":[]}`, []string{"t2_b", "t2_c"}}, + {"empty", `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[]}}}}`, []string{}}, + {"null list", `{"data":{"identity":{"blockedRedditorsInfo":null}}}`, nil}, + {"missing edges", `{"data":{"identity":{"blockedRedditorsInfo":{}}}}`, nil}, + {"null edges", `{"data":{"identity":{"blockedRedditorsInfo":{"edges":null}}}}`, nil}, + {"missing ID", `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[{"node":{}}]}}}}`, nil}, + {"invalid ID", `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[{"node":{"id":"username"}}]}}}}`, nil}, + {"partial error", `{"data":{"identity":{"blockedRedditorsInfo":{"edges":[]}}},"errors":[{}]}`, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + s := blockingSession(t, func(w http.ResponseWriter, req *http.Request) { + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["operation"] != "ChatBlockedRedditors" || !reflect.DeepEqual(body["variables"], map[string]any{}) { + t.Errorf("incorrect list request: %#v", body) + } + io.WriteString(w, tc.body) + }) + got, err := s.GetBlockedUsers(t.Context()) + if (err != nil) != (tc.want == nil) || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("list = %v, %v; want %v", got, err, tc.want) + } + }) + } +} diff --git a/pkg/redditchat/login.go b/pkg/redditchat/login.go index 34bea12..1dce9d2 100644 --- a/pkg/redditchat/login.go +++ b/pkg/redditchat/login.go @@ -385,7 +385,6 @@ func (s *RedditSession) csrfToken() string { } for _, cookie := range s.Client.Jar.Cookies(u) { if cookie.Name == "csrf_token" { - s.CSRFToken = cookie.Value return cookie.Value } }