all: add FCM push support

This commit is contained in:
Tulir Asokan 2025-01-11 20:09:26 +02:00
parent d4fc883736
commit 9e63da1b6b
22 changed files with 769 additions and 67 deletions

View file

@ -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) data, err := json.Marshal(evt)
if err != nil { if err != nil {
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err)) panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))

View file

@ -34,6 +34,7 @@ import (
type Config struct { type Config struct {
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Matrix MatrixConfig `yaml:"matrix"` Matrix MatrixConfig `yaml:"matrix"`
Push PushConfig `yaml:"push"`
Logging zeroconfig.Config `yaml:"logging"` Logging zeroconfig.Config `yaml:"logging"`
} }
@ -41,6 +42,10 @@ type MatrixConfig struct {
DisableHTTP2 bool `yaml:"disable_http2"` DisableHTTP2 bool `yaml:"disable_http2"`
} }
type PushConfig struct {
FCMGateway string `yaml:"fcm_gateway"`
}
type WebConfig struct { type WebConfig struct {
ListenAddress string `yaml:"listen_address"` ListenAddress string `yaml:"listen_address"`
Username string `yaml:"username"` Username string `yaml:"username"`
@ -121,6 +126,10 @@ func (gmx *Gomuks) LoadConfig() error {
gmx.Config.Web.EventBufferSize = 512 gmx.Config.Web.EventBufferSize = 512
changed = true changed = true
} }
if gmx.Config.Push.FCMGateway == "" {
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
changed = true
}
if len(gmx.Config.Web.OriginPatterns) == 0 { if len(gmx.Config.Web.OriginPatterns) == 0 {
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"} gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
changed = true changed = true

View file

@ -34,6 +34,7 @@ import (
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog" "go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli" "go.mau.fi/gomuks/pkg/hicli"
@ -171,7 +172,7 @@ func (gmx *Gomuks) StartClient() {
nil, nil,
gmx.Log.With().Str("component", "hicli").Logger(), gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"), []byte("meow"),
gmx.EventBuffer.HicliEventHandler, gmx.HandleEvent,
) )
gmx.Client.LogoutFunc = gmx.Logout gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client httpClient := gmx.Client.Client.Client
@ -197,6 +198,14 @@ func (gmx *Gomuks) StartClient() {
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started") 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() { func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() { gmx.stopOnce.Do(func() {
close(gmx.stopChan) close(gmx.stopChan)

252
pkg/gomuks/push.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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()
}
}

169
pkg/gomuks/pushmessage.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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,
}
}

View file

@ -190,10 +190,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
}), expiry }), expiry
} }
func (gmx *Gomuks) generateImageToken() string { func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
return gmx.signToken(tokenData{ return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username, Username: gmx.Config.Web.Username,
Expiry: jsontime.U(time.Now().Add(1 * time.Hour)), Expiry: jsontime.U(time.Now().Add(expiry)),
ImageOnly: true, ImageOnly: true,
}) })
} }
@ -206,16 +206,26 @@ func (gmx *Gomuks) signToken(td any) string {
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum) 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() token, expiry := gmx.generateToken()
http.SetCookie(w, &http.Cookie{ if !jsonOutput {
Name: "gomuks_auth", http.SetCookie(w, &http.Cookie{
Value: token, Name: "gomuks_auth",
Expires: expiry, Value: token,
HttpOnly: true, Expires: expiry,
Secure: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, 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) { 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) w.WriteHeader(http.StatusOK)
return return
} }
jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth") authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) { if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie") hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
gmx.writeTokenCookie(w) gmx.writeTokenCookie(w, false, jsonOutput)
w.WriteHeader(http.StatusOK)
} else if username, password, ok := r.BasicAuth(); !ok { } else if username, password, ok := r.BasicAuth(); !ok {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request") 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) w.WriteHeader(http.StatusUnauthorized)
} else { } else {
usernameHash := sha256.Sum256([]byte(username)) 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 passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
if usernameCorrect && passwordCorrect { if usernameCorrect && passwordCorrect {
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password") hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
gmx.writeTokenCookie(w) gmx.writeTokenCookie(w, true, jsonOutput)
w.WriteHeader(http.StatusCreated)
} else { } else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials") 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) w.WriteHeader(http.StatusUnauthorized)
} }
} }

View file

@ -148,7 +148,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
sendImageAuthToken := func() { sendImageAuthToken := func() {
err := writeCmd(ctx, conn, &hicli.JSONCommand{ err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "image_auth_token", Command: "image_auth_token",
Data: exerrors.Must(json.Marshal(gmx.generateImageToken())), Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
}) })
if err != nil { if err != nil {
log.Err(err).Msg("Failed to write image auth token message") log.Err(err).Msg("Failed to write image auth token message")

View file

@ -17,17 +17,18 @@ import (
type Database struct { type Database struct {
*dbutil.Database *dbutil.Database
Account *AccountQuery Account *AccountQuery
AccountData *AccountDataQuery AccountData *AccountDataQuery
Room *RoomQuery Room *RoomQuery
InvitedRoom *InvitedRoomQuery InvitedRoom *InvitedRoomQuery
Event *EventQuery Event *EventQuery
CurrentState *CurrentStateQuery CurrentState *CurrentStateQuery
Timeline *TimelineQuery Timeline *TimelineQuery
SessionRequest *SessionRequestQuery SessionRequest *SessionRequestQuery
Receipt *ReceiptQuery Receipt *ReceiptQuery
Media *MediaQuery Media *MediaQuery
SpaceEdge *SpaceEdgeQuery SpaceEdge *SpaceEdgeQuery
PushRegistration *PushRegistrationQuery
} }
func New(rawDB *dbutil.Database) *Database { func New(rawDB *dbutil.Database) *Database {
@ -36,17 +37,18 @@ func New(rawDB *dbutil.Database) *Database {
return &Database{ return &Database{
Database: rawDB, Database: rawDB,
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)}, InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: &EventQuery{QueryHelper: eventQH}, Event: &EventQuery{QueryHelper: eventQH},
CurrentState: &CurrentStateQuery{QueryHelper: eventQH}, CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
Timeline: &TimelineQuery{QueryHelper: eventQH}, Timeline: &TimelineQuery{QueryHelper: eventQH},
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)}, SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)}, 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 { func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{} return &SpaceEdge{}
} }
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
return &PushRegistration{}
}

View file

@ -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}
}

View file

@ -1,4 +1,4 @@
-- v0 -> v11 (compatible with v10+): Latest revision -- v0 -> v12 (compatible with v10+): Latest revision
CREATE TABLE account ( CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
@ -301,3 +301,13 @@ CREATE TABLE space_edge (
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid) CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT; ) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id); 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;

View file

@ -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;

View file

@ -15,19 +15,24 @@ import (
) )
type SyncRoom struct { type SyncRoom struct {
Meta *database.Room `json:"meta"` Meta *database.Room `json:"meta"`
Timeline []database.TimelineRowTuple `json:"timeline"` Timeline []database.TimelineRowTuple `json:"timeline"`
State map[event.Type]map[string]database.EventRowID `json:"state"` State map[event.Type]map[string]database.EventRowID `json:"state"`
AccountData map[event.Type]*database.AccountData `json:"account_data"` AccountData map[event.Type]*database.AccountData `json:"account_data"`
Events []*database.Event `json:"events"` Events []*database.Event `json:"events"`
Reset bool `json:"reset"` Reset bool `json:"reset"`
Notifications []SyncNotification `json:"notifications"` Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
DismissNotifications bool `json:"dismiss_notifications"`
Notifications []SyncNotification `json:"notifications"`
} }
type SyncNotification struct { type SyncNotification struct {
RowID database.EventRowID `json:"event_rowid"` RowID database.EventRowID `json:"event_rowid"`
Sound bool `json:"sound"` Sound bool `json:"sound"`
Highlight bool `json:"highlight"`
Event *database.Event `json:"-"`
Room *database.Room `json:"-"`
} }
type SyncComplete struct { type SyncComplete struct {
@ -41,6 +46,16 @@ type SyncComplete struct {
TopLevelSpaces []id.RoomID `json:"top_level_spaces"` 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 { func (c *SyncComplete) IsEmpty() bool {
return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.InvitedRooms) == 0 && len(c.AccountData) == 0 return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.InvitedRooms) == 0 && len(c.AccountData) == 0
} }

View file

@ -201,6 +201,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
} }
return cli.GetLoginFlows(ctx) 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: default:
return nil, fmt.Errorf("unknown command %q", req.Command) return nil, fmt.Errorf("unknown command %q", req.Command)
} }

View file

@ -724,8 +724,11 @@ func (h *HiClient) processStateAndTimeline(
if isUnread { if isUnread {
if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived { if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived {
newNotifications = append(newNotifications, SyncNotification{ newNotifications = append(newNotifications, SyncNotification{
RowID: dbEvt.RowID, RowID: dbEvt.RowID,
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
Event: dbEvt,
Room: room,
}) })
} }
newUnreadCounts.AddOne(dbEvt.UnreadType) newUnreadCounts.AddOne(dbEvt.UnreadType)
@ -924,6 +927,7 @@ func (h *HiClient) processStateAndTimeline(
} else { } else {
updatedRoom.UnreadCounts.Add(newUnreadCounts) updatedRoom.UnreadCounts.Add(newUnreadCounts)
} }
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) { if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
updatedRoom.PrevBatch = timeline.PrevBatch updatedRoom.PrevBatch = timeline.PrevBatch
} }
@ -944,14 +948,16 @@ func (h *HiClient) processStateAndTimeline(
receipt.RoomID = "" receipt.RoomID = ""
} }
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{ ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
Meta: room, Meta: room,
Timeline: timelineRowTuples, Timeline: timelineRowTuples,
AccountData: accountData, AccountData: accountData,
State: changedState, State: changedState,
Reset: timeline.Limited, Reset: timeline.Limited,
Events: allNewEvents, Events: allNewEvents,
Notifications: newNotifications, Receipts: receiptMap,
Receipts: receiptMap,
Notifications: newNotifications,
DismissNotifications: dismissNotifications,
} }
} }
return nil return nil

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-gomuks="true">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<link id="favicon" rel="icon" type="image/png" href="gomuks.png"/> <link id="favicon" rel="icon" type="image/png" href="gomuks.png"/>

View file

@ -22,6 +22,7 @@ import type {
ElementRecentEmoji, ElementRecentEmoji,
EventID, EventID,
EventType, EventType,
GomuksAndroidMessageToWeb,
ImagePackRooms, ImagePackRooms,
RPCEvent, RPCEvent,
RawDBEvent, RawDBEvent,
@ -71,6 +72,74 @@ export default class Client {
this.requestNotificationPermission() this.requestNotificationPermission()
} }
async #reallyStartAndroid(signal: AbortSignal) {
const androidListener = async (evt: CustomEventInit<string>) => {
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) => { requestNotificationPermission = (evt?: MouseEvent) => {
window.Notification?.requestPermission().then(permission => { window.Notification?.requestPermission().then(permission => {
console.log("Notification permission:", permission) console.log("Notification permission:", permission)
@ -86,7 +155,11 @@ export default class Client {
start(): () => void { start(): () => void {
const abort = new AbortController() const abort = new AbortController()
this.#reallyStart(abort.signal) if (window.gomuksAndroid) {
this.#reallyStartAndroid(abort.signal)
} else {
this.#reallyStart(abort.signal)
}
this.#gcInterval = setInterval(() => { this.#gcInterval = setInterval(() => {
console.log("Garbage collection completed:", this.store.doGarbageCollection()) console.log("Garbage collection completed:", this.store.doGarbageCollection())
}, window.gcSettings.interval) }, window.gcSettings.interval)

View file

@ -17,6 +17,7 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
import { CancellablePromise } from "../util/promise.ts" import { CancellablePromise } from "../util/promise.ts"
import type { import type {
ClientWellKnown, ClientWellKnown,
DBPushRegistration,
EventID, EventID,
EventRowID, EventRowID,
EventType, EventType,
@ -267,4 +268,8 @@ export default abstract class RPCClient {
requestOpenIDToken(): Promise<RespOpenIDToken> { requestOpenIDToken(): Promise<RespOpenIDToken> {
return this.request("request_openid_token", {}) return this.request("request_openid_token", {})
} }
registerPush(reg: DBPushRegistration): Promise<boolean> {
return this.request("register_push", reg)
}
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
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

View file

@ -284,3 +284,11 @@ export interface ProfileEncryptionInfo {
user_trusted: boolean user_trusted: boolean
errors: string[] errors: string[]
} }
export interface DBPushRegistration {
device_id: string
type: "fcm"
data: unknown
encryption: { key: string }
expiration?: number
}

View file

@ -1,3 +1,4 @@
export * from "./mxtypes.ts" export * from "./mxtypes.ts"
export * from "./hitypes.ts" export * from "./hitypes.ts"
export * from "./hievents.ts" export * from "./hievents.ts"
export * from "./android.ts"

View file

@ -394,10 +394,12 @@ const SettingsView = ({ room }: SettingsViewProps) => {
<AppliedSettingsView room={room} /> <AppliedSettingsView room={room} />
<div className="misc-buttons"> <div className="misc-buttons">
<button onClick={onClickOpenCSSApp}>Sign into css.gomuks.app</button> <button onClick={onClickOpenCSSApp}>Sign into css.gomuks.app</button>
{window.Notification && <button onClick={client.requestNotificationPermission}> {window.Notification && !window.gomuksAndroid && <button onClick={client.requestNotificationPermission}>
Request notification permission Request notification permission
</button>} </button>}
<button onClick={client.registerURIHandler}>Register <code>matrix:</code> URI handler</button> {!window.gomuksAndroid &&
<button onClick={client.registerURIHandler}>Register <code>matrix:</code> URI handler</button>
}
<button className="logout" onClick={onClickLogout}>Logout</button> <button className="logout" onClick={onClickLogout}>Logout</button>
</div> </div>
</> </>

View file

@ -16,5 +16,6 @@ declare global {
gcSettings: GCSettings gcSettings: GCSettings
hackyOpenEventContextMenu?: string hackyOpenEventContextMenu?: string
closeModal: () => void closeModal: () => void
gomuksAndroid?: true
} }
} }