diff --git a/.editorconfig b/.editorconfig index 9fa8d76..1d1f4ed 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,7 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +max_line_length = 120 [.gitlab-ci.yml] indent_size = 2 diff --git a/.gitignore b/.gitignore index 8bf7688..123bc5c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,13 @@ target/ .tmp/ gomuks +start +run *.exe *.deb coverage.out coverage.html deb/usr *.prof +*.db* +*.log diff --git a/go.mod b/go.mod index 3d0c041..fb82b31 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,31 @@ module go.mau.fi/gomuks go 1.23.0 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 +) diff --git a/go.sum b/go.sum index e69de29..2b1713b 100644 --- a/go.sum +++ b/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= diff --git a/gomuks.go b/gomuks.go new file mode 100644 index 0000000..ccdcd07 --- /dev/null +++ b/gomuks.go @@ -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 . + +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) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3f88180 --- /dev/null +++ b/main.go @@ -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 . + +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 +} diff --git a/media.go b/media.go new file mode 100644 index 0000000..4924eb2 --- /dev/null +++ b/media.go @@ -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) +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..65e4336 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,9 @@ +logs +*.log +npm-debug.log* +tsconfig.tsbuildinfo + +node_modules +dist +dist-ssr +*.local diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..4bedcd0 --- /dev/null +++ b/web/eslint.config.js @@ -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", + }, + }, +) diff --git a/web/frontend.go b/web/frontend.go new file mode 100644 index 0000000..3ef52d8 --- /dev/null +++ b/web/frontend.go @@ -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 . + +package web + +import ( + "embed" +) + +//go:generate npm install --legacy-peer-deps +//go:generate npm run build +//go:embed dist +var Frontend embed.FS diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0127288 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + gomuks web + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..eef6cfc --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2585 @@ +{ + "name": "gomuks-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gomuks-web", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "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" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.26.tgz", + "integrity": "sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.12" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.7.26", + "@swc/core-darwin-x64": "1.7.26", + "@swc/core-linux-arm-gnueabihf": "1.7.26", + "@swc/core-linux-arm64-gnu": "1.7.26", + "@swc/core-linux-arm64-musl": "1.7.26", + "@swc/core-linux-x64-gnu": "1.7.26", + "@swc/core-linux-x64-musl": "1.7.26", + "@swc/core-win32-arm64-msvc": "1.7.26", + "@swc/core-win32-ia32-msvc": "1.7.26", + "@swc/core-win32-x64-msvc": "1.7.26" + }, + "peerDependencies": { + "@swc/helpers": "*" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.26.tgz", + "integrity": "sha512-FF3CRYTg6a7ZVW4yT9mesxoVVZTrcSWtmZhxKCYJX9brH4CS/7PRPjAKNk6kzWgWuRoglP7hkjQcd6EpMcZEAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.26.tgz", + "integrity": "sha512-az3cibZdsay2HNKmc4bjf62QVukuiMRh5sfM5kHR/JMTrLyS6vSw7Ihs3UTkZjUxkLTT8ro54LI6sV6sUQUbLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.26.tgz", + "integrity": "sha512-VYPFVJDO5zT5U3RpCdHE5v1gz4mmR8BfHecUZTmD2v1JeFY6fv9KArJUpjrHEEsjK/ucXkQFmJ0jaiWXmpOV9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.26.tgz", + "integrity": "sha512-YKevOV7abpjcAzXrhsl+W48Z9mZvgoVs2eP5nY+uoMAdP2b3GxC0Df1Co0I90o2lkzO4jYBpTMcZlmUXLdXn+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.26.tgz", + "integrity": "sha512-3w8iZICMkQQON0uIcvz7+Q1MPOW6hJ4O5ETjA0LSP/tuKqx30hIniCGOgPDnv3UTMruLUnQbtBwVCZTBKR3Rkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.26.tgz", + "integrity": "sha512-c+pp9Zkk2lqb06bNGkR2Looxrs7FtGDMA4/aHjZcCqATgp348hOKH5WPvNLBl+yPrISuWjbKDVn3NgAvfvpH4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.26.tgz", + "integrity": "sha512-PgtyfHBF6xG87dUSSdTJHwZ3/8vWZfNIXQV2GlwEpslrOkGqy+WaiiyE7Of7z9AvDILfBBBcJvJ/r8u980wAfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.26.tgz", + "integrity": "sha512-9TNXPIJqFynlAOrRD6tUQjMq7KApSklK3R/tXgIxc7Qx+lWu8hlDQ/kVPLpU7PWvMMwC/3hKBW+p5f+Tms1hmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.26.tgz", + "integrity": "sha512-9YngxNcG3177GYdsTum4V98Re+TlCeJEP4kEwEg9EagT5s3YejYdKwVAkAsJszzkXuyRDdnHUpYbTrPG6FiXrQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.26.tgz", + "integrity": "sha512-VR+hzg9XqucgLjXxA13MtV5O3C0bK0ywtLIBw/+a+O+Oc6mxFWHtdUeXDbIi5AiPbn0fjgVJMqYnyjGyyX8u0w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true + }, + "node_modules/@swc/types": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", + "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "dev": true, + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/react": { + "name": "types-react", + "version": "19.0.0-rc.1", + "resolved": "https://registry.npmjs.org/types-react/-/types-react-19.0.0-rc.1.tgz", + "integrity": "sha512-RshndUfqTW6K3STLPis8BtAYCGOkMbtvYsi90gmVNDZBXUyUc5juf2PE9LfS/JmOlUIRO8cWTS/1MTnmhjDqyQ==", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "name": "types-react-dom", + "version": "19.0.0-rc.1", + "resolved": "https://registry.npmjs.org/types-react-dom/-/types-react-dom-19.0.0-rc.1.tgz", + "integrity": "sha512-VSLZJl8VXCD0fAWp7DUTFUDCcZ8DVXOQmjhJMD03odgeFmu14ZQJHCXeETm3BEAhJqfgJaFkLnGkQv88sRx0fQ==", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.8.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.1.tgz", + "integrity": "sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==", + "dev": true, + "dependencies": { + "@swc/core": "^1.7.26" + }, + "peerDependencies": { + "vite": "^4 || ^5" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", + "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0-rc-fb9a90fa48-20240614", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", + "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.12.tgz", + "integrity": "sha512-9neVjoGv20FwYtCP6CB1dzR1vr57ZDNOXst21wd2xJ/cTlM2xLq0GWVlSNTdMn/4BtP6cHYBMCSp1wFBJ9jBsg==", + "dev": true, + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.10.0.tgz", + "integrity": "sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.0.0-rc-0751fac7-20241002", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0-rc-0751fac7-20241002.tgz", + "integrity": "sha512-qbwgll2JbsH16OHaNhbUtS55eyjgf9f1i682crOjcHLns9pyFvPjeiJIztBnhAk9GVS26ZiI00O2oA8AV/FGDA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0-rc-0751fac7-20241002", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-0751fac7-20241002.tgz", + "integrity": "sha512-RpPpmpjWsKI4ThsphySlyFMfXx5Fof8Q8k9oPKqxEAR5Yq20DbQiY/fj+QYQipI0p3zptRSnovaRAKFnmidHKA==", + "dependencies": { + "scheduler": "0.25.0-rc-0751fac7-20241002" + }, + "peerDependencies": { + "react": "19.0.0-rc-0751fac7-20241002" + } + }, + "node_modules/react-spinners": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.14.1.tgz", + "integrity": "sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.25.0-rc-0751fac7-20241002", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-0751fac7-20241002.tgz", + "integrity": "sha512-1aCByRIndzfrl7Qh2Uh/hhOyXK20pCre8XwR1RKOKK9FvWAt8iukv3Xtpf04yJajXW6zRUfSv6dRZFL9E0ZMuw==" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.8.0.tgz", + "integrity": "sha512-BjIT/VwJ8+0rVO01ZQ2ZVnjE1svFBiRczcpr1t1Yxt7sT25VSbPfrJtDsQ8uQTy2pilX5nI9gwxhUyLULNentw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.8.0", + "@typescript-eslint/parser": "8.8.0", + "@typescript-eslint/utils": "8.8.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..edf1d93 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/gomuks.png b/web/public/gomuks.png new file mode 100644 index 0000000..645df4f Binary files /dev/null and b/web/public/gomuks.png differ diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..96360ad --- /dev/null +++ b/web/src/App.tsx @@ -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 . +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() + const [clientState, setClientState] = useState() + 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
+ error {`${connState.error}`} :( +
+ } else if (!connState?.connected || !clientState) { + const msg = connState?.connected ? + "Waiting for client state..." : "Connecting to backend..." + return
+ + {msg} +
+ } else if (!clientState.is_logged_in) { + return + } else if (!clientState.is_verified) { + return + } else { + return + } +} + +export default App diff --git a/web/src/MainScreen.css b/web/src/MainScreen.css new file mode 100644 index 0000000..3fe4e5b --- /dev/null +++ b/web/src/MainScreen.css @@ -0,0 +1,7 @@ +main.matrix-main { + position: fixed; + inset: 0; + + display: grid; + grid-template: "roomlist roomview" 1fr / 300px 1fr; +} diff --git a/web/src/MainScreen.tsx b/web/src/MainScreen.tsx new file mode 100644 index 0000000..f8d3643 --- /dev/null +++ b/web/src/MainScreen.tsx @@ -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 . +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(null) + const activeRoom = activeRoomID && client.store.rooms.get(activeRoomID) + return
+ + {activeRoom && } +
+} + +export default MainScreen diff --git a/web/src/RoomList.css b/web/src/RoomList.css new file mode 100644 index 0000000..19fb4e9 --- /dev/null +++ b/web/src/RoomList.css @@ -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; + } + } + } +} diff --git a/web/src/RoomList.tsx b/web/src/RoomList.tsx new file mode 100644 index 0000000..b15addb --- /dev/null +++ b/web/src/RoomList.tsx @@ -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 . +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
+ {reverseMap(roomList, room => + , + )} +
+} + +function reverseMap(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
+
+ +
+
+
{room.name}
+ {previewText &&
{previewText}
} +
+
+} + +export default RoomList diff --git a/web/src/RoomView.css b/web/src/RoomView.css new file mode 100644 index 0000000..445b64a --- /dev/null +++ b/web/src/RoomView.css @@ -0,0 +1,3 @@ +div.room-view { + overflow-y: scroll; +} diff --git a/web/src/RoomView.tsx b/web/src/RoomView.tsx new file mode 100644 index 0000000..fe8ff44 --- /dev/null +++ b/web/src/RoomView.tsx @@ -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 . +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
+ {roomMeta.room_id} + {timeline.map(entry => )} +
+} + +export default RoomView diff --git a/web/src/TimelineEvent.css b/web/src/TimelineEvent.css new file mode 100644 index 0000000..e69de29 diff --git a/web/src/TimelineEvent.tsx b/web/src/TimelineEvent.tsx new file mode 100644 index 0000000..91adca4 --- /dev/null +++ b/web/src/TimelineEvent.tsx @@ -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 . +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
+ {evt.decrypted_type ?? evt.type} +   + {evt.sender} +   + {body ?? {JSON.stringify(evt.decrypted ?? evt.content, null, " ")}} +
+} + +export default TimelineEvent diff --git a/web/src/client.ts b/web/src/client.ts new file mode 100644 index 0000000..0f1525e --- /dev/null +++ b/web/src/client.ts @@ -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 . +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() + 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(command: string, data: Req): Promise { + return this.rpc.request(command, data) + } + + sendMessage(room_id: RoomID, event_type: EventType, content: Record): Promise { + return this.request("send_message", { room_id, event_type, content }) + } + + ensureGroupSessionShared(room_id: RoomID): Promise { + return this.request("ensure_group_session_shared", { room_id }) + } + + getEvent(room_id: RoomID, event_id: EventID): Promise { + return this.request("get_event", { room_id, event_id }) + } + + getEventsByRowIDs(row_ids: EventRowID[]): Promise { + return this.request("get_events_by_row_ids", { row_ids }) + } + + paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise { + return this.request("paginate", { room_id, max_timeline_id, limit }) + } + + paginateServer(room_id: RoomID, limit: number): Promise { + return this.request("paginate_server", { room_id, limit }) + } + + discoverHomeserver(user_id: UserID): Promise { + return this.request("discover_homeserver", { user_id }) + } + + login(homeserver_url: string, username: string, password: string): Promise { + return this.request("login", { homeserver_url, username, password }) + } + + verify(recovery_key: string): Promise { + return this.request("verify", { recovery_key }) + } +} diff --git a/web/src/eventdispatcher.ts b/web/src/eventdispatcher.ts new file mode 100644 index 0000000..9daae59 --- /dev/null +++ b/web/src/eventdispatcher.ts @@ -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 . +import { useEffect, useState } from "react" + +export function useEventAsState(dispatcher?: EventDispatcher): T | null { + const [state, setState] = useState(null) + useEffect(() => dispatcher && dispatcher.listen(setState), [dispatcher]) + return state +} + +export function useNonNullEventAsState(dispatcher: NonNullCachedEventDispatcher): T { + const [state, setState] = useState(dispatcher.current) + useEffect(() => dispatcher.listen(setState), [dispatcher]) + return state +} + +export class EventDispatcher { + #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 extends EventDispatcher { + 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 extends CachedEventDispatcher { + current: T + + constructor(cache: T) { + super(cache) + this.current = cache + } +} diff --git a/web/src/hievents.ts b/web/src/hievents.ts new file mode 100644 index 0000000..cbd6ea5 --- /dev/null +++ b/web/src/hievents.ts @@ -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 . +import { + DBEvent, + DBRoom, + DeviceID, + EventRowID, + RoomID, + TimelineRowTuple, + UserID, +} from "./hitypes.ts" + +export interface RPCCommand { + command: string + request_id: number + data: T +} + +export interface TypingEventData { + room_id: RoomID + user_ids: UserID[] +} + +export interface TypingEvent extends RPCCommand { + command: "typing" +} + +export interface SendCompleteData { + event: DBEvent + error: string | null +} + +export interface SendCompleteEvent extends RPCCommand { + command: "send_complete" +} + +export interface EventsDecryptedData { + room_id: RoomID + preview_event_rowid?: EventRowID + events: DBEvent[] +} + +export interface EventsDecryptedEvent extends RPCCommand { + command: "events_decrypted" +} + +export interface SyncRoom { + meta: DBRoom + timeline: TimelineRowTuple[] + events: DBEvent[] + reset: boolean +} + +export interface SyncCompleteData { + rooms: Record +} + +export interface SyncCompleteEvent extends RPCCommand { + 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 { + command: "client_state" +} + +export type RPCEvent = + ClientStateEvent | + TypingEvent | + SendCompleteEvent | + EventsDecryptedEvent | + SyncCompleteEvent diff --git a/web/src/hitypes.ts b/web/src/hitypes.ts new file mode 100644 index 0000000..190aebe --- /dev/null +++ b/web/src/hitypes.ts @@ -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 . +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 + 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 + } +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..8d46438 --- /dev/null +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/login/LoginScreen.css b/web/src/login/LoginScreen.css new file mode 100644 index 0000000..7f67ffd --- /dev/null +++ b/web/src/login/LoginScreen.css @@ -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; + } +} diff --git a/web/src/login/LoginScreen.tsx b/web/src/login/LoginScreen.tsx new file mode 100644 index 0000000..a85ad24 --- /dev/null +++ b/web/src/login/LoginScreen.tsx @@ -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 . +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
+

gomuks web

+
+ setUsername(evt.target.value)} + /> + setPassword(evt.target.value)} + /> + setHomeserverURL(evt.target.value)} + /> + +
+ {error &&
+ {error} +
} +
+} diff --git a/web/src/login/VerificationScreen.tsx b/web/src/login/VerificationScreen.tsx new file mode 100644 index 0000000..2333e0f --- /dev/null +++ b/web/src/login/VerificationScreen.tsx @@ -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 . +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
+

gomuks web

+
+

Successfully logged in as {clientState.user_id}

+ setRecoveryKey(evt.target.value)} + /> + +
+ {error &&
+ {error} +
} +
+} diff --git a/web/src/login/index.ts b/web/src/login/index.ts new file mode 100644 index 0000000..18e4e84 --- /dev/null +++ b/web/src/login/index.ts @@ -0,0 +1,2 @@ +export { LoginScreen } from "./LoginScreen.tsx" +export { VerificationScreen } from "./VerificationScreen.tsx" diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..173a9d9 --- /dev/null +++ b/web/src/main.tsx @@ -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 . +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import App from "./App.tsx" +import "./index.css" + +createRoot(document.getElementById("root")!).render( + + + , +) diff --git a/web/src/rpc.ts b/web/src/rpc.ts new file mode 100644 index 0000000..95ec284 --- /dev/null +++ b/web/src/rpc.ts @@ -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 . +import { RPCEvent } from "./hievents.ts" +import { EventDispatcher } from "./eventdispatcher.ts" + +export class CancellablePromise extends Promise { + constructor( + executor: (resolve: (value: T) => void, reject: (reason?: Error) => void) => void, + readonly cancel: (reason: string) => void, + ) { + super(executor) + } +} + +export interface RPCClient { + connect: EventDispatcher + event: EventDispatcher + start(): void + stop(): void + request(command: string, data: Req): CancellablePromise +} + +export interface ConnectionEvent { + connected: boolean + error: Error | null +} diff --git a/web/src/statestore.ts b/web/src/statestore.ts new file mode 100644 index 0000000..62553e5 --- /dev/null +++ b/web/src/statestore.ts @@ -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 . +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(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 + readonly timeline = new NonNullCachedEventDispatcher([]) + readonly eventsByRowID: Map = new Map() + readonly eventsByID: Map = 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 = new Map() + readonly roomList = new NonNullCachedEventDispatcher([]) + + #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() + 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) + } + } + } +} diff --git a/web/src/wsclient.ts b/web/src/wsclient.ts new file mode 100644 index 0000000..279560d --- /dev/null +++ b/web/src/wsclient.ts @@ -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 . +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 = new CachedEventDispatcher() + readonly event: EventDispatcher = new EventDispatcher() + readonly #pendingRequests: Map 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(command: string, data: Req): CancellablePromise { + 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 + 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() + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e618c22 --- /dev/null +++ b/web/tsconfig.json @@ -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" + ] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..941d643 --- /dev/null +++ b/web/vite.config.ts @@ -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", + } + }, + }, +}) diff --git a/websocket.go b/websocket.go new file mode 100644 index 0000000..38e7e1e --- /dev/null +++ b/websocket.go @@ -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 . + +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") +}