From 9e63da1b6b2ff5ee2634f0a51715c0cac6db8073 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 11 Jan 2025 20:09:26 +0200 Subject: [PATCH] all: add FCM push support --- pkg/gomuks/buffer.go | 2 +- pkg/gomuks/config.go | 9 + pkg/gomuks/gomuks.go | 11 +- pkg/gomuks/push.go | 252 ++++++++++++++++++ pkg/gomuks/pushmessage.go | 169 ++++++++++++ pkg/gomuks/server.go | 48 ++-- pkg/gomuks/websocket.go | 2 +- pkg/hicli/database/database.go | 50 ++-- pkg/hicli/database/pushregistration.go | 78 ++++++ .../database/upgrades/00-latest-revision.sql | 12 +- .../upgrades/12-push-registrations.sql | 10 + pkg/hicli/events.go | 35 ++- pkg/hicli/json-commands.go | 4 + pkg/hicli/sync.go | 26 +- web/index.html | 2 +- web/src/api/client.ts | 75 +++++- web/src/api/rpc.ts | 5 + web/src/api/types/android.ts | 30 +++ web/src/api/types/hitypes.ts | 8 + web/src/api/types/index.ts | 1 + web/src/ui/settings/SettingsView.tsx | 6 +- web/src/vite-env.d.ts | 1 + 22 files changed, 769 insertions(+), 67 deletions(-) create mode 100644 pkg/gomuks/push.go create mode 100644 pkg/gomuks/pushmessage.go create mode 100644 pkg/hicli/database/pushregistration.go create mode 100644 pkg/hicli/database/upgrades/12-push-registrations.sql create mode 100644 web/src/api/types/android.ts diff --git a/pkg/gomuks/buffer.go b/pkg/gomuks/buffer.go index 5171fec..a6233b6 100644 --- a/pkg/gomuks/buffer.go +++ b/pkg/gomuks/buffer.go @@ -54,7 +54,7 @@ func NewEventBuffer(maxSize int) *EventBuffer { } } -func (eb *EventBuffer) HicliEventHandler(evt any) { +func (eb *EventBuffer) Push(evt any) { data, err := json.Marshal(evt) if err != nil { panic(fmt.Errorf("failed to marshal event %T: %w", evt, err)) diff --git a/pkg/gomuks/config.go b/pkg/gomuks/config.go index 7479cc6..3d51e00 100644 --- a/pkg/gomuks/config.go +++ b/pkg/gomuks/config.go @@ -34,6 +34,7 @@ import ( type Config struct { Web WebConfig `yaml:"web"` Matrix MatrixConfig `yaml:"matrix"` + Push PushConfig `yaml:"push"` Logging zeroconfig.Config `yaml:"logging"` } @@ -41,6 +42,10 @@ type MatrixConfig struct { DisableHTTP2 bool `yaml:"disable_http2"` } +type PushConfig struct { + FCMGateway string `yaml:"fcm_gateway"` +} + type WebConfig struct { ListenAddress string `yaml:"listen_address"` Username string `yaml:"username"` @@ -121,6 +126,10 @@ func (gmx *Gomuks) LoadConfig() error { gmx.Config.Web.EventBufferSize = 512 changed = true } + if gmx.Config.Push.FCMGateway == "" { + gmx.Config.Push.FCMGateway = "https://push.gomuks.app" + changed = true + } if len(gmx.Config.Web.OriginPatterns) == 0 { gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"} changed = true diff --git a/pkg/gomuks/gomuks.go b/pkg/gomuks/gomuks.go index 3758eb6..0b382d3 100644 --- a/pkg/gomuks/gomuks.go +++ b/pkg/gomuks/gomuks.go @@ -34,6 +34,7 @@ import ( "go.mau.fi/util/dbutil" "go.mau.fi/util/exerrors" "go.mau.fi/util/exzerolog" + "go.mau.fi/util/ptr" "golang.org/x/net/http2" "go.mau.fi/gomuks/pkg/hicli" @@ -171,7 +172,7 @@ func (gmx *Gomuks) StartClient() { nil, gmx.Log.With().Str("component", "hicli").Logger(), []byte("meow"), - gmx.EventBuffer.HicliEventHandler, + gmx.HandleEvent, ) gmx.Client.LogoutFunc = gmx.Logout httpClient := gmx.Client.Client.Client @@ -197,6 +198,14 @@ func (gmx *Gomuks) StartClient() { gmx.Log.Info().Stringer("user_id", userID).Msg("Client started") } +func (gmx *Gomuks) HandleEvent(evt any) { + gmx.EventBuffer.Push(evt) + syncComplete, ok := evt.(*hicli.SyncComplete) + if ok && ptr.Val(syncComplete.Since) != "" { + go gmx.SendPushNotifications(syncComplete) + } +} + func (gmx *Gomuks) Stop() { gmx.stopOnce.Do(func() { close(gmx.stopChan) diff --git a/pkg/gomuks/push.go b/pkg/gomuks/push.go new file mode 100644 index 0000000..6e3347e --- /dev/null +++ b/pkg/gomuks/push.go @@ -0,0 +1,252 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2025 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 gomuks + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "go.mau.fi/util/random" + "maunium.net/go/mautrix/id" + + "go.mau.fi/gomuks/pkg/hicli" + "go.mau.fi/gomuks/pkg/hicli/database" +) + +type PushNotification struct { + Dismiss []PushDismiss `json:"dismiss,omitempty"` + OrigMessages []*PushNewMessage `json:"-"` + RawMessages []json.RawMessage `json:"messages,omitempty"` + ImageAuth string `json:"image_auth,omitempty"` + ImageAuthExpiry *jsontime.UnixMilli `json:"image_auth_expiry,omitempty"` + HasImportant bool `json:"-"` +} + +type PushDismiss struct { + RoomID id.RoomID `json:"room_id"` +} + +var pushClient = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, + ResponseHeaderTimeout: 10 * time.Second, + Proxy: http.ProxyFromEnvironment, + ForceAttemptHTTP2: true, + MaxIdleConns: 5, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + Timeout: 60 * time.Second, +} + +func (gmx *Gomuks) SendPushNotifications(sync *hicli.SyncComplete) { + var ctx context.Context + var push PushNotification + for _, room := range sync.Rooms { + if room.DismissNotifications && len(push.Dismiss) < 10 { + push.Dismiss = append(push.Dismiss, PushDismiss{RoomID: room.Meta.ID}) + } + for _, notif := range room.Notifications { + if ctx == nil { + ctx = gmx.Log.With(). + Str("action", "send push notification"). + Logger().WithContext(context.Background()) + } + msg := gmx.formatPushNotificationMessage(ctx, notif) + if msg == nil { + continue + } + msgJSON, err := json.Marshal(msg) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Int64("event_rowid", int64(notif.RowID)). + Stringer("event_id", notif.Event.ID). + Msg("Failed to marshal push notification") + continue + } else if len(msgJSON) > 1500 { + // This should not happen as long as formatPushNotificationMessage doesn't return too long messages + zerolog.Ctx(ctx).Error(). + Int64("event_rowid", int64(notif.RowID)). + Stringer("event_id", notif.Event.ID). + Msg("Push notification too long") + continue + } + push.RawMessages = append(push.RawMessages, msgJSON) + push.OrigMessages = append(push.OrigMessages, msg) + } + } + if len(push.Dismiss) == 0 && len(push.RawMessages) == 0 { + return + } + if ctx == nil { + ctx = gmx.Log.With(). + Str("action", "send push notification"). + Logger().WithContext(context.Background()) + } + pushRegs, err := gmx.Client.DB.PushRegistration.GetAll(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get push registrations") + return + } + if len(push.RawMessages) > 0 { + exp := time.Now().Add(24 * time.Hour) + push.ImageAuth = gmx.generateImageToken(24 * time.Hour) + push.ImageAuthExpiry = ptr.Ptr(jsontime.UM(exp)) + } + for notif := range push.Split { + gmx.SendPushNotification(ctx, pushRegs, notif) + } +} + +func (pn *PushNotification) Split(yield func(*PushNotification) bool) { + const maxSize = 2000 + currentSize := 0 + offset := 0 + hasSound := false + for i, msg := range pn.RawMessages { + if len(msg) >= maxSize { + // This is already checked in SendPushNotifications, so this should never happen + panic("push notification message too long") + } + if currentSize+len(msg) > maxSize { + yield(&PushNotification{ + Dismiss: pn.Dismiss, + RawMessages: pn.RawMessages[offset:i], + ImageAuth: pn.ImageAuth, + HasImportant: hasSound, + }) + offset = i + currentSize = 0 + hasSound = false + } + currentSize += len(msg) + hasSound = hasSound || pn.OrigMessages[i].Sound + } + yield(&PushNotification{ + Dismiss: pn.Dismiss, + RawMessages: pn.RawMessages[offset:], + ImageAuth: pn.ImageAuth, + HasImportant: hasSound, + }) +} + +func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) { + rawPayload, err := json.Marshal(notif) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to marshal push notification") + return + } else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 { + zerolog.Ctx(ctx).Error().Msg("Generated push payload too long") + return + } + for _, reg := range pushRegs { + devicePayload := rawPayload + encrypted := false + if reg.Encryption.Key != nil { + var err error + devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload") + continue + } + encrypted = true + } + switch reg.Type { + case database.PushTypeFCM: + if !encrypted { + zerolog.Ctx(ctx).Warn(). + Str("device_id", reg.DeviceID). + Msg("FCM push registration doesn't have encryption key") + continue + } + var token string + err = json.Unmarshal(reg.Data, &token) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token") + continue + } + gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant) + } + } +} + +func encryptPush(payload, key []byte) ([]byte, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key must be 32 bytes long") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM cipher: %w", err) + } + iv := random.Bytes(12) + encrypted := make([]byte, 12, 12+len(payload)) + copy(encrypted, iv) + return gcm.Seal(encrypted, iv, payload, nil), nil +} + +type PushRequest struct { + Token string `json:"token"` + Payload []byte `json:"payload"` + HighPriority bool `json:"high_priority"` +} + +func (gmx *Gomuks) SendFCMPush(ctx context.Context, token string, payload []byte, highPriority bool) { + wrappedPayload, _ := json.Marshal(&PushRequest{ + Token: token, + Payload: payload, + HighPriority: highPriority, + }) + url := fmt.Sprintf("%s/_gomuks/push/fcm", gmx.Config.Push.FCMGateway) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wrappedPayload)) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create push request") + return + } + resp, err := pushClient.Do(req) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("push_token", token).Msg("Failed to send push request") + } else if resp.StatusCode != http.StatusOK { + zerolog.Ctx(ctx).Error(). + Int("status", resp.StatusCode). + Str("push_token", token). + Msg("Non-200 status while sending push request") + } else { + zerolog.Ctx(ctx).Trace(). + Int("status", resp.StatusCode). + Str("push_token", token). + Msg("Sent push request") + } + if resp != nil { + _ = resp.Body.Close() + } +} diff --git a/pkg/gomuks/pushmessage.go b/pkg/gomuks/pushmessage.go new file mode 100644 index 0000000..a4d3d1e --- /dev/null +++ b/pkg/gomuks/pushmessage.go @@ -0,0 +1,169 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2025 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 gomuks + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "unicode/utf8" + + "github.com/rs/zerolog" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "go.mau.fi/gomuks/pkg/hicli" + "go.mau.fi/gomuks/pkg/hicli/database" +) + +type PushNewMessage struct { + Timestamp jsontime.UnixMilli `json:"timestamp"` + EventID id.EventID `json:"event_id"` + EventRowID database.EventRowID `json:"event_rowid"` + + RoomID id.RoomID `json:"room_id"` + RoomName string `json:"room_name"` + RoomAvatar string `json:"room_avatar,omitempty"` + Sender NotificationUser `json:"sender"` + Self NotificationUser `json:"self"` + + Text string `json:"text"` + Image string `json:"image,omitempty"` + Mention bool `json:"mention,omitempty"` + Reply bool `json:"reply,omitempty"` + Sound bool `json:"sound,omitempty"` +} + +type NotificationUser struct { + ID id.UserID `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` +} + +func getAvatarLinkForNotification(name, ident string, uri id.ContentURIString) string { + parsed := uri.ParseOrIgnore() + if !parsed.IsValid() { + return "" + } + var fallbackChar rune + if name == "" { + fallbackChar, _ = utf8.DecodeRuneInString(ident[1:]) + } else { + fallbackChar, _ = utf8.DecodeRuneInString(name) + } + return fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false&fallback=%s", parsed.Homeserver, parsed.FileID, url.QueryEscape(string(fallbackChar))) +} + +func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, userID id.UserID) (user NotificationUser) { + user = NotificationUser{ID: userID, Name: userID.Localpart()} + memberEvt, err := gmx.Client.DB.CurrentState.Get(ctx, roomID, event.StateMember, userID.String()) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event") + return + } + var memberContent event.MemberEventContent + _ = json.Unmarshal(memberEvt.Content, &memberContent) + if memberContent.Displayname != "" { + user.Name = memberContent.Displayname + } + if len(user.Name) > 50 { + user.Name = user.Name[:50] + "…" + } + if memberContent.AvatarURL != "" { + user.Avatar = getAvatarLinkForNotification(memberContent.Displayname, userID.String(), memberContent.AvatarURL) + } + return +} + +func (gmx *Gomuks) formatPushNotificationMessage(ctx context.Context, notif hicli.SyncNotification) *PushNewMessage { + evtType := notif.Event.Type + rawContent := notif.Event.Content + if evtType == event.EventEncrypted.Type { + evtType = notif.Event.DecryptedType + rawContent = notif.Event.Decrypted + } + if evtType != event.EventMessage.Type && evtType != event.EventSticker.Type { + return nil + } + var content event.MessageEventContent + err := json.Unmarshal(rawContent, &content) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err). + Stringer("event_id", notif.Event.ID). + Msg("Failed to unmarshal message content to format push notification") + return nil + } + var roomAvatar, image string + if notif.Room.Avatar != nil { + avatarIdent := notif.Room.ID.String() + if ptr.Val(notif.Room.DMUserID) != "" { + avatarIdent = notif.Room.DMUserID.String() + } + roomAvatar = getAvatarLinkForNotification(ptr.Val(notif.Room.Name), avatarIdent, notif.Room.Avatar.CUString()) + } + roomName := ptr.Val(notif.Room.Name) + if roomName == "" { + roomName = "Unnamed room" + } + if len(roomName) > 50 { + roomName = roomName[:50] + "…" + } + text := content.Body + if len(text) > 400 { + text = text[:350] + "[…]" + } + if content.MsgType == event.MsgImage || evtType == event.EventSticker.Type { + if content.File != nil && content.File.URL != "" { + parsed := content.File.URL.ParseOrIgnore() + if len(content.File.URL) < 255 && parsed.IsValid() { + image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=true", parsed.Homeserver, parsed.FileID) + } + } else if content.URL != "" { + parsed := content.URL.ParseOrIgnore() + if len(content.URL) < 255 && parsed.IsValid() { + image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false", parsed.Homeserver, parsed.FileID) + } + } + if content.FileName == "" || content.FileName == content.Body { + text = "Sent a photo" + } + } else if content.MsgType.IsMedia() { + if content.FileName == "" || content.FileName == content.Body { + text = "Sent a file: " + text + } + } + return &PushNewMessage{ + Timestamp: notif.Event.Timestamp, + EventID: notif.Event.ID, + EventRowID: notif.Event.RowID, + + RoomID: notif.Room.ID, + RoomName: roomName, + RoomAvatar: roomAvatar, + Sender: gmx.getNotificationUser(ctx, notif.Room.ID, notif.Event.Sender), + Self: gmx.getNotificationUser(ctx, notif.Room.ID, gmx.Client.Account.UserID), + + Text: text, + Image: image, + Mention: content.Mentions.Has(gmx.Client.Account.UserID), + Reply: content.RelatesTo.GetNonFallbackReplyTo() != "", + Sound: notif.Sound, + } +} diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go index 7c2c8f2..73cc8f3 100644 --- a/pkg/gomuks/server.go +++ b/pkg/gomuks/server.go @@ -190,10 +190,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) { }), expiry } -func (gmx *Gomuks) generateImageToken() string { +func (gmx *Gomuks) generateImageToken(expiry time.Duration) string { return gmx.signToken(tokenData{ Username: gmx.Config.Web.Username, - Expiry: jsontime.U(time.Now().Add(1 * time.Hour)), + Expiry: jsontime.U(time.Now().Add(expiry)), ImageOnly: true, }) } @@ -206,16 +206,26 @@ func (gmx *Gomuks) signToken(td any) string { return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum) } -func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) { +func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) { token, expiry := gmx.generateToken() - http.SetCookie(w, &http.Cookie{ - Name: "gomuks_auth", - Value: token, - Expires: expiry, - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) + if !jsonOutput { + http.SetCookie(w, &http.Cookie{ + Name: "gomuks_auth", + Value: token, + Expires: expiry, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + } + if created { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusOK) + } + if jsonOutput { + _ = json.NewEncoder(w).Encode(map[string]string{"token": token}) + } } func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { @@ -223,14 +233,17 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) return } + jsonOutput := r.URL.Query().Get("output") == "json" + allowPrompt := r.URL.Query().Get("no_prompt") != "true" authCookie, err := r.Cookie("gomuks_auth") if err == nil && gmx.validateAuth(authCookie.Value, false) { hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie") - gmx.writeTokenCookie(w) - w.WriteHeader(http.StatusOK) + gmx.writeTokenCookie(w, false, jsonOutput) } else if username, password, ok := r.BasicAuth(); !ok { hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request") - w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + if allowPrompt { + w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + } w.WriteHeader(http.StatusUnauthorized) } else { usernameHash := sha256.Sum256([]byte(username)) @@ -239,11 +252,12 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil if usernameCorrect && passwordCorrect { hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password") - gmx.writeTokenCookie(w) - w.WriteHeader(http.StatusCreated) + gmx.writeTokenCookie(w, true, jsonOutput) } else { hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials") - w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + if allowPrompt { + w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + } w.WriteHeader(http.StatusUnauthorized) } } diff --git a/pkg/gomuks/websocket.go b/pkg/gomuks/websocket.go index b8c00f4..dda65d2 100644 --- a/pkg/gomuks/websocket.go +++ b/pkg/gomuks/websocket.go @@ -148,7 +148,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) { sendImageAuthToken := func() { err := writeCmd(ctx, conn, &hicli.JSONCommand{ Command: "image_auth_token", - Data: exerrors.Must(json.Marshal(gmx.generateImageToken())), + Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))), }) if err != nil { log.Err(err).Msg("Failed to write image auth token message") diff --git a/pkg/hicli/database/database.go b/pkg/hicli/database/database.go index ed1a1b4..a3b1840 100644 --- a/pkg/hicli/database/database.go +++ b/pkg/hicli/database/database.go @@ -17,17 +17,18 @@ import ( type Database struct { *dbutil.Database - Account *AccountQuery - AccountData *AccountDataQuery - Room *RoomQuery - InvitedRoom *InvitedRoomQuery - Event *EventQuery - CurrentState *CurrentStateQuery - Timeline *TimelineQuery - SessionRequest *SessionRequestQuery - Receipt *ReceiptQuery - Media *MediaQuery - SpaceEdge *SpaceEdgeQuery + Account *AccountQuery + AccountData *AccountDataQuery + Room *RoomQuery + InvitedRoom *InvitedRoomQuery + Event *EventQuery + CurrentState *CurrentStateQuery + Timeline *TimelineQuery + SessionRequest *SessionRequestQuery + Receipt *ReceiptQuery + Media *MediaQuery + SpaceEdge *SpaceEdgeQuery + PushRegistration *PushRegistrationQuery } func New(rawDB *dbutil.Database) *Database { @@ -36,17 +37,18 @@ func New(rawDB *dbutil.Database) *Database { return &Database{ Database: rawDB, - Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, - AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, - Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, - InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)}, - Event: &EventQuery{QueryHelper: eventQH}, - CurrentState: &CurrentStateQuery{QueryHelper: eventQH}, - Timeline: &TimelineQuery{QueryHelper: eventQH}, - SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)}, - Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, - Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, - SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)}, + Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, + AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, + Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, + InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)}, + Event: &EventQuery{QueryHelper: eventQH}, + CurrentState: &CurrentStateQuery{QueryHelper: eventQH}, + Timeline: &TimelineQuery{QueryHelper: eventQH}, + SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)}, + Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, + Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, + SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)}, + PushRegistration: &PushRegistrationQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newPushRegistration)}, } } @@ -85,3 +87,7 @@ func newAccount(_ *dbutil.QueryHelper[*Account]) *Account { func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge { return &SpaceEdge{} } + +func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration { + return &PushRegistration{} +} diff --git a/pkg/hicli/database/pushregistration.go b/pkg/hicli/database/pushregistration.go new file mode 100644 index 0000000..a89e7a1 --- /dev/null +++ b/pkg/hicli/database/pushregistration.go @@ -0,0 +1,78 @@ +// 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" + "encoding/json" + "time" + + "go.mau.fi/util/dbutil" + "go.mau.fi/util/jsontime" +) + +const ( + getNonExpiredPushTargets = ` + SELECT device_id, type, data, encryption, expiration + FROM push_registration + WHERE expiration > $1 + ` + putPushRegistration = ` + INSERT INTO push_registration (device_id, type, data, encryption, expiration) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (device_id) DO UPDATE SET + type = EXCLUDED.type, + data = EXCLUDED.data, + encryption = EXCLUDED.encryption, + expiration = EXCLUDED.expiration + ` +) + +type PushRegistrationQuery struct { + *dbutil.QueryHelper[*PushRegistration] +} + +func (prq *PushRegistrationQuery) Put(ctx context.Context, reg *PushRegistration) error { + return prq.Exec(ctx, putPushRegistration, reg.sqlVariables()...) +} + +func (seq *PushRegistrationQuery) GetAll(ctx context.Context) ([]*PushRegistration, error) { + return seq.QueryMany(ctx, getNonExpiredPushTargets, time.Now().Unix()) +} + +type PushType string + +const ( + PushTypeFCM PushType = "fcm" +) + +type EncryptionKey struct { + Key []byte `json:"key,omitempty"` +} + +type PushRegistration struct { + DeviceID string `json:"device_id"` + Type PushType `json:"type"` + Data json.RawMessage `json:"data"` + Encryption EncryptionKey `json:"encryption"` + Expiration jsontime.Unix `json:"expiration"` +} + +func (pe *PushRegistration) Scan(row dbutil.Scannable) (*PushRegistration, error) { + err := row.Scan(&pe.DeviceID, &pe.Type, (*[]byte)(&pe.Data), dbutil.JSON{Data: &pe.Encryption}, &pe.Expiration) + if err != nil { + return nil, err + } + return pe, nil +} + +func (pe *PushRegistration) sqlVariables() []any { + if pe.Expiration.IsZero() { + pe.Expiration = jsontime.U(time.Now().Add(7 * 24 * time.Hour)) + } + return []interface{}{pe.DeviceID, pe.Type, unsafeJSONString(pe.Data), dbutil.JSON{Data: &pe.Encryption}, pe.Expiration} +} diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql index 97f98d4..847afae 100644 --- a/pkg/hicli/database/upgrades/00-latest-revision.sql +++ b/pkg/hicli/database/upgrades/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v11 (compatible with v10+): Latest revision +-- v0 -> v12 (compatible with v10+): Latest revision CREATE TABLE account ( user_id TEXT NOT NULL PRIMARY KEY, device_id TEXT NOT NULL, @@ -301,3 +301,13 @@ CREATE TABLE space_edge ( CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid) ) STRICT; CREATE INDEX space_edge_child_idx ON space_edge (child_id); + +CREATE TABLE push_registration ( + device_id TEXT NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL, + encryption TEXT NOT NULL, + expiration INTEGER NOT NULL, + + PRIMARY KEY (device_id) +) STRICT; diff --git a/pkg/hicli/database/upgrades/12-push-registrations.sql b/pkg/hicli/database/upgrades/12-push-registrations.sql new file mode 100644 index 0000000..07c03eb --- /dev/null +++ b/pkg/hicli/database/upgrades/12-push-registrations.sql @@ -0,0 +1,10 @@ +-- v12 (compatible with v10+): Add table for push registrations +CREATE TABLE push_registration ( + device_id TEXT NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL, + encryption TEXT NOT NULL, + expiration INTEGER NOT NULL, + + PRIMARY KEY (device_id) +) STRICT; diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index e45a4e8..31cb9bb 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -15,19 +15,24 @@ import ( ) type SyncRoom struct { - Meta *database.Room `json:"meta"` - Timeline []database.TimelineRowTuple `json:"timeline"` - State map[event.Type]map[string]database.EventRowID `json:"state"` - AccountData map[event.Type]*database.AccountData `json:"account_data"` - Events []*database.Event `json:"events"` - Reset bool `json:"reset"` - Notifications []SyncNotification `json:"notifications"` - Receipts map[id.EventID][]*database.Receipt `json:"receipts"` + Meta *database.Room `json:"meta"` + Timeline []database.TimelineRowTuple `json:"timeline"` + State map[event.Type]map[string]database.EventRowID `json:"state"` + AccountData map[event.Type]*database.AccountData `json:"account_data"` + Events []*database.Event `json:"events"` + Reset bool `json:"reset"` + Receipts map[id.EventID][]*database.Receipt `json:"receipts"` + + DismissNotifications bool `json:"dismiss_notifications"` + Notifications []SyncNotification `json:"notifications"` } type SyncNotification struct { - RowID database.EventRowID `json:"event_rowid"` - Sound bool `json:"sound"` + RowID database.EventRowID `json:"event_rowid"` + Sound bool `json:"sound"` + Highlight bool `json:"highlight"` + Event *database.Event `json:"-"` + Room *database.Room `json:"-"` } type SyncComplete struct { @@ -41,6 +46,16 @@ type SyncComplete struct { TopLevelSpaces []id.RoomID `json:"top_level_spaces"` } +func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) { + for _, room := range c.Rooms { + for _, notif := range room.Notifications { + if !yield(notif) { + return + } + } + } +} + func (c *SyncComplete) IsEmpty() bool { return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.InvitedRooms) == 0 && len(c.AccountData) == 0 } diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 2ef0ab4..dca1ea7 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -201,6 +201,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any } return cli.GetLoginFlows(ctx) }) + case "register_push": + return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) { + return true, h.DB.PushRegistration.Put(ctx, params) + }) default: return nil, fmt.Errorf("unknown command %q", req.Command) } diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index 316da7e..ad515de 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -724,8 +724,11 @@ func (h *HiClient) processStateAndTimeline( if isUnread { if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived { newNotifications = append(newNotifications, SyncNotification{ - RowID: dbEvt.RowID, - Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), + RowID: dbEvt.RowID, + Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), + Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight), + Event: dbEvt, + Room: room, }) } newUnreadCounts.AddOne(dbEvt.UnreadType) @@ -924,6 +927,7 @@ func (h *HiClient) processStateAndTimeline( } else { updatedRoom.UnreadCounts.Add(newUnreadCounts) } + dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0 if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) { updatedRoom.PrevBatch = timeline.PrevBatch } @@ -944,14 +948,16 @@ func (h *HiClient) processStateAndTimeline( receipt.RoomID = "" } ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{ - Meta: room, - Timeline: timelineRowTuples, - AccountData: accountData, - State: changedState, - Reset: timeline.Limited, - Events: allNewEvents, - Notifications: newNotifications, - Receipts: receiptMap, + Meta: room, + Timeline: timelineRowTuples, + AccountData: accountData, + State: changedState, + Reset: timeline.Limited, + Events: allNewEvents, + Receipts: receiptMap, + + Notifications: newNotifications, + DismissNotifications: dismissNotifications, } } return nil diff --git a/web/index.html b/web/index.html index 7a59ab1..6be7b75 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 89e78a0..4f26ef4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -22,6 +22,7 @@ import type { ElementRecentEmoji, EventID, EventType, + GomuksAndroidMessageToWeb, ImagePackRooms, RPCEvent, RawDBEvent, @@ -71,6 +72,74 @@ export default class Client { this.requestNotificationPermission() } + async #reallyStartAndroid(signal: AbortSignal) { + const androidListener = async (evt: CustomEventInit) => { + const evtData = JSON.parse(evt.detail ?? "{}") as GomuksAndroidMessageToWeb + switch (evtData.type) { + case "register_push": + await this.rpc.registerPush({ + type: "fcm", + device_id: evtData.device_id, + data: evtData.token, + encryption: evtData.encryption, + expiration: evtData.expiration, + }) + return + case "auth": + try { + const resp = await fetch("_gomuks/auth?no_prompt=true", { + method: "POST", + headers: { + Authorization: evtData.authorization, + }, + signal, + }) + if (!resp.ok && !signal.aborted) { + console.error("Failed to authenticate:", resp.status, resp.statusText) + window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", { + detail: { + event: "auth_fail", + error: `${resp.statusText || resp.status}`, + }, + })) + return + } + } catch (err) { + console.error("Failed to authenticate:", err) + window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", { + detail: { + event: "auth_fail", + error: `${err}`.replace(/^Error: /, ""), + }, + })) + return + } + if (signal.aborted) { + return + } + console.log("Successfully authenticated, connecting to websocket") + this.rpc.start() + return + } + } + const unsubscribeConnect = this.rpc.connect.listen(evt => { + if (!evt.connected) { + return + } + window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", { + detail: { event: "connected" }, + })) + }) + window.addEventListener("GomuksAndroidMessageToWeb", androidListener) + signal.addEventListener("abort", () => { + unsubscribeConnect() + window.removeEventListener("GomuksAndroidMessageToWeb", androidListener) + }) + window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", { + detail: { event: "ready" }, + })) + } + requestNotificationPermission = (evt?: MouseEvent) => { window.Notification?.requestPermission().then(permission => { console.log("Notification permission:", permission) @@ -86,7 +155,11 @@ export default class Client { start(): () => void { const abort = new AbortController() - this.#reallyStart(abort.signal) + if (window.gomuksAndroid) { + this.#reallyStartAndroid(abort.signal) + } else { + this.#reallyStart(abort.signal) + } this.#gcInterval = setInterval(() => { console.log("Garbage collection completed:", this.store.doGarbageCollection()) }, window.gcSettings.interval) diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 083ca75..95fb019 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -17,6 +17,7 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher. import { CancellablePromise } from "../util/promise.ts" import type { ClientWellKnown, + DBPushRegistration, EventID, EventRowID, EventType, @@ -267,4 +268,8 @@ export default abstract class RPCClient { requestOpenIDToken(): Promise { return this.request("request_openid_token", {}) } + + registerPush(reg: DBPushRegistration): Promise { + return this.request("register_push", reg) + } } diff --git a/web/src/api/types/android.ts b/web/src/api/types/android.ts new file mode 100644 index 0000000..8f62f84 --- /dev/null +++ b/web/src/api/types/android.ts @@ -0,0 +1,30 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 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 . + +export interface AndroidRegisterPushEvent { + type: "register_push" + device_id: string + token: string + encryption: { key: string } + expiration?: number +} + +export interface AndroidAuthEvent { + type: "auth" + authorization: `Bearer ${string}` +} + +export type GomuksAndroidMessageToWeb = AndroidRegisterPushEvent | AndroidAuthEvent diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 637f38a..c7489cd 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -284,3 +284,11 @@ export interface ProfileEncryptionInfo { user_trusted: boolean errors: string[] } + +export interface DBPushRegistration { + device_id: string + type: "fcm" + data: unknown + encryption: { key: string } + expiration?: number +} diff --git a/web/src/api/types/index.ts b/web/src/api/types/index.ts index 88930fa..693c67a 100644 --- a/web/src/api/types/index.ts +++ b/web/src/api/types/index.ts @@ -1,3 +1,4 @@ export * from "./mxtypes.ts" export * from "./hitypes.ts" export * from "./hievents.ts" +export * from "./android.ts" diff --git a/web/src/ui/settings/SettingsView.tsx b/web/src/ui/settings/SettingsView.tsx index daf5c44..67ec3c7 100644 --- a/web/src/ui/settings/SettingsView.tsx +++ b/web/src/ui/settings/SettingsView.tsx @@ -394,10 +394,12 @@ const SettingsView = ({ room }: SettingsViewProps) => {
- {window.Notification && } - + {!window.gomuksAndroid && + + }
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 9cef2d8..ae9d0a8 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -16,5 +16,6 @@ declare global { gcSettings: GCSettings hackyOpenEventContextMenu?: string closeModal: () => void + gomuksAndroid?: true } }