mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
all: add FCM push support
This commit is contained in:
parent
d4fc883736
commit
9e63da1b6b
22 changed files with 769 additions and 67 deletions
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
252
pkg/gomuks/push.go
Normal 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
169
pkg/gomuks/pushmessage.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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{}
|
||||||
|
}
|
||||||
|
|
78
pkg/hicli/database/pushregistration.go
Normal file
78
pkg/hicli/database/pushregistration.go
Normal 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}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
10
pkg/hicli/database/upgrades/12-push-registrations.sql
Normal file
10
pkg/hicli/database/upgrades/12-push-registrations.sql
Normal 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;
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
30
web/src/api/types/android.ts
Normal file
30
web/src/api/types/android.ts
Normal 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
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
|
@ -16,5 +16,6 @@ declare global {
|
||||||
gcSettings: GCSettings
|
gcSettings: GCSettings
|
||||||
hackyOpenEventContextMenu?: string
|
hackyOpenEventContextMenu?: string
|
||||||
closeModal: () => void
|
closeModal: () => void
|
||||||
|
gomuksAndroid?: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue