Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ 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.

FCM push registration is implemented for wrappers that provide a Reddit-project
token. Reddit's Android registration has no chat-only filter: unrelated Reddit
notifications may also wake the wrapper. Account notification settings are not
changed. Real FCM delivery and token health acknowledgements remain unverified;
see the [protocol findings](pkg/redditchat/README.md#push-registration).

## Setup

Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager):
Expand Down
14 changes: 9 additions & 5 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ type RedditClient struct {
userLogin *bridgev2.UserLogin
userID networkid.UserID

rc *redditchat.Client
clientMu sync.RWMutex
session *redditchat.RedditSession
meta *UserLoginMetadata
rc *redditchat.Client
clientMu sync.RWMutex
session *redditchat.RedditSession
meta *UserLoginMetadata
refreshMu sync.Mutex
pushMu sync.Mutex

syncMu sync.Mutex
syncCancel context.CancelFunc
Expand Down Expand Up @@ -165,8 +167,10 @@ func (r *RedditClient) saveMeta(ctx context.Context) error {
var errRefreshAccountMismatch = errors.New("reddit session belongs to a different account; sign in again with the original account")

// refreshChatToken re-mints the Reddit Matrix access token using the saved
// Reddit session cookies. Called on 401 from the Matrix homeserver.
// Reddit session cookies when chat or push registration needs fresh credentials.
func (r *RedditClient) refreshChatToken(ctx context.Context) error {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
if r.session == nil {
return redditchat.ErrSessionUnavailable
}
Expand Down
1 change: 1 addition & 0 deletions pkg/connector/dbmeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type UserLoginMetadata struct {
Credentials redditchat.Credentials `json:"credentials"`
CookiesJSON string `json:"cookies_json,omitempty"`
ChatTokenExpiry int64 `json:"chat_token_expiry,omitempty"`
PushDeviceID string `json:"push_device_id,omitempty"`
NextBatch string `json:"next_batch,omitempty"`
Username string `json:"username,omitempty"`
RoomStateVersion int `json:"room_state_version,omitempty"`
Expand Down
65 changes: 65 additions & 0 deletions pkg/connector/push.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package connector

import (
"context"
"errors"
"fmt"
"strings"

"github.com/google/uuid"
"maunium.net/go/mautrix/bridgev2"

"github.com/beeper/reddit/pkg/redditchat"
)

var _ bridgev2.PushableNetworkAPI = (*RedditClient)(nil)

var pushConfig = &bridgev2.PushConfig{
FCM: &bridgev2.FCMPushConfig{SenderID: redditchat.FCMSenderID},
}

func (r *RedditClient) GetPushConfigs() *bridgev2.PushConfig {
return pushConfig
}

func (r *RedditClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error {
if pushType != bridgev2.PushTypeFCM {
return fmt.Errorf("unsupported push type: %s", pushType)
}
if !r.IsLoggedIn() {
return bridgev2.ErrNotLoggedIn
}
if strings.TrimSpace(token) == "" {
return errors.New("reddit push: FCM token is required")
}
if err := ctx.Err(); err != nil {
return err
}
r.pushMu.Lock()
defer r.pushMu.Unlock()
deviceID, err := r.pushDeviceID(ctx)
if err != nil {
return err
}
err = r.remote().RegisterFCMPush(ctx, token, deviceID)
if errors.Is(err, redditchat.ErrPushAuthentication) && r.session != nil {
if refreshErr := r.refreshChatToken(ctx); refreshErr != nil {
return fmt.Errorf("refresh Reddit credentials for push registration: %w", refreshErr)
}
err = r.remote().RegisterFCMPush(ctx, token, deviceID)
}
return err
}

func (r *RedditClient) pushDeviceID(ctx context.Context) (string, error) {
r.saveMu.Lock()
defer r.saveMu.Unlock()
if r.meta.PushDeviceID == "" {
r.meta.PushDeviceID = uuid.NewString()
if err := r.userLogin.Save(ctx); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Bug · Push registration saves user-login metadata concurrently with the sync loop's unlocked map writes

userLogin.Save marshals the whole UserLoginMetadata, including the HiddenRooms map, but saveMu only serializes saves against each other. The sync loop in chatsync.go mutates NextBatch, RoomStateVersion and HiddenRooms without holding it. RegisterPushNotifications runs on a separate goroutine, so a first registration, or any registration that refreshes credentials via saveMeta, can overlap a sync write. That can hit Go's fatal concurrent map iteration and map write and crash the bridge. It can also persist a sync cursor that the sync loop later rolls back in memory. Guard all r.meta access with one mutex, or clone the metadata under the lock before marshaling.

Ref: pkg/connector/chatsync.go lines ~103 and ~200-208.

Found by Indent Review Agent

r.meta.PushDeviceID = ""
return "", fmt.Errorf("save Reddit push device ID: %w", err)
}
}
return r.meta.PushDeviceID, nil
}
191 changes: 191 additions & 0 deletions pkg/connector/push_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package connector

import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"path/filepath"
"strings"
"testing"

"github.com/google/uuid"
"go.mau.fi/util/dbutil"
_ "go.mau.fi/util/dbutil/litestream"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"

"github.com/beeper/reddit/pkg/redditchat"
)

type pushTransport func(*http.Request) (*http.Response, error)

func (f pushTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }

func TestPushRegistrationFromWrapper(t *testing.T) {
r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected Matrix request") })
r.meta.PushDeviceID = "persistent-bridge-device"
calls := 0
r.matrix().Client.Transport = pushTransport(func(req *http.Request) (*http.Response, error) {
calls++
var body struct {
Variables struct {
PushToken string `json:"pushToken"`
DeviceID string `json:"deviceId"`
} `json:"variables"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatal(err)
}
wantToken := "first-token"
if calls == 2 {
wantToken = "rotated-token"
}
if body.Variables.PushToken != wantToken || body.Variables.DeviceID != "persistent-bridge-device" {
t.Fatal("wrong token or device changed on token rotation")
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":{"registerMobilePushToken":{"ok":true}}}`))}, nil
})
var pushable bridgev2.PushableNetworkAPI = r
cfg := pushable.GetPushConfigs()
if cfg.FCM == nil || cfg.FCM.SenderID != "933360113277" || cfg.Web != nil || cfg.APNs != nil || cfg.Native {
t.Fatalf("wrong relay configuration: %+v", cfg)
}
for _, token := range []string{"first-token", "rotated-token"} {
if err := pushable.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, token); err != nil {
t.Fatal(err)
}
}
if calls != 2 {
t.Fatalf("got %d registrations", calls)
}
}

func TestPushRegistrationRejectsUnsupportedRequests(t *testing.T) {
r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected request") })
for _, pushType := range []bridgev2.PushType{bridgev2.PushTypeUnknown, bridgev2.PushTypeWeb, bridgev2.PushTypeAPNs} {
if err := r.RegisterPushNotifications(t.Context(), pushType, "token"); err == nil {
t.Fatalf("accepted unsupported type: %s", pushType)
}
}
if err := r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, " "); err == nil {
t.Fatal("accepted blank token")
}
r.rc = nil
if err := r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "token"); !errors.Is(err, bridgev2.ErrNotLoggedIn) {
t.Fatalf("wrong missing-login error: %v", err)
}
}

func TestPushRegistrationWithoutRenewableSession(t *testing.T) {
r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected Matrix request") })
r.meta.PushDeviceID = "device"
calls := 0
r.matrix().Client.Transport = pushTransport(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{}`))}, nil
})
err := r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "token")
if !errors.Is(err, redditchat.ErrPushAuthentication) || calls != 1 {
t.Fatalf("wrong auth failure: %v (%d calls)", err, calls)
}
}

func pushTestDatabase(t *testing.T, r *RedditClient) {
t.Helper()
db, err := dbutil.NewWithDialect(filepath.Join(t.TempDir(), "push.db"), "sqlite3")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
r.main.Bridge.DB = database.New("", r.main.GetDBMetaTypes(), db)
if err = db.Upgrade(t.Context()); err != nil {
t.Fatal(err)
}
r.userLogin.Bridge = r.main.Bridge
r.userLogin.Metadata = r.meta
r.userLogin.UserMXID = "@test:example.com"
if err = r.main.Bridge.DB.User.Insert(t.Context(), &database.User{MXID: r.userLogin.UserMXID}); err != nil {
t.Fatal(err)
}
if err = r.main.Bridge.DB.UserLogin.Insert(t.Context(), r.userLogin.UserLogin); err != nil {
t.Fatal(err)
}
}

func TestPushDeviceIDPersistenceAndSaveFailure(t *testing.T) {
r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected request") })
pushTestDatabase(t, r)
deviceID, err := r.pushDeviceID(t.Context())
if err != nil {
t.Fatal(err)
}
if _, err = uuid.Parse(deviceID); err != nil {
t.Fatal(err)
}
stored, err := r.main.Bridge.DB.UserLogin.GetByID(t.Context(), r.userLogin.ID)
if err != nil {
t.Fatal(err)
}
if stored.Metadata.(*UserLoginMetadata).PushDeviceID != deviceID {
t.Fatal("push device ID was not persisted")
}
if err = r.main.Bridge.DB.Close(); err != nil {
t.Fatal(err)
}
if reused, err := r.pushDeviceID(t.Context()); err != nil || reused != deviceID {
t.Fatalf("existing device ID was not reused: %v", err)
}
r.meta.PushDeviceID = ""
err = r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "token")
if err == nil || r.meta.PushDeviceID != "" {
t.Fatal("failed save retained an unpersisted device ID")
}
}

func TestPushRegistrationRefreshesExpiredCredentials(t *testing.T) {
r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected request") })
pushTestDatabase(t, r)
freshToken := "header." + base64.RawURLEncoding.EncodeToString([]byte(`{"lid":"t2_a"}`)) + ".signature"
registrationCalls, refreshCalls := 0, 0
transport := pushTransport(func(req *http.Request) (*http.Response, error) {
status, body := http.StatusOK, ""
switch req.URL.Host + req.URL.Path {
case "gql-fed.reddit.com/":
registrationCalls++
if registrationCalls == 1 {
status, body = http.StatusUnauthorized, `{}`
} else {
if req.Header.Get("Authorization") != "Bearer "+freshToken {
t.Fatal("registration retry used expired credentials")
}
body = `{"data":{"registerMobilePushToken":{"ok":true}}}`
}
case "www.reddit.com/svc/shreddit/token":
refreshCalls++
body = `{"token":"` + freshToken + `","expires":1900000000}`
case "matrix.redditspace.com/_matrix/client/v3/account/whoami":
body = `{"device_id":"matrix-device"}`
default:
t.Fatalf("unexpected request: %s", req.URL)
}
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
previousTransport := http.DefaultTransport
http.DefaultTransport = transport
t.Cleanup(func() { http.DefaultTransport = previousTransport })
r.matrix().Client.Transport = transport
var err error
r.session, err = redditchat.NewRedditSession(&http.Client{Transport: transport}, "", "")
if err != nil {
t.Fatal(err)
}
r.session.CSRFToken = "csrf"
if err = r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "push-token"); err != nil {
t.Fatal(err)
}
if registrationCalls != 2 || refreshCalls != 1 || r.meta.Credentials.AccessToken != freshToken {
t.Fatalf("incorrect refresh: registrations=%d refreshes=%d", registrationCalls, refreshCalls)
}
}
73 changes: 73 additions & 0 deletions pkg/redditchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,76 @@ handled here and by the connector, without overriding bridgev2 delivery.

Local protocol tests use synthetic responses and do not establish end-to-end
acceptance.

## Push registration

Source: Reddit Android **2026.41.0**, build **2641021** (`com.reddit.frontpage`),
from the APKMirror bundle inspected on 2026-10-15.

The Android app registers one FCM token with Reddit's general notification
service. It does not register a Matrix pusher for chat. `RegisterFCMPush` uses
the same persisted GraphQL operation:

- FCM sender ID: `933360113277` (`res/values/strings.xml`,
`gcm_defaultSenderId`). Firebase project: `reddit-android`.
- Endpoint: `POST https://gql-fed.reddit.com/` with JSON and
`Authorization: Bearer <Reddit access token>`.
- Operation: `RegisterMobilePushToken`.
- Persisted query version: `1`; SHA-256:
`35313b81bfbb621c426b2f2ec276db3db476a5d8362a1ddbeec982a01dc74977`.
- Variables: `authTokens` (array of Reddit access tokens), `pushToken` (FCM
registration token), `deviceId` (installation ID), `timezoneName` (IANA zone),
`timestamp` (UTC ISO-8601 instant), and `language`.
- Success requires `data.registerMobilePushToken.ok == true` and no GraphQL
errors. HTTP 200 alone is insufficient.

The operation is in `defpackage/fr90.java`; the request envelope is in
`defpackage/vf10.java`. The call chain is `PushUtil.fetchFreshAccessTokens` ->
`RedditPushTokenRepository.registerPushToken` ->
`RemoteGqlPushTokenDataSource.registerPushToken`. The app supplies access tokens
for its logged-in accounts; the bridge supplies only the current login's token.
The connector persists a separate `push_device_id`, reuses it when the FCM token
rotates, and retries once after refreshing expired Reddit credentials. The FCM
token is supplied by the wrapper/relay, not generated by the connector.

### Chat-only scope

There is no notification-category input in this registration operation. The
app's `UpdateNotificationPreferences` input contains account preferences, with
no device ID or push token. The bridge does not modify those settings, since
that would affect the user's Reddit app too. No separate chat-only registration
was found in this APK.

A receiver/relay can filter the FCM data field `type`, case-insensitively, for
`CHAT_MESSAGE`, `CHAT_REQUEST`, and `CHAT_ACCEPT_INVITE`. The payload also includes
`account_id` and can include `chat_deeplink_uri`; chat event details can appear
inside the JSON string `extra_event_params`. Sources:
`notification/impl/controller/c.java`, `defpackage/{tw60,uw60,sw60}.java`, and
`PushNotificationExtrasMapper`. Filtering in the receiver prevents unrelated
wakeups; it does not prevent Reddit from sending those pushes to FCM. The current
bridgev2 FCM registration interface does not expose a relay filter, so this
connector advertises general Reddit FCM pushes, with `Native` left false.

The receiver must also consider `PUSH_TOKEN_HEALTH_CHECK`: Android handles this
silently and submits its `encrypted_push_token` to the GraphQL mutation
`SetNotificationsPushTokenActive(input: {token: ...})`. Its persisted hash is
`e6358f27c52cdedf2dd023b24b2f6621473067a5ca4a181dfbc169b02d3793d9`.
Sources: `defpackage/d27.java`, `SendMailroomPingWorker`, and
`defpackage/iaf0.java`. This acknowledgement is not wired into the bridge's
registration-only callback. Long-term token health and deregistration on logout
still need verification with the receiving relay.

### Verification boundary

An existing test web session successfully refreshed a chat bearer token. Using
that token, the mobile endpoint recognized the persisted operation and returned
the expected validation errors for all six deliberately omitted variables
(HTTP 400). Without authentication the same request returned HTTP 401. These
checks did not register a token or change account preferences.

Local tests cover the registration wire, response validation, authentication
failures, credential redaction, redirects, cancellation, and wrapper integration.
A real Reddit-project FCM token is still required to verify successful token
binding, push delivery, health checks, and background wakeup end to end. In
particular, endpoint authentication does not establish that the registration
resolver accepts a web-minted token for mobile delivery.
Loading
Loading