mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Merge remote-tracking branch 'origin/main' into fix/security-hardening-review
This commit is contained in:
+6
-1
@@ -17,9 +17,14 @@ sqlc-install:
|
||||
sqlc-generate:
|
||||
sqlc generate
|
||||
|
||||
# Verify only db/dbgen: the committed db/pgdbgen files carry hand-added
|
||||
# `//go:build postgres` tags that `sqlc generate` strips, so a pgdbgen diff
|
||||
# is expected noise. pgdbgen is scheduled for removal with the Postgres
|
||||
# scaffolding; restore it after generating so verify leaves a clean tree.
|
||||
sqlc-verify:
|
||||
sqlc generate
|
||||
@git diff --exit-code db/dbgen db/pgdbgen || ( \
|
||||
@git checkout -- db/pgdbgen
|
||||
@git diff --exit-code db/dbgen || ( \
|
||||
echo "ERROR: generated sqlc output is stale. Run 'make sqlc-generate' and commit the result." ; \
|
||||
exit 1 ; \
|
||||
)
|
||||
|
||||
@@ -232,7 +232,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
|
||||
|
||||
// Admin panel: static files + REST API (Phase 6).
|
||||
// Restrict /admin to configured CIDRs (default: private networks only).
|
||||
u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord")
|
||||
u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo)
|
||||
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies))
|
||||
|
||||
+10
-1
@@ -79,8 +79,14 @@ type PluginsConfig struct {
|
||||
}
|
||||
|
||||
// GitHubConfig holds GitHub API settings for update checking.
|
||||
//
|
||||
// Owner/Repo point at the public releases repository. Server and client
|
||||
// update checks fetch release assets from this repo, so it must stay
|
||||
// publicly readable even when the source repository is private.
|
||||
type GitHubConfig struct {
|
||||
Token string `koanf:"token"`
|
||||
Owner string `koanf:"owner"`
|
||||
Repo string `koanf:"repo"`
|
||||
}
|
||||
|
||||
// VoiceConfig holds LiveKit server connection and voice quality settings.
|
||||
@@ -188,7 +194,10 @@ func defaults() Config {
|
||||
LiveKitURL: "ws://localhost:7880",
|
||||
Quality: "medium",
|
||||
},
|
||||
GitHub: GitHubConfig{},
|
||||
GitHub: GitHubConfig{
|
||||
Owner: "J3vb",
|
||||
Repo: "OwnCord-releases",
|
||||
},
|
||||
EventPersistence: EventPersistenceConfig{
|
||||
Enabled: true,
|
||||
RetentionHours: 24,
|
||||
|
||||
@@ -61,14 +61,14 @@ func (q *Queries) GetEventsSince(ctx context.Context, arg GetEventsSinceParams)
|
||||
}
|
||||
|
||||
const getMaxEventSeq = `-- name: GetMaxEventSeq :one
|
||||
SELECT COALESCE(MAX(seq), 0) FROM events
|
||||
SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) AS max_seq FROM events
|
||||
`
|
||||
|
||||
func (q *Queries) GetMaxEventSeq(ctx context.Context) (interface{}, error) {
|
||||
func (q *Queries) GetMaxEventSeq(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, getMaxEventSeq)
|
||||
var coalesce interface{}
|
||||
err := row.Scan(&coalesce)
|
||||
return coalesce, err
|
||||
var max_seq int64
|
||||
err := row.Scan(&max_seq)
|
||||
return max_seq, err
|
||||
}
|
||||
|
||||
const persistEvent = `-- name: PersistEvent :exec
|
||||
|
||||
@@ -61,7 +61,7 @@ type Querier interface {
|
||||
GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error)
|
||||
GetInvite(ctx context.Context, code string) (GetInviteRow, error)
|
||||
GetLatestMessageID(ctx context.Context, channelID int64) (interface{}, error)
|
||||
GetMaxEventSeq(ctx context.Context) (interface{}, error)
|
||||
GetMaxEventSeq(ctx context.Context) (int64, error)
|
||||
GetMessage(ctx context.Context, id int64) (Message, error)
|
||||
GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error)
|
||||
GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error)
|
||||
|
||||
+6
-6
@@ -30,8 +30,8 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/goleak v1.3.0
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/mod v0.34.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/mod v0.35.0
|
||||
modernc.org/sqlite v1.48.0
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
@@ -57,7 +57,7 @@ require (
|
||||
github.com/frostbyte73/core v0.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gammazero/deque v1.2.1 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
@@ -135,10 +135,10 @@ require (
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
|
||||
+14
-14
@@ -83,8 +83,8 @@ github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ
|
||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -356,21 +356,21 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -385,8 +385,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -398,16 +398,16 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Plugins get a per-plugin namespaced KV store backed by the PluginStore
|
||||
// rows in the events/plugin schema. Capacity caps and value-size caps are
|
||||
// enforced here so a misbehaving plugin can't fill the database.
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// A plugin that declares the `ui` capability ships HTML/CSS/JS assets and a
|
||||
// list of tabs. The host serves those assets at /api/v1/plugins/<name>/ui/...
|
||||
// and the Solid.js client bridge renders each tab inside a sandboxed iframe.
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//
|
||||
// Loader walks the directory, parses every plugin.json, and returns a slice
|
||||
// of foundPlugin records. The Registry then persists each into the store.
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//go:build !wazero
|
||||
|
||||
// Default build stub — TOML manifest parsing is not compiled in without -tags wazero.
|
||||
|
||||
package plugin
|
||||
|
||||
// tryLoadPluginTOML always reports "not present" in the default build so the
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// directory and persists each manifest into the PluginStore so admins can see
|
||||
// what is "installed", but the .wasm files are NOT executed. Calling
|
||||
// Dispatch() in the default build returns ErrRuntimeUnavailable.
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Default plugin runtime: no Wazero. Plugin manifests are still discovered,
|
||||
// persisted, and surfaced through the admin API, but `.wasm` modules are not
|
||||
// executed. To enable real WASM execution build with `-tags wazero`.
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
|
||||
@@ -1 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFEMUUzM0FDNTBCMTFCQzIKUldUQ0c3RlFyRE1lSFUvK1M1Wk1PZFcwVmJMMnZLc0o3TThjSnNVZEY3NDFaVkxPekUyemJRVzAK
|
||||
@@ -564,7 +564,7 @@ func (u *Updater) verifySignatureReader(reader io.Reader, signatureText []byte,
|
||||
return fmt.Errorf("reading file for signature verification: %w", err)
|
||||
}
|
||||
|
||||
normalizedSig := []byte(strings.TrimSpace(string(signatureText)))
|
||||
normalizedSig := normalizeSignatureText(signatureText)
|
||||
var parsedSig minisign.Signature
|
||||
if err := parsedSig.UnmarshalText(normalizedSig); err != nil {
|
||||
return fmt.Errorf("invalid update signature format: %w", err)
|
||||
@@ -576,6 +576,21 @@ func (u *Updater) verifySignatureReader(reader io.Reader, signatureText []byte,
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSignatureText returns the raw minisign signature document from
|
||||
// signatureText. `tauri signer sign` emits .sig files that are base64-wrapped
|
||||
// minisign documents (the same wrapping used for the pinned public key file);
|
||||
// raw minisign documents pass through unchanged.
|
||||
func normalizeSignatureText(signatureText []byte) []byte {
|
||||
trimmed := []byte(strings.TrimSpace(string(signatureText)))
|
||||
if bytes.HasPrefix(trimmed, []byte("untrusted comment:")) {
|
||||
return trimmed
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(string(trimmed)); err == nil {
|
||||
return []byte(strings.TrimSpace(string(decoded)))
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (u *Updater) serverSignaturePublicKey() (minisign.PublicKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(u.signingKeyText)
|
||||
if err != nil {
|
||||
|
||||
@@ -1130,3 +1130,38 @@ func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
newReq, _ := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
|
||||
return http.DefaultTransport.RoundTrip(newReq)
|
||||
}
|
||||
|
||||
// TestVerifySignature_TauriBase64WrappedFormat locks in support for the .sig
|
||||
// format that `tauri signer sign` produces in the release pipeline: a
|
||||
// base64-wrapped minisign document. Raw minisign documents must keep working.
|
||||
func TestVerifySignature_TauriBase64WrappedFormat(t *testing.T) {
|
||||
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
|
||||
content := []byte("tauri wrapped signature test")
|
||||
rawSig := signTestAsset(t, privateKey, content)
|
||||
wrappedSig := []byte(base64.StdEncoding.EncodeToString(rawSig))
|
||||
|
||||
path := filepath.Join(t.TempDir(), "chatserver.exe")
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatalf("writing test asset: %v", err)
|
||||
}
|
||||
|
||||
if err := u.VerifySignature(path, wrappedSig); err != nil {
|
||||
t.Errorf("base64-wrapped tauri signature should verify: %v", err)
|
||||
}
|
||||
if err := u.VerifySignature(path, rawSig); err != nil {
|
||||
t.Errorf("raw minisign signature should verify: %v", err)
|
||||
}
|
||||
// Valid base64 that decodes to garbage must fail cleanly, not verify.
|
||||
garbage := []byte(base64.StdEncoding.EncodeToString([]byte("not a signature")))
|
||||
if err := u.VerifySignature(path, garbage); err == nil {
|
||||
t.Error("garbage base64 signature should fail verification")
|
||||
}
|
||||
// Wrapped signature over different content must fail verification.
|
||||
otherPath := filepath.Join(t.TempDir(), "other.bin")
|
||||
if err := os.WriteFile(otherPath, []byte("tampered"), 0o600); err != nil {
|
||||
t.Fatalf("writing tampered asset: %v", err)
|
||||
}
|
||||
if err := u.VerifySignature(otherPath, wrappedSig); err == nil {
|
||||
t.Error("wrapped signature must not verify tampered content")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestDM_ChatEdit_ParticipantCanEdit(t *testing.T) {
|
||||
hub.HandleMessageForTest(cAlice, dmChatEditMsg(msgID, "edited"))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Alice should receive the chat_edited broadcast (via broadcastToDMParticipants).
|
||||
// Alice should receive the chat_edited broadcast (via the sequenced DM event path).
|
||||
msgs := dmDrainAll(sendAlice)
|
||||
edited := dmFindMsgType(msgs, "chat_edited")
|
||||
if edited == nil {
|
||||
|
||||
@@ -34,6 +34,7 @@ func newEmitTestHub() *Hub {
|
||||
pubsub: NewPubSub(),
|
||||
replayBuf: NewEventRingBuffer(100),
|
||||
voiceKeyHolders: make(map[int64]int64),
|
||||
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +186,7 @@ func TestEmitEvents_ExcludeSenderEvent(t *testing.T) {
|
||||
|
||||
h.EmitEvents(events)
|
||||
|
||||
// broadcastExclude is synchronous — check immediately.
|
||||
// broadcastExcludeLow is synchronous — check immediately.
|
||||
senderMsgs := drainChan(sendSender, 50*time.Millisecond)
|
||||
otherMsgs := drainChan(sendOther, 50*time.Millisecond)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// StartEventPruner runs a background goroutine that deletes events older than
|
||||
// the configured retention window. It is the bounded-storage half of the
|
||||
// event persistence design: the persister appends, the pruner trims.
|
||||
|
||||
package ws
|
||||
|
||||
import (
|
||||
|
||||
@@ -93,8 +93,9 @@ func (p *LiveKitProcess) SetProcessStoppedForTest() {
|
||||
// NewHubForTest creates a minimal Hub with no DB or limiter for webhook testing.
|
||||
func NewHubForTest() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[int64]*Client),
|
||||
pubsub: NewPubSub(),
|
||||
clients: make(map[int64]*Client),
|
||||
pubsub: NewPubSub(),
|
||||
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-62
@@ -5,30 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// Rate limit windows.
|
||||
const (
|
||||
chatRateLimit = 10
|
||||
chatWindow = time.Second
|
||||
typingRateLimit = 1
|
||||
typingWindow = 3 * time.Second
|
||||
presenceRateLimit = 1
|
||||
presenceWindow = 10 * time.Second
|
||||
reactionRateLimit = 5
|
||||
reactionWindow = time.Second
|
||||
)
|
||||
|
||||
// maxMessageLen is the maximum allowed message length in runes (Unicode code points).
|
||||
const maxMessageLen = 4000
|
||||
|
||||
var sanitizer = bluemonday.StrictPolicy()
|
||||
|
||||
// HandleMessageForTest dispatches a raw WebSocket message from client c.
|
||||
// Exported so ws_test package can invoke it directly without a real connection.
|
||||
func (h *Hub) HandleMessageForTest(c *Client, raw []byte) {
|
||||
@@ -226,21 +207,11 @@ func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLab
|
||||
return false
|
||||
}
|
||||
|
||||
// broadcastExclude sends a message to all clients in the sender's channel
|
||||
// EXCEPT the sender. Unlike hub.BroadcastToChannel, messages sent via this
|
||||
// function are NOT stored in the replay ring buffer — they are ephemeral.
|
||||
// This is correct for typing indicators but would be incorrect for messages
|
||||
// that should survive reconnection replay.
|
||||
func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) {
|
||||
if channelID == 0 {
|
||||
h.pubsub.Publish(TopicGlobal, msg, excludeUserID)
|
||||
return
|
||||
}
|
||||
h.pubsub.Publish(ChannelTopic(channelID), msg, excludeUserID)
|
||||
}
|
||||
|
||||
// broadcastExcludeLow is like broadcastExclude but at low priority.
|
||||
// Used for typing indicators — dropped on overflow instead of disconnecting.
|
||||
// broadcastExcludeLow sends a message at low priority to all clients in the
|
||||
// sender's channel EXCEPT the sender. Messages sent via this function are NOT
|
||||
// stored in the replay ring buffer — they are ephemeral. This is correct for
|
||||
// typing indicators (dropped on overflow instead of disconnecting) but would
|
||||
// be incorrect for messages that should survive reconnection replay.
|
||||
func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
|
||||
if channelID == 0 {
|
||||
h.pubsub.PublishLow(TopicGlobal, msg, excludeUserID)
|
||||
@@ -249,31 +220,3 @@ func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
|
||||
h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID)
|
||||
}
|
||||
|
||||
// broadcastToDMParticipants sends a message to all participants of a DM channel
|
||||
// while preserving DM semantics (delivery is by participant, not channel focus).
|
||||
// Unlike broadcastToDMParticipantsExclude, this path is sequenced and replayable.
|
||||
func (h *Hub) broadcastToDMParticipants(channelID int64, msg []byte) {
|
||||
participantIDs, err := h.db.GetDMParticipantIDs(channelID)
|
||||
if err != nil {
|
||||
slog.Error("broadcastToDMParticipants GetDMParticipantIDs", "err", err, "channel_id", channelID)
|
||||
return
|
||||
}
|
||||
h.sendSequencedToUsers(channelID, participantIDs, msg)
|
||||
}
|
||||
|
||||
// broadcastToDMParticipantsExclude sends a message to all participants of a DM
|
||||
// channel EXCEPT the specified user. Used for ephemeral events like typing
|
||||
// indicators where echoing back to the sender is undesirable.
|
||||
func (h *Hub) broadcastToDMParticipantsExclude(channelID, excludeUserID int64, msg []byte) {
|
||||
participantIDs, err := h.db.GetDMParticipantIDs(channelID)
|
||||
if err != nil {
|
||||
slog.Error("broadcastToDMParticipantsExclude GetDMParticipantIDs", "err", err, "channel_id", channelID)
|
||||
return
|
||||
}
|
||||
for _, pid := range participantIDs {
|
||||
if pid == excludeUserID {
|
||||
continue
|
||||
}
|
||||
h.SendToUser(pid, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// plugin returns a Reply, it is sent only to the invoking client (ephemeral).
|
||||
// If the plugin returns a Broadcast string, it is broadcast to the channel
|
||||
// only after verifying the invoking client holds SEND_MESSAGES permission.
|
||||
|
||||
package ws
|
||||
|
||||
import (
|
||||
|
||||
@@ -1696,7 +1696,7 @@ func TestTyping_RateLimited_SilentlyDropped(t *testing.T) {
|
||||
|
||||
// TestBroadcastExclude_SendsToOthersNotSelf verifies that broadcastExclude
|
||||
// delivers to all channel members except the excluded user.
|
||||
// This is exercised indirectly via typing_start (which calls broadcastExclude).
|
||||
// This is exercised indirectly via typing_start (which calls broadcastExcludeLow).
|
||||
func TestBroadcastExclude_SendsToOthersNotSelf(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
chID := seedTestChannel(t, database, "excl-chan1")
|
||||
|
||||
+3
-20
@@ -555,26 +555,9 @@ func (h *Hub) BroadcastToAllLow(msg []byte) {
|
||||
h.pubsub.PublishGlobalLow(msg)
|
||||
}
|
||||
|
||||
// sendSequencedToUsers stamps msg with a monotonic seq, stores it in the replay
|
||||
// buffer under channelID, and fanouts the wrapped payload to the provided users.
|
||||
func (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) {
|
||||
h.seqMu.Lock()
|
||||
defer h.seqMu.Unlock()
|
||||
|
||||
seq := h.nextSeq()
|
||||
wrapped := wrapWithSeq(msg, seq)
|
||||
|
||||
// Store DM event for reconnect replay; filtering is channel-based and uses
|
||||
// allowed channel IDs computed at auth time (including open DMs).
|
||||
h.replayBuf.Push(seq, channelID, wrapped)
|
||||
h.persistEvent(seq, channelID, wrapped)
|
||||
|
||||
for _, userID := range userIDs {
|
||||
h.SendToUser(userID, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
// sendSequencedToUsersHigh is like sendSequencedToUsers but uses high-priority delivery.
|
||||
// sendSequencedToUsersHigh stamps msg with a monotonic seq, stores it in the
|
||||
// replay buffer under channelID, and fans the wrapped payload out to the
|
||||
// provided users with high-priority delivery.
|
||||
func (h *Hub) sendSequencedToUsersHigh(channelID int64, userIDs []int64, msg []byte) {
|
||||
h.seqMu.Lock()
|
||||
defer h.seqMu.Unlock()
|
||||
|
||||
@@ -489,7 +489,12 @@ func TestHub_ConcurrentRegisterUnregister(t *testing.T) {
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// The hub loop drains register/unregister asynchronously; poll instead of
|
||||
// a fixed sleep, which flakes under -race on slow runners.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for hub.ClientCount() != 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients after concurrent churn, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package ws
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -217,16 +216,3 @@ func (ps *PubSub) TopicsForClient(userID int64) []Topic {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// debugDump logs the current subscription state. For development use only.
|
||||
func (ps *PubSub) debugDump() {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for topic, subs := range ps.topics {
|
||||
ids := make([]int64, 0, len(subs))
|
||||
for uid := range subs {
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
slog.Debug("pubsub: topic", "topic", string(topic), "subscribers", ids)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -411,9 +411,13 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) {
|
||||
voiceChID := c.getVoiceChID()
|
||||
replaced := hub.unregisterNow(c)
|
||||
if c.user != nil {
|
||||
// Always clean up voice state — LeaveVoiceChannelIfMatch uses a
|
||||
// join_token guard so it won't remove a replacement client's session.
|
||||
if voiceChID != 0 {
|
||||
// Clean up voice state only when this was the user's final
|
||||
// connection. A replacement connection owns the (transferred)
|
||||
// voice session, and the join_token guard cannot tell the
|
||||
// difference — the transfer keeps the same joined_at — so
|
||||
// cleaning here would delete the replacement's DB row whenever
|
||||
// teardown snapshots voiceChID before the transfer zeroes it.
|
||||
if voiceChID != 0 && !replaced {
|
||||
hub.handleVoiceLeave(ctx, c)
|
||||
}
|
||||
c.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user