web: init

This commit is contained in:
Tulir Asokan 2024-10-06 21:45:46 +03:00
parent 4767def4b5
commit 1a359f9793
40 changed files with 5091 additions and 0 deletions

View file

@ -7,6 +7,7 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 120
[.gitlab-ci.yml] [.gitlab-ci.yml]
indent_size = 2 indent_size = 2

4
.gitignore vendored
View file

@ -2,9 +2,13 @@
target/ target/
.tmp/ .tmp/
gomuks gomuks
start
run
*.exe *.exe
*.deb *.deb
coverage.out coverage.out
coverage.html coverage.html
deb/usr deb/usr
*.prof *.prof
*.db*
*.log

28
go.mod
View file

@ -3,3 +3,31 @@ module go.mau.fi/gomuks
go 1.23.0 go 1.23.0
toolchain go1.23.2 toolchain go1.23.2
require (
github.com/coder/websocket v1.8.12
github.com/mattn/go-sqlite3 v1.14.23
github.com/rs/zerolog v1.33.0
go.mau.fi/util v0.8.1-0.20241003092848-3b49d3e0b9ee
go.mau.fi/zeroconfig v0.1.3
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.21.1-0.20241006181705-64692eb06e11
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)

62
go.sum
View file

@ -0,0 +1,62 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.mau.fi/util v0.8.1-0.20241003092848-3b49d3e0b9ee h1:/BGpUK7fzVyFgy5KBiyP7ktEDn20vzz/5FTngrXtIEE=
go.mau.fi/util v0.8.1-0.20241003092848-3b49d3e0b9ee/go.mod h1:L9qnqEkhe4KpuYmILrdttKTXL79MwGLyJ4EOskWxO3I=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.21.1-0.20241006181705-64692eb06e11 h1:XhBqRfWg75OCsXxmb4uFJtHs6feq4MG9xaBWZKcMhFg=
maunium.net/go/mautrix v0.21.1-0.20241006181705-64692eb06e11/go.mod h1:+fF5qsmXRCEXQZgW5ececC0PI3c7gISHTLcyftP4Bh0=

272
gomuks.go Normal file
View file

@ -0,0 +1,272 @@
// 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/>.
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"maps"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"slices"
"sync"
"syscall"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/util/requestlog"
"go.mau.fi/zeroconfig"
"maunium.net/go/mautrix/hicli"
"go.mau.fi/gomuks/web"
)
type Gomuks struct {
Log *zerolog.Logger
Server *http.Server
Client *hicli.HiClient
ConfigDir string
DataDir string
CacheDir string
LogDir string
stopOnce sync.Once
stopChan chan struct{}
websocketClosers map[uint64]WebsocketCloseFunc
eventListeners map[uint64]func(*hicli.JSONCommand)
nextListenerID uint64
eventListenersLock sync.RWMutex
}
func NewGomuks() *Gomuks {
return &Gomuks{
stopChan: make(chan struct{}),
eventListeners: make(map[uint64]func(*hicli.JSONCommand)),
websocketClosers: make(map[uint64]WebsocketCloseFunc),
}
}
func (gmx *Gomuks) LoadConfig() {
// We need 4 directories: config, data, cache, logs
//
// 1. If GOMUKS_ROOT is set, all directories are created under that.
// 2. If GOMUKS_*_HOME is set, that value is used as the directory.
// 3. Use system-specific defaults as below
//
// *nix:
// - Config: $XDG_CONFIG_HOME/gomuks or $HOME/.config/gomuks
// - Data: $XDG_DATA_HOME/gomuks or $HOME/.local/share/gomuks
// - Cache: $XDG_CACHE_HOME/gomuks or $HOME/.cache/gomuks
// - Logs: $XDG_STATE_HOME/gomuks or $HOME/.local/state/gomuks
//
// Windows:
// - Config and Data: %AppData%\gomuks
// - Cache: %LocalAppData%\gomuks
// - Logs: %LocalAppData%\gomuks\logs
//
// macOS:
// - Config and Data: $HOME/Library/Application Support/gomuks
// - Cache: $HOME/Library/Caches/gomuks
// - Logs: $HOME/Library/Logs/gomuks
if gomuksRoot := os.Getenv("GOMUKS_ROOT"); gomuksRoot != "" {
exerrors.PanicIfNotNil(os.MkdirAll(gomuksRoot, 0700))
gmx.CacheDir = filepath.Join(gomuksRoot, "cache")
gmx.ConfigDir = filepath.Join(gomuksRoot, "config")
gmx.DataDir = filepath.Join(gomuksRoot, "data")
gmx.LogDir = filepath.Join(gomuksRoot, "logs")
} else {
homeDir := exerrors.Must(os.UserHomeDir())
if cacheDir := os.Getenv("GOMUKS_CACHE_HOME"); cacheDir != "" {
gmx.CacheDir = cacheDir
} else {
gmx.CacheDir = filepath.Join(exerrors.Must(os.UserCacheDir()), "gomuks")
}
if configDir := os.Getenv("GOMUKS_CONFIG_HOME"); configDir != "" {
gmx.ConfigDir = configDir
} else {
gmx.ConfigDir = filepath.Join(exerrors.Must(os.UserConfigDir()), "gomuks")
}
if dataDir := os.Getenv("GOMUKS_DATA_HOME"); dataDir != "" {
gmx.DataDir = dataDir
} else if dataDir = os.Getenv("XDG_DATA_HOME"); dataDir != "" {
gmx.DataDir = filepath.Join(dataDir, "gomuks")
} else if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
gmx.DataDir = gmx.ConfigDir
} else {
gmx.DataDir = filepath.Join(homeDir, ".local", "share", "gomuks")
}
if logDir := os.Getenv("GOMUKS_LOGS_HOME"); logDir != "" {
gmx.LogDir = logDir
} else if logDir = os.Getenv("XDG_STATE_HOME"); logDir != "" {
gmx.LogDir = filepath.Join(logDir, "gomuks")
} else if runtime.GOOS == "darwin" {
gmx.DataDir = filepath.Join(homeDir, "Library", "Logs", "gomuks")
} else if runtime.GOOS == "windows" {
gmx.DataDir = filepath.Join(gmx.CacheDir, "logs")
} else {
gmx.LogDir = filepath.Join(homeDir, ".local", "state", "gomuks")
}
}
exerrors.PanicIfNotNil(os.MkdirAll(gmx.ConfigDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.CacheDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.DataDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.LogDir, 0700))
}
func (gmx *Gomuks) SetupLog() {
gmx.Log = exerrors.Must((&zeroconfig.Config{
MinLevel: ptr.Ptr(zerolog.TraceLevel),
Writers: []zeroconfig.WriterConfig{{
Type: zeroconfig.WriterTypeStdout,
Format: zeroconfig.LogFormatPrettyColored,
}},
}).Compile())
exzerolog.SetupDefaults(gmx.Log)
}
func (gmx *Gomuks) StartServer(addr string) {
api := http.NewServeMux()
api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
middlewares := []func(http.Handler) http.Handler{
hlog.NewHandler(*gmx.Log),
hlog.RequestIDHandler("request_id", "Request-ID"),
requestlog.AccessLogger(false),
}
apiHandler := http.StripPrefix("/_gomuks", api)
for _, middleware := range slices.Backward(middlewares) {
apiHandler = middleware(apiHandler)
}
router := http.NewServeMux()
router.Handle("/_gomuks/", apiHandler)
if frontend, err := fs.Sub(web.Frontend, "dist"); err != nil {
gmx.Log.Warn().Msg("Frontend not found")
} else {
router.Handle("/", http.FileServerFS(frontend))
}
gmx.Server = &http.Server{
Addr: addr,
Handler: router,
}
go func() {
err := gmx.Server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
gmx.Log.Info().Str("address", addr).Msg("Server started")
}
func (gmx *Gomuks) StartClient() {
rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{
PoolConfig: dbutil.PoolConfig{
Type: "sqlite3-fk-wal",
URI: fmt.Sprintf("file:%s/gomuks.db?_txlock=immediate", gmx.DataDir),
MaxOpenConns: 5,
MaxIdleConns: 1,
},
}, dbutil.ZeroLogger(gmx.Log.With().Str("component", "hicli").Str("db_section", "main").Logger()))
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to open database")
os.Exit(10)
}
ctx := gmx.Log.WithContext(context.Background())
gmx.Client = hicli.New(
rawDB,
nil,
gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"),
hicli.JSONEventHandler(gmx.OnEvent).HandleEvent,
)
userID, err := gmx.Client.DB.Account.GetFirstUserID(ctx)
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get first user ID")
os.Exit(11)
}
err = gmx.Client.Start(ctx, userID, nil)
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to start client")
os.Exit(12)
}
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
}
func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() {
close(gmx.stopChan)
})
}
func (gmx *Gomuks) WaitForInterrupt() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
select {
case <-c:
case <-gmx.stopChan:
}
}
func (gmx *Gomuks) directStop() {
gmx.eventListenersLock.Lock()
closers := slices.Collect(maps.Values(gmx.websocketClosers))
gmx.eventListenersLock.Unlock()
for _, closer := range closers {
closer(websocket.StatusServiceRestart, "Server shutting down")
}
gmx.Client.Stop()
err := gmx.Server.Close()
if err != nil {
gmx.Log.Error().Err(err).Msg("Failed to close server")
}
}
func (gmx *Gomuks) OnEvent(evt *hicli.JSONCommand) {
gmx.eventListenersLock.RLock()
defer gmx.eventListenersLock.RUnlock()
for _, listener := range gmx.eventListeners {
listener(evt)
}
}
type WebsocketCloseFunc func(websocket.StatusCode, string)
func (gmx *Gomuks) SubscribeEvents(closeForRestart WebsocketCloseFunc, cb func(command *hicli.JSONCommand)) func() {
gmx.eventListenersLock.Lock()
defer gmx.eventListenersLock.Unlock()
gmx.nextListenerID++
id := gmx.nextListenerID
gmx.eventListeners[id] = cb
gmx.websocketClosers[id] = closeForRestart
return func() {
gmx.eventListenersLock.Lock()
defer gmx.eventListenersLock.Unlock()
delete(gmx.eventListeners, id)
delete(gmx.websocketClosers, id)
}
}

127
main.go Normal file
View file

@ -0,0 +1,127 @@
// 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/>.
package main
import (
"fmt"
"os"
"runtime"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
_ "go.mau.fi/util/dbutil/litestream"
flag "maunium.net/go/mauflag"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/hicli"
)
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
const StaticVersion = "0.4.0"
const URL = "https://github.com/tulir/gomuks"
var (
Version string
VersionDesc string
LinkifiedVersion string
ParsedBuildTime time.Time
)
var wantHelp, _ = flag.MakeHelpFlag()
var version = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
var listenAddress = flag.MakeFull("l", "listen", "Address to listen on.", "localhost:29325").String()
func main() {
hicli.InitialDeviceDisplayName = "gomuks web"
initVersion(Tag, Commit, BuildTime)
flag.SetHelpTitles(
"gomuks - A Matrix client written in Go.",
"gomuks [-hv] [-l address]",
)
err := flag.Parse()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
flag.PrintHelp()
os.Exit(1)
} else if *wantHelp {
flag.PrintHelp()
os.Exit(0)
} else if *version {
fmt.Println(VersionDesc)
os.Exit(0)
}
gmx := NewGomuks()
gmx.LoadConfig()
gmx.SetupLog()
gmx.Log.Info().
Str("version", Version).
Str("go_version", runtime.Version()).
Time("built_at", ParsedBuildTime).
Msg("Initializing gomuks")
gmx.StartServer(*listenAddress)
gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete")
gmx.WaitForInterrupt()
gmx.Log.Info().Msg("Shutting down...")
gmx.directStop()
gmx.Log.Info().Msg("Shutdown complete")
os.Exit(0)
}
func initVersion(tag, commit, rawBuildTime string) {
if len(tag) > 0 && tag[0] == 'v' {
tag = tag[1:]
}
if tag != StaticVersion {
suffix := "+dev"
if len(commit) > 8 {
Version = fmt.Sprintf("%s%s.%s", StaticVersion, suffix, commit[:8])
} else {
Version = fmt.Sprintf("%s%s.unknown", StaticVersion, suffix)
}
} else {
Version = StaticVersion
}
LinkifiedVersion = fmt.Sprintf("v%s", Version)
if tag == Version {
LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", Version, URL, tag)
} else if len(commit) > 8 {
LinkifiedVersion = strings.Replace(LinkifiedVersion, commit[:8], fmt.Sprintf("[%s](%s/commit/%s)", commit[:8], URL, commit), 1)
}
if rawBuildTime != "unknown" {
ParsedBuildTime, _ = time.Parse(time.RFC3339, rawBuildTime)
}
var builtWith string
if ParsedBuildTime.IsZero() {
rawBuildTime = "unknown"
builtWith = runtime.Version()
} else {
rawBuildTime = ParsedBuildTime.Format(time.RFC1123)
builtWith = fmt.Sprintf("built at %s with %s", rawBuildTime, runtime.Version())
}
mautrix.DefaultUserAgent = fmt.Sprintf("gomuks/%s %s", Version, mautrix.DefaultUserAgent)
VersionDesc = fmt.Sprintf("gomuks %s (%s)", Version, builtWith)
BuildTime = rawBuildTime
}

182
media.go Normal file
View file

@ -0,0 +1,182 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/hicli/database"
"maunium.net/go/mautrix/id"
)
var ErrBadGateway = mautrix.RespError{
ErrCode: "FI.MAU.GOMUKS.BAD_GATEWAY",
StatusCode: http.StatusBadGateway,
}
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, entry *database.CachedMedia, force bool) bool {
if entry == nil || entry.Hash == nil {
if force {
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
return true
}
return false
}
log := zerolog.Ctx(ctx)
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry))
if err != nil {
if errors.Is(err, os.ErrNotExist) && !force {
return false
}
log.Err(err).Msg("Failed to open cache file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w)
return true
}
defer func() {
_ = cacheFile.Close()
}()
cacheEntryToHeaders(w, entry)
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, cacheFile)
if err != nil {
log.Err(err).Msg("Failed to copy cache file to response")
}
return true
}
func (gmx *Gomuks) cacheEntryToPath(entry *database.CachedMedia) string {
hashPath := hex.EncodeToString(entry.Hash[:])
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
}
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.CachedMedia) {
w.Header().Set("Content-Type", entry.MimeType)
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none';")
}
func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
mxc := id.ContentURI{
Homeserver: r.PathValue("server"),
FileID: r.PathValue("media_id"),
}
if !mxc.IsValid() {
mautrix.MInvalidParam.WithMessage("Invalid mxc URI").Write(w)
return
}
query := r.URL.Query()
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
logVal := zerolog.Ctx(r.Context()).With().
Stringer("mxc_uri", mxc).
Bool("encrypted", encrypted).
Logger()
log := &logVal
ctx := log.WithContext(r.Context())
cacheEntry, err := gmx.Client.DB.CachedMedia.Get(ctx, mxc)
if err != nil {
log.Err(err).Msg("Failed to get cached media entry")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to get cached media entry: %v", err)).Write(w)
return
} else if (cacheEntry == nil || cacheEntry.EncFile == nil) && encrypted {
mautrix.MNotFound.WithMessage("Media encryption keys not found in cache").Write(w)
return
}
if gmx.downloadMediaFromCache(ctx, w, cacheEntry, false) {
return
}
tempFile, err := os.CreateTemp("", "gomuks-download-*")
if err != nil {
log.Err(err).Msg("Failed to create temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w)
return
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
resp, err := gmx.Client.Client.Download(ctx, mxc)
if err != nil {
log.Err(err).Msg("Failed to download media")
ErrBadGateway.WithMessage(err.Error()).Write(w)
return
}
defer func() {
_ = resp.Body.Close()
}()
if cacheEntry == nil {
cacheEntry = &database.CachedMedia{
MXC: mxc,
MimeType: resp.Header.Get("Content-Type"),
Size: resp.ContentLength,
}
}
reader := resp.Body
if cacheEntry.EncFile != nil {
err = cacheEntry.EncFile.PrepareForDecryption()
if err != nil {
log.Err(err).Msg("Failed to prepare media for decryption")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to prepare media for decryption: %v", err)).Write(w)
return
}
reader = cacheEntry.EncFile.DecryptStream(reader)
}
fileHasher := sha256.New()
hashReader := io.TeeReader(reader, fileHasher)
cacheEntry.Size, err = io.Copy(tempFile, hashReader)
if err != nil {
log.Err(err).Msg("Failed to copy media to temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w)
return
}
err = reader.Close()
if err != nil {
log.Err(err).Msg("Failed to close media reader")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to finish reading media: %v", err)).Write(w)
return
}
_ = tempFile.Close()
if cacheEntry.FileName == "" {
_, params, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
cacheEntry.FileName = params["filename"]
}
if cacheEntry.MimeType == "" {
cacheEntry.MimeType = resp.Header.Get("Content-Type")
}
cacheEntry.Hash = (*[32]byte)(fileHasher.Sum(nil))
err = gmx.Client.DB.CachedMedia.Put(ctx, cacheEntry)
if err != nil {
log.Err(err).Msg("Failed to save cache entry")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to save cache entry: %v", err)).Write(w)
return
}
cachePath := gmx.cacheEntryToPath(cacheEntry)
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
log.Err(err).Msg("Failed to create cache directory")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w)
return
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
log.Err(err).Msg("Failed to rename temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w)
return
}
gmx.downloadMediaFromCache(ctx, w, cacheEntry, true)
}

9
web/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
logs
*.log
npm-debug.log*
tsconfig.tsbuildinfo
node_modules
dist
dist-ssr
*.local

66
web/eslint.config.js Normal file
View file

@ -0,0 +1,66 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
export default tseslint.config(
{ignores: ["dist"]},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2023,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{allowConstantExport: true},
],
"indent": ["error", "tab", {
"FunctionDeclaration": {"parameters": "first"},
"FunctionExpression": {"parameters": "first"},
"CallExpression": {"arguments": "first"},
"ArrayExpression": "first",
"ObjectExpression": "first",
"ImportDeclaration": "first",
}],
"object-curly-newline": ["error", {
"consistent": true,
}],
"object-curly-spacing": ["error", "always", {
"arraysInObjects": false,
"objectsInObjects": false,
}],
"array-bracket-spacing": ["error", "never"],
"one-var-declaration-per-line": ["error", "initializations"],
"quotes": ["error", "double"],
"semi": ["error", "never"],
"comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 120],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always",
}],
"func-style": ["warn", "declaration", {"allowArrowFunctions": true}],
"id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}],
"new-cap": ["warn", {
"newIsCap": true,
"capIsNew": true,
}],
"no-empty": ["error", {
"allowEmptyCatch": true,
}],
"eol-last": ["error", "always"],
"no-console": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
)

26
web/frontend.go Normal file
View file

@ -0,0 +1,26 @@
// 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/>.
package web
import (
"embed"
)
//go:generate npm install --legacy-peer-deps
//go:generate npm run build
//go:embed dist
var Frontend embed.FS

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/gomuks.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gomuks web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
</body>
</html>

2585
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
web/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "gomuks-web",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"react": "^19.0.0-rc-0751fac7-20241002",
"react-dom": "^19.0.0-rc-0751fac7-20241002",
"react-spinners": "^0.14.1"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
}
}

BIN
web/public/gomuks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

0
web/src/App.css Normal file
View file

68
web/src/App.tsx Normal file
View file

@ -0,0 +1,68 @@
// 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/>.
import { useEffect, useMemo, useState } from "react"
import "./App.css"
import Client from "./client.ts"
import WSClient from "./wsclient.ts"
import { ClientState } from "./hievents.ts"
import { ConnectionEvent } from "./rpc.ts"
import { LoginScreen, VerificationScreen } from "./login"
import { ScaleLoader } from "react-spinners"
import MainScreen from "./MainScreen.tsx"
function App() {
const [connState, setConnState] = useState<ConnectionEvent>()
const [clientState, setClientState] = useState<ClientState>()
const client = useMemo(() => new Client(new WSClient("/_gomuks/websocket")), [])
useEffect(() => {
((window as unknown) as { client: Client }).client = client
// TODO remove this debug log
const unlistenDebug = client.rpc.event.listen(ev => {
console.debug("Received event:", ev)
})
const unlistenConnect = client.rpc.connect.listen(setConnState)
const unlistenState = client.state.listen(setClientState)
client.rpc.start()
return () => {
unlistenConnect()
unlistenState()
unlistenDebug()
client.rpc.stop()
}
}, [client])
if (connState?.error) {
return <div>
error {`${connState.error}`} :(
</div>
} else if (!connState?.connected || !clientState) {
const msg = connState?.connected ?
"Waiting for client state..." : "Connecting to backend..."
return <div>
<ScaleLoader/>
{msg}
</div>
} else if (!clientState.is_logged_in) {
return <LoginScreen client={client} clientState={clientState}/>
} else if (!clientState.is_verified) {
return <VerificationScreen client={client} clientState={clientState}/>
} else {
return <MainScreen client={client} />
}
}
export default App

7
web/src/MainScreen.css Normal file
View file

@ -0,0 +1,7 @@
main.matrix-main {
position: fixed;
inset: 0;
display: grid;
grid-template: "roomlist roomview" 1fr / 300px 1fr;
}

36
web/src/MainScreen.tsx Normal file
View file

@ -0,0 +1,36 @@
// 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/>.
import { useState } from "react"
import type Client from "./client.ts"
import type { RoomID } from "./hitypes.ts"
import RoomList from "./RoomList.tsx"
import RoomView from "./RoomView.tsx"
import "./MainScreen.css"
export interface MainScreenProps {
client: Client
}
const MainScreen = ({ client }: MainScreenProps) => {
const [activeRoomID, setActiveRoomID] = useState<RoomID | null>(null)
const activeRoom = activeRoomID && client.store.rooms.get(activeRoomID)
return <main className="matrix-main">
<RoomList client={client} setActiveRoom={setActiveRoomID} />
{activeRoom && <RoomView client={client} room={activeRoom} />}
</main>
}
export default MainScreen

50
web/src/RoomList.css Normal file
View file

@ -0,0 +1,50 @@
div.room-list {
grid-area: roomlist;
overflow-y: auto;
div.room-entry {
width: 100%;
display: flex;
gap: 4px;
border-radius: 4px;
user-select: none;
cursor: pointer;
&:hover {
background-color: #EEE;
}
> div.room-entry-left {
height: 48px;
width: 48px;
> img.room-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
padding: 4px;
}
}
> div.room-entry-right {
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: space-around;
> div.room-name {
font-weight: bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
> div.message-preview {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
}

101
web/src/RoomList.tsx Normal file
View file

@ -0,0 +1,101 @@
// 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/>.
import React, { useMemo } from "react"
import Client from "./client.ts"
import { DBEvent, RoomID } from "./hitypes.ts"
import { useNonNullEventAsState } from "./eventdispatcher.ts"
import { RoomListEntry } from "./statestore.ts"
import "./RoomList.css"
export interface RoomListProps {
client: Client
setActiveRoom: (room_id: RoomID) => void
}
const RoomList = ({ client, setActiveRoom }: RoomListProps) => {
const roomList = useNonNullEventAsState(client.store.roomList)
const clickRoom = useMemo(() => (evt: React.MouseEvent) => {
const roomID = evt.currentTarget.getAttribute("data-room-id")
if (roomID) {
setActiveRoom(roomID)
} else {
console.warn("No room ID :(", evt.currentTarget)
}
}, [setActiveRoom])
return <div className="room-list">
{reverseMap(roomList, room =>
<RoomEntry
key={room.room_id}
client={client}
room={room}
setActiveRoom={clickRoom}
/>,
)}
</div>
}
function reverseMap<T, O>(arg: T[], fn: (a: T) => O) {
return arg.map((_, i, arr) => fn(arr[arr.length - i - 1]))
}
export interface RoomListEntryProps {
client: Client
room: RoomListEntry
setActiveRoom: (evt: React.MouseEvent) => void
}
function makePreviewText(evt?: DBEvent): string {
if (!evt) {
return ""
}
if (evt.type === "m.room.message") {
// @ts-expect-error TODO add content types
return evt.content.body
} else if (evt.decrypted_type === "m.room.message") {
// @ts-expect-error TODO add content types
return evt.decrypted.body
}
return ""
}
const avatarRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/
const getAvatarURL = (avatar?: string): string | undefined => {
if (!avatar) {
return undefined
}
const match = avatar.match(avatarRegex)
if (!match) {
return undefined
}
return `_gomuks/media/${match[1]}/${match[2]}`
}
const RoomEntry = ({ room, setActiveRoom }: RoomListEntryProps) => {
const previewText = makePreviewText(room.preview_event)
return <div className="room-entry" onClick={setActiveRoom} data-room-id={room.room_id}>
<div className="room-entry-left">
<img className="room-avatar" src={getAvatarURL(room.avatar)} alt=""/>
</div>
<div className="room-entry-right">
<div className="room-name">{room.name}</div>
{previewText && <div className="message-preview" title={previewText}>{previewText}</div>}
</div>
</div>
}
export default RoomList

3
web/src/RoomView.css Normal file
View file

@ -0,0 +1,3 @@
div.room-view {
overflow-y: scroll;
}

38
web/src/RoomView.tsx Normal file
View file

@ -0,0 +1,38 @@
// 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/>.
import Client from "./client.ts"
import { RoomStateStore } from "./statestore.ts"
import { useNonNullEventAsState } from "./eventdispatcher.ts"
import "./RoomView.css"
import TimelineEvent from "./TimelineEvent.tsx"
export interface RoomViewProps {
client: Client
room: RoomStateStore
}
const RoomView = ({ client, room }: RoomViewProps) => {
const roomMeta = useNonNullEventAsState(room.meta)
const timeline = useNonNullEventAsState(room.timeline)
return <div className="room-view">
{roomMeta.room_id}
{timeline.map(entry => <TimelineEvent
key={entry.event_rowid} client={client} room={room} eventRowID={entry.event_rowid}
/>)}
</div>
}
export default RoomView

View file

39
web/src/TimelineEvent.tsx Normal file
View file

@ -0,0 +1,39 @@
// 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/>.
import { RoomViewProps } from "./RoomView.tsx"
import "./TimelineEvent.css"
export interface TimelineEventProps extends RoomViewProps {
eventRowID: number
}
const TimelineEvent = ({ room, eventRowID }: TimelineEventProps) => {
const evt = room.eventsByRowID.get(eventRowID)
if (!evt) {
return null
}
// @ts-expect-error TODO add content types
const body = (evt.decrypted ?? evt.content).body
return <div className="timeline-event">
<code>{evt.decrypted_type ?? evt.type}</code>
&nbsp;
<code>{evt.sender}</code>
&nbsp;
{body ?? <code>{JSON.stringify(evt.decrypted ?? evt.content, null, " ")}</code>}
</div>
}
export default TimelineEvent

81
web/src/client.ts Normal file
View file

@ -0,0 +1,81 @@
// 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/>.
import type {
ClientWellKnown, DBEvent, EventID, EventRowID, EventType, RoomID, TimelineRowID, UserID,
} from "./hitypes.ts"
import { ClientState, RPCEvent } from "./hievents.ts"
import { RPCClient } from "./rpc.ts"
import { CachedEventDispatcher } from "./eventdispatcher.ts"
import { StateStore } from "./statestore.ts"
export default class Client {
readonly state = new CachedEventDispatcher<ClientState>()
readonly store = new StateStore()
constructor(readonly rpc: RPCClient) {
this.rpc.event.listen(this.#handleEvent)
}
#handleEvent = (ev: RPCEvent) => {
if (ev.command === "client_state") {
this.state.emit(ev.data)
} else if (ev.command === "sync_complete") {
this.store.applySync(ev.data)
} else if (ev.command === "events_decrypted") {
this.store.applyDecrypted(ev.data)
}
}
request<Req, Resp>(command: string, data: Req): Promise<Resp> {
return this.rpc.request(command, data)
}
sendMessage(room_id: RoomID, event_type: EventType, content: Record<string, unknown>): Promise<DBEvent> {
return this.request("send_message", { room_id, event_type, content })
}
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
return this.request("ensure_group_session_shared", { room_id })
}
getEvent(room_id: RoomID, event_id: EventID): Promise<DBEvent> {
return this.request("get_event", { room_id, event_id })
}
getEventsByRowIDs(row_ids: EventRowID[]): Promise<DBEvent[]> {
return this.request("get_events_by_row_ids", { row_ids })
}
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<DBEvent[]> {
return this.request("paginate", { room_id, max_timeline_id, limit })
}
paginateServer(room_id: RoomID, limit: number): Promise<DBEvent[]> {
return this.request("paginate_server", { room_id, limit })
}
discoverHomeserver(user_id: UserID): Promise<ClientWellKnown> {
return this.request("discover_homeserver", { user_id })
}
login(homeserver_url: string, username: string, password: string): Promise<boolean> {
return this.request("login", { homeserver_url, username, password })
}
verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key })
}
}

View file

@ -0,0 +1,79 @@
// 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/>.
import { useEffect, useState } from "react"
export function useEventAsState<T>(dispatcher?: EventDispatcher<T>): T | null {
const [state, setState] = useState<T | null>(null)
useEffect(() => dispatcher && dispatcher.listen(setState), [dispatcher])
return state
}
export function useNonNullEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T {
const [state, setState] = useState<T>(dispatcher.current)
useEffect(() => dispatcher.listen(setState), [dispatcher])
return state
}
export class EventDispatcher<T> {
#listeners: ((data: T) => void)[] = []
listen(listener: (data: T) => void): () => void {
this.#listeners.push(listener)
return () => {
const idx = this.#listeners.indexOf(listener)
if (idx >= 0) {
this.#listeners.splice(idx, 1)
}
}
}
emit(data: T) {
for (const listener of this.#listeners) {
listener(data)
}
}
}
export class CachedEventDispatcher<T> extends EventDispatcher<T> {
current: T | null
constructor(cache?: T | null) {
super()
this.current = cache ?? null
}
emit(data: T) {
this.current = data
super.emit(data)
}
listen(listener: (data: T) => void): () => void {
const unlisten = super.listen(listener)
if (this.current !== null) {
listener(this.current)
}
return unlisten
}
}
export class NonNullCachedEventDispatcher<T> extends CachedEventDispatcher<T> {
current: T
constructor(cache: T) {
super(cache)
this.current = cache
}
}

96
web/src/hievents.ts Normal file
View file

@ -0,0 +1,96 @@
// 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/>.
import {
DBEvent,
DBRoom,
DeviceID,
EventRowID,
RoomID,
TimelineRowTuple,
UserID,
} from "./hitypes.ts"
export interface RPCCommand<T> {
command: string
request_id: number
data: T
}
export interface TypingEventData {
room_id: RoomID
user_ids: UserID[]
}
export interface TypingEvent extends RPCCommand<TypingEventData> {
command: "typing"
}
export interface SendCompleteData {
event: DBEvent
error: string | null
}
export interface SendCompleteEvent extends RPCCommand<SendCompleteData> {
command: "send_complete"
}
export interface EventsDecryptedData {
room_id: RoomID
preview_event_rowid?: EventRowID
events: DBEvent[]
}
export interface EventsDecryptedEvent extends RPCCommand<EventsDecryptedData> {
command: "events_decrypted"
}
export interface SyncRoom {
meta: DBRoom
timeline: TimelineRowTuple[]
events: DBEvent[]
reset: boolean
}
export interface SyncCompleteData {
rooms: Record<RoomID, SyncRoom>
}
export interface SyncCompleteEvent extends RPCCommand<SyncCompleteData> {
command: "sync_complete"
}
export type ClientState = {
is_logged_in: false
is_verified: false
} | {
is_logged_in: true
is_verified: boolean
user_id: UserID
device_id: DeviceID
homeserver_url: string
}
export interface ClientStateEvent extends RPCCommand<ClientState> {
command: "client_state"
}
export type RPCEvent =
ClientStateEvent |
TypingEvent |
SendCompleteEvent |
EventsDecryptedEvent |
SyncCompleteEvent

125
web/src/hitypes.ts Normal file
View file

@ -0,0 +1,125 @@
// 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 type EventRowID = number
export type TimelineRowID = number
export type RoomID = string
export type EventID = string
export type UserID = string
export type DeviceID = string
export type EventType = string
export type ContentURI = string
export type RoomAlias = string
export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11"
export type RoomType = "" | "m.space"
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
export interface TimelineRowTuple {
timeline_rowid: TimelineRowID
event_rowid: EventRowID
}
export enum RoomNameQuality {
Nil = 0,
Participants,
CanonicalAlias,
Explicit,
}
export interface RoomPredecessor {
room_id: RoomID
event_id: EventID
}
export interface CreateEventContent {
type: RoomType
"m.federate": boolean
room_version: RoomVersion
predecessor: RoomPredecessor
}
export interface LazyLoadSummary {
heroes?: UserID[]
"m.joined_member_count"?: number
"m.invited_member_count"?: number
}
export interface EncryptionEventContent {
algorithm: string
rotation_period_ms?: number
rotation_period_msgs?: number
}
export interface DBRoom {
room_id: RoomID
creation_content: CreateEventContent
name?: string
name_quality: RoomNameQuality
avatar?: ContentURI
topic?: string
canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary
encryption_event?: EncryptionEventContent
has_member_list: boolean
preview_event_rowid: EventRowID
sorting_timestamp: number
prev_batch: string
}
export interface DBEvent {
rowid: EventRowID
timeline_rowid: TimelineRowID
room_id: RoomID
event_id: EventID
sender: UserID
type: EventType
state_key?: string
timestamp: number
content: unknown
decrypted?: unknown
decrypted_type?: EventType
unsigned: EventUnsigned
transaction_id?: string
redacted_by?: EventID
relates_to?: EventID
relation_type?: RelationType
decryption_error?: string
reactions?: Record<string, number>
last_edit_rowid?: EventRowID
}
export interface EventUnsigned {
prev_content?: unknown
prev_sender?: UserID
}
export interface ClientWellKnown {
"m.homeserver": {
base_url: string
},
"m.identity_server": {
base_url: string
}
}

32
web/src/index.css Normal file
View file

@ -0,0 +1,32 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #EEE;
}
#root {
display: flex;
justify-content: center;
}
main {
background-color: white;
}
pre, code {
font-family: "Fira Code", monospace;
}
button {
cursor: pointer;
font-size: 1em;
}
:root {
--primary-color: #00c853;
--primary-color-light: #92ffc0;
--primary-color-dark: #00b24a;
--error-color: red;
--error-color-light: #ff6666;
}

View file

@ -0,0 +1,66 @@
main.matrix-login {
max-width: 30rem;
width: 100%;
padding: 3rem 6rem;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.25);
margin: 2rem;
@media (width < 800px) {
padding: 2rem 4rem;
}
@media (width < 500px) {
padding: 1rem;
box-shadow: none;
margin: 0 !important;
}
h1 {
margin: 0 0 2rem;
text-align: center;
}
button, input {
margin-top: .5rem;
padding: 1rem;
font-size: 1rem;
width: 100%;
display: block;
border-radius: .25rem;
box-sizing: border-box;
}
input {
border: 1px solid var(--primary-color);
&:hover {
outline: 1px solid var(--primary-color);
}
&:focus {
outline: 3px solid var(--primary-color);
}
}
form {
margin: 2rem 0;
}
button {
background-color: var(--primary-color);
color: white;
font-weight: bold;
border: none;
&:hover, &:focus {
background-color: var(--primary-color-dark);
}
}
div.error {
border: 2px solid var(--error-color);
border-radius: .25rem;
padding: 1rem;
}
}

View file

@ -0,0 +1,87 @@
// 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/>.
import React, { useCallback, useEffect, useState } from "react"
import type Client from "../client.ts"
import "./LoginScreen.css"
import { ClientState } from "../hievents.ts"
export interface LoginScreenProps {
client: Client
clientState: ClientState
}
export const LoginScreen = ({ client }: LoginScreenProps) => {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [homeserverURL, setHomeserverURL] = useState("")
const [error, setError] = useState("")
const login = useCallback((evt: React.FormEvent) => {
evt.preventDefault()
client.login(homeserverURL, username, password).then(
() => {},
err => setError(err.toString()),
)
}, [homeserverURL, username, password, client])
const resolveHomeserver = useCallback(() => {
client.discoverHomeserver(username).then(
resp => setHomeserverURL(resp["m.homeserver"].base_url),
err => setError(`Failed to resolve homeserver: ${err}`),
)
}, [client, username])
useEffect(() => {
if (!username.startsWith("@") || !username.includes(":") || !username.includes(".")) {
return
}
const timeout = setTimeout(resolveHomeserver, 500)
return () => {
clearTimeout(timeout)
}
}, [username, resolveHomeserver])
return <main className="matrix-login">
<h1>gomuks web</h1>
<form onSubmit={login}>
<input
type="text"
id="mxlogin-username"
placeholder="User ID"
value={username}
onChange={evt => setUsername(evt.target.value)}
/>
<input
type="password"
id="mxlogin-password"
placeholder="Password"
value={password}
onChange={evt => setPassword(evt.target.value)}
/>
<input
type="text"
id="mxlogin-homeserver-url"
placeholder="Homeserver URL"
value={homeserverURL}
onChange={evt => setHomeserverURL(evt.target.value)}
/>
<button className="mx-login-button" type="submit">Login</button>
</form>
{error && <div className="error">
{error}
</div>}
</main>
}

View file

@ -0,0 +1,52 @@
// 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/>.
import React, { useCallback, useState } from "react"
import "./LoginScreen.css"
import { LoginScreenProps } from "./LoginScreen.tsx"
export const VerificationScreen = ({ client, clientState }: LoginScreenProps) => {
if (!clientState.is_logged_in) {
throw new Error("Invalid state")
}
const [recoveryKey, setRecoveryKey] = useState("")
const [error, setError] = useState("")
const verify = useCallback((evt: React.FormEvent) => {
evt.preventDefault()
client.verify(recoveryKey).then(
() => {},
err => setError(err.toString()),
)
}, [recoveryKey, client])
return <main className="matrix-login">
<h1>gomuks web</h1>
<form onSubmit={verify}>
<p>Successfully logged in as <code>{clientState.user_id}</code></p>
<input
type="text"
id="mxlogin-recoverykey"
placeholder="Recovery key"
value={recoveryKey}
onChange={evt => setRecoveryKey(evt.target.value)}
/>
<button className="mx-login-button" type="submit">Verify</button>
</form>
{error && <div className="error">
{error}
</div>}
</main>
}

2
web/src/login/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { LoginScreen } from "./LoginScreen.tsx"
export { VerificationScreen } from "./VerificationScreen.tsx"

25
web/src/main.tsx Normal file
View file

@ -0,0 +1,25 @@
// 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/>.
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App.tsx"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App/>
</StrictMode>,
)

39
web/src/rpc.ts Normal file
View file

@ -0,0 +1,39 @@
// 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/>.
import { RPCEvent } from "./hievents.ts"
import { EventDispatcher } from "./eventdispatcher.ts"
export class CancellablePromise<T> extends Promise<T> {
constructor(
executor: (resolve: (value: T) => void, reject: (reason?: Error) => void) => void,
readonly cancel: (reason: string) => void,
) {
super(executor)
}
}
export interface RPCClient {
connect: EventDispatcher<ConnectionEvent>
event: EventDispatcher<RPCEvent>
start(): void
stop(): void
request<Req, Resp>(command: string, data: Req): CancellablePromise<Resp>
}
export interface ConnectionEvent {
connected: boolean
error: Error | null
}

196
web/src/statestore.ts Normal file
View file

@ -0,0 +1,196 @@
// 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/>.
import type {
ContentURI,
DBEvent,
DBRoom,
EventID,
EventRowID,
LazyLoadSummary,
RoomID,
TimelineRowTuple,
} from "./hitypes.ts"
import type { EventsDecryptedData, SyncCompleteData, SyncRoom } from "./hievents.ts"
import { NonNullCachedEventDispatcher } from "./eventdispatcher.ts"
function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
if (!arr1 || !arr2) {
return !arr1 && !arr2
}
if (arr1.length !== arr2.length) {
return false
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false
}
}
return true
}
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
arraysAreEqual(ll1?.heroes, ll2?.heroes)
}
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
return meta1.name === meta2.name &&
meta1.avatar === meta2.avatar &&
meta1.topic === meta2.topic &&
meta1.canonical_alias === meta2.canonical_alias &&
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
meta1.has_member_list === meta2.has_member_list
}
export class RoomStateStore {
readonly meta: NonNullCachedEventDispatcher<DBRoom>
readonly timeline = new NonNullCachedEventDispatcher<TimelineRowTuple[]>([])
readonly eventsByRowID: Map<EventRowID, DBEvent> = new Map()
readonly eventsByID: Map<EventID, DBEvent> = new Map()
constructor(meta: DBRoom) {
this.meta = new NonNullCachedEventDispatcher(meta)
}
applySync(sync: SyncRoom) {
if (visibleMetaIsEqual(this.meta.current, sync.meta)) {
this.meta.current = sync.meta
} else {
this.meta.emit(sync.meta)
}
for (const evt of sync.events) {
this.eventsByRowID.set(evt.rowid, evt)
this.eventsByID.set(evt.event_id, evt)
}
if (sync.reset) {
this.timeline.emit(sync.timeline)
} else {
this.timeline.emit([...this.timeline.current, ...sync.timeline])
}
}
applyDecrypted(decrypted: EventsDecryptedData) {
let timelineChanged = false
for (const evt of decrypted.events) {
timelineChanged = timelineChanged || !!this.timeline.current.find(rt => rt.event_rowid === evt.rowid)
this.eventsByRowID.set(evt.rowid, evt)
this.eventsByID.set(evt.event_id, evt)
}
if (timelineChanged) {
this.timeline.emit([...this.timeline.current])
}
if (decrypted.preview_event_rowid) {
this.meta.current.preview_event_rowid = decrypted.preview_event_rowid
}
}
}
export interface RoomListEntry {
room_id: RoomID
sorting_timestamp: number
preview_event?: DBEvent
name: string
avatar?: ContentURI
}
export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
#roomListEntryChanged(entry: SyncRoom, oldEntry: RoomStateStore): boolean {
return entry.meta.sorting_timestamp !== oldEntry.meta.current.sorting_timestamp ||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
}
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry {
if (!room) {
room = this.rooms.get(entry.meta.room_id)
}
return {
room_id: entry.meta.room_id,
sorting_timestamp: entry.meta.sorting_timestamp,
preview_event: room?.eventsByRowID.get(entry.meta.preview_event_rowid),
name: entry.meta.name ?? "Unnamed room",
avatar: entry.meta.avatar,
}
}
applySync(sync: SyncCompleteData) {
const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry>()
for (const [roomID, data] of Object.entries(sync.rooms)) {
let isNewRoom = false
let room = this.rooms.get(roomID)
if (!room) {
room = new RoomStateStore(data.meta)
this.rooms.set(roomID, room)
isNewRoom = true
}
const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room))
room.applySync(data)
if (roomListEntryChanged) {
changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room))
}
}
let updatedRoomList: RoomListEntry[] | undefined
if (resyncRoomList) {
updatedRoomList = Object.values(sync.rooms).map(entry => this.#makeRoomListEntry(entry))
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
} else if (changedRoomListEntries.size > 0) {
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
for (const entry of changedRoomListEntries.values()) {
if (updatedRoomList.length === 0 || entry.sorting_timestamp >=
updatedRoomList[updatedRoomList.length - 1].sorting_timestamp) {
updatedRoomList.push(entry)
} else if (entry.sorting_timestamp <= 0 ||
entry.sorting_timestamp < updatedRoomList[0]?.sorting_timestamp) {
updatedRoomList.unshift(entry)
} else {
const indexToPushAt = updatedRoomList.findLastIndex(val =>
val.sorting_timestamp <= entry.sorting_timestamp)
updatedRoomList.splice(indexToPushAt + 1, 0, entry)
}
}
}
if (updatedRoomList) {
this.roomList.emit(updatedRoomList)
}
}
applyDecrypted(decrypted: EventsDecryptedData) {
const room = this.rooms.get(decrypted.room_id)
if (!room) {
// TODO log or something?
return
}
room.applyDecrypted(decrypted)
if (decrypted.preview_event_rowid) {
const idx = this.roomList.current.findIndex(entry => entry.room_id === decrypted.room_id)
if (idx !== -1) {
const updatedRoomList = [...this.roomList.current]
updatedRoomList[idx] = {
...updatedRoomList[idx],
preview_event: room.eventsByRowID.get(decrypted.preview_event_rowid),
}
this.roomList.emit(updatedRoomList)
}
}
}
}

147
web/src/wsclient.ts Normal file
View file

@ -0,0 +1,147 @@
// 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/>.
import { RPCCommand, RPCEvent } from "./hievents.ts"
import { CachedEventDispatcher, EventDispatcher } from "./eventdispatcher.ts"
import { CancellablePromise, ConnectionEvent, RPCClient } from "./rpc.ts"
export class ErrorResponse extends Error {
constructor(public data: unknown) {
super(`${data}`)
}
}
export default class WSClient implements RPCClient {
#conn: WebSocket | null = null
readonly connect: CachedEventDispatcher<ConnectionEvent> = new CachedEventDispatcher()
readonly event: EventDispatcher<RPCEvent> = new EventDispatcher()
readonly #pendingRequests: Map<number, {
resolve: (data: unknown) => void,
reject: (err: Error) => void
}> = new Map()
#nextRequestID: number = 1
constructor(readonly addr: string) {
}
start() {
try {
console.info("Connecting to websocket", this.addr)
this.#conn = new WebSocket(this.addr)
this.#conn.onmessage = this.#onMessage
this.#conn.onopen = this.#onOpen
this.#conn.onerror = this.#onError
this.#conn.onclose = this.#onClose
} catch (err) {
this.#dispatchConnectionStatus(false, err as Error)
}
}
stop() {
this.#conn?.close(1000, "Client closed")
}
#cancelRequest(request_id: number, reason: string) {
if (!this.#pendingRequests.has(request_id)) {
console.debug("Tried to cancel unknown request", request_id)
return
}
this.request("cancel", { request_id, reason }).then(
() => console.debug("Cancelled request", request_id, "for", reason),
err => console.debug("Failed to cancel request", request_id, "for", reason, err),
)
}
request<Req, Resp>(command: string, data: Req): CancellablePromise<Resp> {
if (!this.#conn) {
return new CancellablePromise((_resolve, reject) => {
reject(new Error("Websocket not connected"))
}, () => {
})
}
const request_id = this.#nextRequestID++
return new CancellablePromise((resolve, reject) => {
if (!this.#conn) {
reject(new Error("Websocket not connected"))
return
}
this.#pendingRequests.set(request_id, { resolve: resolve as ((value: unknown) => void), reject })
this.#conn.send(JSON.stringify({
command,
request_id,
data,
}))
}, this.#cancelRequest.bind(this, request_id))
}
#onMessage = (ev: MessageEvent) => {
let parsed: RPCCommand<unknown>
try {
parsed = JSON.parse(ev.data)
if (!parsed.command) {
throw new Error("Missing 'command' field in JSON message")
}
} catch (err) {
console.error("Malformed JSON in websocket:", err)
console.error("Message:", ev.data)
this.#conn?.close(1003, "Malformed JSON")
return
}
if (parsed.command === "response" || parsed.command === "error") {
const target = this.#pendingRequests.get(parsed.request_id)
if (!target) {
console.error("Received response for unknown request:", parsed)
return
}
this.#pendingRequests.delete(parsed.request_id)
if (parsed.command === "response") {
target.resolve(parsed.data)
} else {
target.reject(new ErrorResponse(parsed.data))
}
} else {
this.event.emit(parsed as RPCEvent)
}
}
#dispatchConnectionStatus(connected: boolean, error: Error | null) {
this.connect.emit({ connected, error })
}
#onOpen = () => {
console.info("Websocket opened")
this.#dispatchConnectionStatus(true, null)
}
#clearPending = () => {
for (const { reject } of this.#pendingRequests.values()) {
reject(new Error("Websocket closed"))
}
this.#pendingRequests.clear()
}
#onError = (ev: Event) => {
console.error("Websocket error:", ev)
this.#dispatchConnectionStatus(false, new Error("Websocket error"))
this.#clearPending()
}
#onClose = (ev: CloseEvent) => {
console.warn("Websocket closed:", ev)
this.#dispatchConnectionStatus(false, new Error(`Websocket closed: ${ev.code} ${ev.reason}`))
this.#clearPending()
}
}

28
web/tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src", "vite.config.ts"
]
}

17
web/vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import {defineConfig} from "vite"
import react from "@vitejs/plugin-react-swc"
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/_gomuks/websocket": {
target: "http://localhost:29325",
ws: true,
},
"/_gomuks": {
target: "http://localhost:29325",
}
},
},
})

267
websocket.go Normal file
View file

@ -0,0 +1,267 @@
// 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/>.
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"sync"
"time"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/hicli"
"maunium.net/go/mautrix/hicli/database"
"maunium.net/go/mautrix/id"
)
func writeCmd(ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommand) error {
writer, err := conn.Writer(ctx, websocket.MessageText)
if err != nil {
return err
}
err = json.NewEncoder(writer).Encode(&cmd)
if err != nil {
return err
}
return writer.Close()
}
const StatusEventsStuck = 4001
func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var conn *websocket.Conn
log := zerolog.Ctx(r.Context())
recoverPanic := func(context string) bool {
err := recover()
if err != nil {
logEvt := log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Str("goroutine", context)
if realErr, ok := err.(error); ok {
logEvt = logEvt.Err(realErr)
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
}
logEvt.Msg("Panic in websocket handler")
return true
}
return false
}
defer recoverPanic("read loop")
conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"localhost:*"},
})
if acceptErr != nil {
log.Warn().Err(acceptErr).Msg("Failed to accept websocket connection")
return
}
log.Info().Msg("Accepted new websocket connection")
conn.SetReadLimit(128 * 1024)
ctx, cancel := context.WithCancel(context.Background())
ctx = log.WithContext(ctx)
unsubscribe := func() {}
evts := make(chan *hicli.JSONCommand, 32)
forceClose := func() {
cancel()
unsubscribe()
_ = conn.CloseNow()
close(evts)
}
var closeOnce sync.Once
defer closeOnce.Do(forceClose)
closeManually := func(statusCode websocket.StatusCode, reason string) {
log.Debug().Stringer("status_code", statusCode).Str("reason", reason).Msg("Closing connection manually")
_ = conn.Close(statusCode, reason)
closeOnce.Do(forceClose)
}
unsubscribe = gmx.SubscribeEvents(closeManually, func(evt *hicli.JSONCommand) {
if ctx.Err() != nil {
return
}
select {
case evts <- evt:
default:
log.Warn().Msg("Event queue full, closing connection")
cancel()
go func() {
defer recoverPanic("closing connection after error in event handler")
_ = conn.Close(StatusEventsStuck, "Event queue full")
closeOnce.Do(forceClose)
}()
}
})
go func() {
defer recoverPanic("event loop")
defer closeOnce.Do(forceClose)
ctxDone := ctx.Done()
for {
select {
case cmd := <-evts:
err := writeCmd(ctx, conn, cmd)
if err != nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write outgoing event")
return
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent outgoing event")
}
case <-ctxDone:
return
}
}
}()
submitCmd := func(cmd *hicli.JSONCommand) {
defer func() {
if recoverPanic("command handler") {
_ = conn.Close(websocket.StatusInternalError, "Command handler panicked")
closeOnce.Do(forceClose)
}
}()
log.Trace().
Int64("req_id", cmd.RequestID).
Str("command", cmd.Command).
RawJSON("data", cmd.Data).
Msg("Received command")
resp := gmx.Client.SubmitJSONCommand(ctx, cmd)
if ctx.Err() != nil {
return
}
err := writeCmd(ctx, conn, resp)
if err != nil && ctx.Err() == nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write response")
closeOnce.Do(forceClose)
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
}
}
initData, initErr := json.Marshal(gmx.Client.State())
if initErr != nil {
log.Err(initErr).Msg("Failed to marshal init message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "client_state",
Data: initData,
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init message")
return
}
go gmx.sendInitialData(ctx, conn)
log.Debug().Msg("Connection initialization complete")
var closeErr websocket.CloseError
for {
msgType, reader, err := conn.Reader(ctx)
if err != nil {
if errors.As(err, &closeErr) {
log.Debug().
Stringer("status_code", closeErr.Code).
Str("reason", closeErr.Reason).
Msg("Connection closed")
} else {
log.Err(err).Msg("Failed to read message")
}
return
} else if msgType != websocket.MessageText {
log.Error().Stringer("message_type", msgType).Msg("Unexpected message type")
_ = conn.Close(websocket.StatusUnsupportedData, "Non-text message")
return
}
var cmd hicli.JSONCommand
err = json.NewDecoder(reader).Decode(&cmd)
if err != nil {
log.Err(err).Msg("Failed to parse message")
_ = conn.Close(websocket.StatusUnsupportedData, "Invalid JSON")
return
}
go submitCmd(&cmd)
}
}
func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) {
maxTS := time.Now().Add(1 * time.Hour)
log := zerolog.Ctx(ctx)
var roomCount int
const BatchSize = 100
for {
rooms, err := gmx.Client.DB.Room.GetBySortTS(ctx, maxTS, BatchSize)
if err != nil {
if ctx.Err() == nil {
log.Err(err).Msg("Failed to get initial rooms to send to client")
}
return
}
roomCount += len(rooms)
payload := hicli.SyncComplete{
Rooms: make(map[id.RoomID]*hicli.SyncRoom, len(rooms)-1),
}
for _, room := range rooms {
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
break
}
maxTS = room.SortingTimestamp.Time
syncRoom := &hicli.SyncRoom{
Meta: room,
Events: make([]*database.Event, 0, 2),
Timeline: make([]database.TimelineRowTuple, 0),
}
payload.Rooms[room.ID] = syncRoom
if room.PreviewEventRowID != 0 {
previewEvent, err := gmx.Client.DB.Event.GetByRowID(ctx, room.PreviewEventRowID)
if err != nil {
log.Err(err).Msg("Failed to get preview event for room")
return
} else if previewEvent != nil {
syncRoom.Events = append(syncRoom.Events, previewEvent)
}
if previewEvent != nil && previewEvent.LastEditRowID != nil {
lastEdit, err := gmx.Client.DB.Event.GetByRowID(ctx, *previewEvent.LastEditRowID)
if err != nil {
log.Err(err).Msg("Failed to get last edit for preview event")
return
} else if lastEdit != nil {
syncRoom.Events = append(syncRoom.Events, lastEdit)
}
}
}
}
marshaledPayload, err := json.Marshal(&payload)
if err != nil {
log.Err(err).Msg("Failed to marshal initial rooms to send to client")
return
}
err = writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "sync_complete",
RequestID: 0,
Data: marshaledPayload,
})
if err != nil {
log.Err(err).Msg("Failed to send initial rooms to client")
return
}
if len(rooms) < BatchSize {
break
}
}
log.Info().Int("room_count", roomCount).Msg("Sent initial rooms to client")
}