mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
Merge branch 'main' of https://github.com/tulir/gomuks into nexy7574/mod
This commit is contained in:
commit
dcd2cec667
24 changed files with 779 additions and 69 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)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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{}
|
||||
}
|
||||
|
|
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 (
|
||||
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;
|
||||
|
|
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 {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-gomuks="true">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link id="favicon" rel="icon" type="image/png" href="gomuks.png"/>
|
||||
|
|
|
@ -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<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) => {
|
||||
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)
|
||||
|
|
|
@ -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<RespOpenIDToken> {
|
||||
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
|
||||
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 "./hitypes.ts"
|
||||
export * from "./hievents.ts"
|
||||
export * from "./android.ts"
|
||||
|
|
|
@ -394,10 +394,12 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
<AppliedSettingsView room={room} />
|
||||
<div className="misc-buttons">
|
||||
<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
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -118,5 +118,13 @@ export function getPerMessageProfile(evt: MemDBEvent | null): BeeperPerMessagePr
|
|||
if (evt === null || evt.type !== "m.room.message" && evt.type !== "m.sticker") {
|
||||
return undefined
|
||||
}
|
||||
return (evt.content as MessageEventContent)["com.beeper.per_message_profile"]
|
||||
const profile = (evt.content as MessageEventContent)["com.beeper.per_message_profile"]
|
||||
if (profile?.displayname && typeof profile.displayname !== "string") {
|
||||
return undefined
|
||||
} else if (profile?.avatar_url && typeof profile.avatar_url !== "string") {
|
||||
return undefined
|
||||
} else if (profile?.id && typeof profile.id !== "string") {
|
||||
return undefined
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@ export function getServerName(userID: UserID): string {
|
|||
}
|
||||
|
||||
export function getDisplayname(userID: UserID, profile?: UserProfile | null): string {
|
||||
return profile?.displayname || getLocalpart(userID)
|
||||
return ensureString(profile?.displayname) || getLocalpart(userID)
|
||||
}
|
||||
|
||||
export function parseMXC(mxc: unknown): [string, string] | [] {
|
||||
|
|
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
|
@ -16,5 +16,6 @@ declare global {
|
|||
gcSettings: GCSettings
|
||||
hackyOpenEventContextMenu?: string
|
||||
closeModal: () => void
|
||||
gomuksAndroid?: true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue