mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-18 17:53:42 -05:00
web: init
This commit is contained in:
parent
4767def4b5
commit
1a359f9793
40 changed files with 5091 additions and 0 deletions
|
@ -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
4
.gitignore
vendored
|
@ -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
28
go.mod
|
@ -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
62
go.sum
|
@ -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
272
gomuks.go
Normal 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
127
main.go
Normal 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
182
media.go
Normal 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
9
web/.gitignore
vendored
Normal 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
66
web/eslint.config.js
Normal 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
26
web/frontend.go
Normal 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
13
web/index.html
Normal 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
2585
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
web/package.json
Normal file
35
web/package.json
Normal 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
BIN
web/public/gomuks.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
0
web/src/App.css
Normal file
0
web/src/App.css
Normal file
68
web/src/App.tsx
Normal file
68
web/src/App.tsx
Normal 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
7
web/src/MainScreen.css
Normal 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
36
web/src/MainScreen.tsx
Normal 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
50
web/src/RoomList.css
Normal 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
101
web/src/RoomList.tsx
Normal 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
3
web/src/RoomView.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
div.room-view {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
38
web/src/RoomView.tsx
Normal file
38
web/src/RoomView.tsx
Normal 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
|
0
web/src/TimelineEvent.css
Normal file
0
web/src/TimelineEvent.css
Normal file
39
web/src/TimelineEvent.tsx
Normal file
39
web/src/TimelineEvent.tsx
Normal 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>
|
||||||
|
|
||||||
|
<code>{evt.sender}</code>
|
||||||
|
|
||||||
|
{body ?? <code>{JSON.stringify(evt.decrypted ?? evt.content, null, " ")}</code>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimelineEvent
|
81
web/src/client.ts
Normal file
81
web/src/client.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
79
web/src/eventdispatcher.ts
Normal file
79
web/src/eventdispatcher.ts
Normal 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
96
web/src/hievents.ts
Normal 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
125
web/src/hitypes.ts
Normal 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
32
web/src/index.css
Normal 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;
|
||||||
|
}
|
66
web/src/login/LoginScreen.css
Normal file
66
web/src/login/LoginScreen.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
87
web/src/login/LoginScreen.tsx
Normal file
87
web/src/login/LoginScreen.tsx
Normal 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>
|
||||||
|
}
|
52
web/src/login/VerificationScreen.tsx
Normal file
52
web/src/login/VerificationScreen.tsx
Normal 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
2
web/src/login/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export { LoginScreen } from "./LoginScreen.tsx"
|
||||||
|
export { VerificationScreen } from "./VerificationScreen.tsx"
|
25
web/src/main.tsx
Normal file
25
web/src/main.tsx
Normal 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
39
web/src/rpc.ts
Normal 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
196
web/src/statestore.ts
Normal 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
147
web/src/wsclient.ts
Normal 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
28
web/tsconfig.json
Normal 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
17
web/vite.config.ts
Normal 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
267
websocket.go
Normal 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")
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue