Merge remote-tracking branch 'origin/main' into fix/security-hardening-review

This commit is contained in:
J3vb
2026-07-19 08:09:56 +02:00
49 changed files with 622 additions and 535 deletions
+6 -1
View File
@@ -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 ; \
)
+1 -1
View File
@@ -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
View File
@@ -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,
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+1
View File
@@ -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 (
+1
View File
@@ -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 (
+1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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 (
+1
View File
@@ -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
View File
@@ -1 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFEMUUzM0FDNTBCMTFCQzIKUldUQ0c3RlFyRE1lSFUvK1M1Wk1PZFcwVmJMMnZLc0o3TThjSnNVZEY3NDFaVkxPekUyemJRVzAK
+16 -1
View File
@@ -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 {
+35
View File
@@ -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")
}
}
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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)
+1
View File
@@ -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 (
+3 -2
View File
@@ -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
View File
@@ -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)
}
}
+1
View File
@@ -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 (
+1 -1
View File
@@ -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
View File
@@ -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()
+6 -1
View File
@@ -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())
}
-14
View File
@@ -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
View File
@@ -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()