diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 675f8991..d83e023a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,24 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Patch auto-generated Tauri TypeScript bindings + working-directory: Client/tauri-client/ + # tauri-typegen generates an Event type that is intentionally unused in app code. + # Rename it to _Event so @typescript-eslint/no-unused-vars does not fail. + run: | + node -e " + const fs = require('fs'); + const p = 'src/generated/events.ts'; + if (fs.existsSync(p)) { + let c = fs.readFileSync(p, 'utf8'); + c = c.replace(/\btype Event\b/g, 'type _Event').replace(/\binterface Event\b/g, 'interface _Event'); + fs.writeFileSync(p, c); + console.log('Patched: renamed Event -> _Event in generated/events.ts'); + } else { + console.log('src/generated/events.ts not found, skipping patch.'); + } + " + - name: Security audit (npm) run: npm audit --audit-level=high diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index 4c96b769..2cf95b7c 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -3,6 +3,7 @@ // All endpoints are mounted under the existing AdminIPRestrict group so they // inherit the same network ACL as the rest of the admin panel. Authentication // is handled by the admin handler's middleware before this handler runs. + package api import ( diff --git a/Server/plugin/host_commands.go b/Server/plugin/host_commands.go index 9bd104da..2cd9b026 100644 --- a/Server/plugin/host_commands.go +++ b/Server/plugin/host_commands.go @@ -3,6 +3,7 @@ // Plugins that declare the "commands" capability register one or more slash // commands at activation time. The WS command dispatcher (Server/ws/command.go) // calls Registry.DispatchCommand after exhausting its built-in command table. + package plugin import ( diff --git a/Server/plugin/host_events.go b/Server/plugin/host_events.go index 81f78bb8..2c3c887e 100644 --- a/Server/plugin/host_events.go +++ b/Server/plugin/host_events.go @@ -4,6 +4,7 @@ // At activation time the wazero-tagged build wires each subscription into // the WS pub/sub hub via Hub.Subscribe; the default build records the // subscription in-memory only. + package plugin import ( diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index a882f304..34056d50 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -4,6 +4,7 @@ // against PluginsConfig.HTTPAllowlist (host suffix match) before being sent. // The wazero-tagged build invokes this from the plugin's `host_http_request` // import; the default build exposes it for testing. + package plugin import ( diff --git a/Server/plugin/host_ui.go b/Server/plugin/host_ui.go index 97d1e97d..a4045a0f 100644 --- a/Server/plugin/host_ui.go +++ b/Server/plugin/host_ui.go @@ -80,7 +80,7 @@ func (r *Registry) AssetHandler(inst *Instance) http.Handler { // This runs on every request — cheap relative to the file read — // and closes the TOCTOU gap between install-time validation and // runtime serving. - info, lerr := os.Lstat(full) + info, lerr := os.Lstat(full) //nolint:gosec // path traversal blocked above: rel validated and cleaned if lerr != nil { http.NotFound(w, req) return @@ -93,7 +93,7 @@ func (r *Registry) AssetHandler(inst *Instance) http.Handler { http.Error(w, "forbidden", http.StatusForbidden) return } - f, openErr := os.Open(full) + f, openErr := os.Open(full) //nolint:gosec // path traversal blocked above: rel validated and cleaned if openErr != nil { http.NotFound(w, req) return diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 9daaed5c..3ecfafcb 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -65,7 +65,7 @@ type Instance struct { // module is the wazero compiled module in the wazero-tagged build, or // nil in the default build. - module any + module any //nolint:unused // assigned by wazero-tagged build } // UITabBinding is the public projection of a plugin's declared UI tab, @@ -280,7 +280,7 @@ func (r *Registry) InstallFromZip(ctx context.Context, zipBytes []byte) (string, cleanup() return "", oErr } - out, cErr := os.OpenFile(destAbs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640) + out, cErr := os.OpenFile(destAbs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if cErr != nil { _ = rc.Close() cleanup() diff --git a/Server/service/channel.go b/Server/service/channel.go index ff079a61..f29e4cb4 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -71,14 +71,14 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) } var visible []db.Channel - for _, ch := range all { - if ch.Type == "dm" { + for i := range all { + if all[i].Type == "dm" { continue } - o := overrides[ch.ID] + o := overrides[all[i].ID] effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) if effective&permissions.ReadMessages == permissions.ReadMessages { - visible = append(visible, ch) + visible = append(visible, all[i]) } } @@ -107,13 +107,13 @@ func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface ch, err := s.st.GetChannel(channelID) if err != nil || ch == nil { - return nil, nil // silent drop + return nil, nil //nolint:nilerr // typing indicators are best-effort; errors silently dropped } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) - if err != nil || !ok { - return nil, nil // silent drop + ok, dmErr := s.st.IsDMParticipant(userID, channelID) + if dmErr != nil || !ok { + return nil, nil //nolint:nilerr // typing indicators are best-effort; errors silently dropped } } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { return nil, nil // silent drop @@ -171,10 +171,8 @@ func (s *ChannelService) HandleChannelFocus(userID, channelID int64) (*db.Channe if err != nil || !ok { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - } else { - if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { - return nil, fmt.Errorf("%w: access denied", ErrForbidden) - } + } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + return nil, fmt.Errorf("%w: access denied", ErrForbidden) } // Mark channel as read. diff --git a/Server/service/message.go b/Server/service/message.go index 067b3453..3ea6ab49 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -435,10 +435,8 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b if dmErr != nil || !ok { return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) } - } else { - if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.AddReactions) { - return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden) - } + } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.AddReactions) { + return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden) } action := "add" @@ -493,10 +491,8 @@ func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) if err != nil || !ok { return nil, false, fmt.Errorf("%w: access denied", ErrNotFound) } - } else { - if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { - return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) - } + } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) } if limit <= 0 { @@ -645,18 +641,18 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) } var ids []int64 - for _, ch := range channels { - if ch.Type == "dm" { + for i := range channels { + if channels[i].Type == "dm" { continue } if isAdmin { - ids = append(ids, ch.ID) + ids = append(ids, channels[i].ID) continue } - o := overrides[ch.ID] + o := overrides[channels[i].ID] effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) if effective&permissions.ReadMessages == permissions.ReadMessages { - ids = append(ids, ch.ID) + ids = append(ids, channels[i].ID) } } diff --git a/Server/telemetry/metrics.go b/Server/telemetry/metrics.go index 799fa92c..5e70d4d5 100644 --- a/Server/telemetry/metrics.go +++ b/Server/telemetry/metrics.go @@ -5,6 +5,7 @@ // should cache the returned instrument in a struct field rather than calling // these helpers per request — they take a sync.RWMutex to read the global // provider and the cost adds up at high throughput. + package telemetry import "sync" @@ -72,7 +73,7 @@ func NewAppMetrics() *AppMetrics { // NewAppMetrics() call re-binds instruments against whatever provider is now // global. The real OTel Init uses this to migrate from the no-op provider // installed by the package-level init() to the SDK-backed one. -func resetAppMetricsForInit() { +func resetAppMetricsForInit() { //nolint:unused // called by otel-tagged build only appMetricsMu.Lock() defer appMetricsMu.Unlock() appMetricsInst = nil diff --git a/Server/telemetry/middleware.go b/Server/telemetry/middleware.go index e20dca7d..bd2ca8bc 100644 --- a/Server/telemetry/middleware.go +++ b/Server/telemetry/middleware.go @@ -2,6 +2,7 @@ // provider. The default no-op build returns next unchanged; the otel-tagged // build wraps next with otelchi.Middleware. Mount it from the Chi router so // every REST request becomes a span automatically. + package telemetry import "net/http" diff --git a/Server/telemetry/telemetry.go b/Server/telemetry/telemetry.go index d7c84a69..ea8d363b 100644 --- a/Server/telemetry/telemetry.go +++ b/Server/telemetry/telemetry.go @@ -111,20 +111,20 @@ func Global() Provider { return globalProvider } -// Tracer is a convenience that fetches a tracer from the global provider. +// GlobalTracer is a convenience that fetches a tracer from the global provider. func GlobalTracer(name string) Tracer { return Global().Tracer(name) } -// Meter is a convenience that fetches a meter from the global provider. +// GlobalMeter is a convenience that fetches a meter from the global provider. func GlobalMeter(name string) Meter { return Global().Meter(name) } // ── No-op implementation ──────────────────────────────────────────────────── type noopProvider struct{} -func (noopProvider) Tracer(string) Tracer { return noopTracer{} } -func (noopProvider) Meter(string) Meter { return noopMeter{} } +func (noopProvider) Tracer(string) Tracer { return noopTracer{} } +func (noopProvider) Meter(string) Meter { return noopMeter{} } func (noopProvider) HTTPMiddleware(next http.Handler) http.Handler { return next } -func (noopProvider) PrometheusHandler() http.Handler { return nil } +func (noopProvider) PrometheusHandler() http.Handler { return nil } type noopTracer struct{} @@ -134,15 +134,15 @@ func (noopTracer) Start(ctx context.Context, _ string, _ ...Attr) (context.Conte type noopSpan struct{} -func (noopSpan) End() {} +func (noopSpan) End() {} func (noopSpan) SetAttributes(...Attr) {} -func (noopSpan) RecordError(error) {} +func (noopSpan) RecordError(error) {} type noopMeter struct{} -func (noopMeter) Counter(string, string) Counter { return noopCounter{} } +func (noopMeter) Counter(string, string) Counter { return noopCounter{} } func (noopMeter) Histogram(string, string, string) Histogram { return noopHistogram{} } -func (noopMeter) Gauge(string, string) Gauge { return noopGauge{} } +func (noopMeter) Gauge(string, string) Gauge { return noopGauge{} } type noopCounter struct{} diff --git a/Server/telemetry/telemetry_default.go b/Server/telemetry/telemetry_default.go index 6e632aa3..78e671ce 100644 --- a/Server/telemetry/telemetry_default.go +++ b/Server/telemetry/telemetry_default.go @@ -3,6 +3,7 @@ // Default no-op build of the telemetry package — see telemetry.go for the // public API. The real OpenTelemetry SDK wiring lives in telemetry_otel.go and // is selected by `go build -tags otel ./...`. + package telemetry import ( diff --git a/Server/ws/command.go b/Server/ws/command.go index 40fe7ab6..5123dc54 100644 --- a/Server/ws/command.go +++ b/Server/ws/command.go @@ -227,7 +227,7 @@ func (c VoiceE2EEOfferCmd) IV() string { return c.iv } // authenticated client; raw is the JSON payload body. // Unexported to prevent accidental mutation; use getCommandConstructor for lookups. var commandConstructors = map[string]func(userID int64, reqID string, raw json.RawMessage) (Command, error){ - MsgTypePing: func(userID int64, _ string, _ json.RawMessage) (Command, error) { + MsgTypePing: func(userID int64, _ string, _ json.RawMessage) (Command, error) { //nolint:unparam // error always nil; signature dictated by map type return PingCmd{userID: userID}, nil }, @@ -396,11 +396,11 @@ var commandConstructors = map[string]func(userID int64, reqID string, raw json.R return VoiceJoinCmd{userID: userID, channelID: chID}, nil }, - MsgTypeVoiceLeave: func(userID int64, _ string, _ json.RawMessage) (Command, error) { + MsgTypeVoiceLeave: func(userID int64, _ string, _ json.RawMessage) (Command, error) { //nolint:unparam // error always nil; signature dictated by map type return VoiceLeaveCmd{userID: userID}, nil }, - MsgTypeVoiceTokenRefresh: func(userID int64, _ string, _ json.RawMessage) (Command, error) { + MsgTypeVoiceTokenRefresh: func(userID int64, _ string, _ json.RawMessage) (Command, error) { //nolint:unparam // error always nil; signature dictated by map type return VoiceTokenRefreshCmd{userID: userID}, nil }, diff --git a/Server/ws/emit.go b/Server/ws/emit.go index e2a5e0a4..bf8ab1cf 100644 --- a/Server/ws/emit.go +++ b/Server/ws/emit.go @@ -31,6 +31,8 @@ func (h *Hub) EmitEvents(events []Event) { case UserTargetedEvent: // High priority: targeted events (DM opens, mentions). h.SendToUserHigh(e.TargetUserID(), e.Payload()) + case ChannelEvent: + h.BroadcastToChannel(e.ChannelID(), e.Payload()) case BroadcastAllEvent: // Check concrete type: presence is low-priority, others are normal. if _, isPresence := ev.(PresenceEvent); isPresence { @@ -38,8 +40,6 @@ func (h *Hub) EmitEvents(events []Event) { } else { h.BroadcastToAll(e.Payload()) } - case ChannelEvent: - h.BroadcastToChannel(e.ChannelID(), e.Payload()) default: slog.Warn("EmitEvents: unknown event type", "type", fmt.Sprintf("%T", ev)) } diff --git a/Server/ws/event_persister.go b/Server/ws/event_persister.go index d676fe22..3fecc361 100644 --- a/Server/ws/event_persister.go +++ b/Server/ws/event_persister.go @@ -6,6 +6,7 @@ // counter is incremented. The reconnection handler tolerates gaps because the // in-memory ring buffer remains the primary cold-start source for clients // whose last_seq is recent. + package ws import ( diff --git a/Server/ws/handlers_command_test.go b/Server/ws/handlers_command_test.go index 3d0f645e..abde5e14 100644 --- a/Server/ws/handlers_command_test.go +++ b/Server/ws/handlers_command_test.go @@ -4,6 +4,7 @@ package ws_test // plugin EventSink wiring (Phase C Step 9). import ( + "bytes" "encoding/json" "testing" @@ -133,7 +134,7 @@ func TestEventSink_Emit_DeliversToBroadcaster(t *testing.T) { if gotChannelID != 42 { t.Fatalf("expected channelID=42, got %d", gotChannelID) } - if string(gotPayload) != string(want) { + if !bytes.Equal(gotPayload, want) { t.Fatalf("expected payload=%s, got %s", want, gotPayload) } } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index b3d78ab3..623ce27c 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -702,7 +702,7 @@ func (h *Hub) persistEvent(seq uint64, channelID int64, payload []byte) { eventType = "channel_broadcast" } } - h.eventPersister.Enqueue(int64(seq), eventType, channelID, payload) + h.eventPersister.Enqueue(int64(seq), eventType, channelID, payload) //nolint:gosec // seq is a monotonically increasing counter, never reaches MaxInt64 } // extractEventType scans a wrapped JSON envelope for the value of the "type" diff --git a/Server/ws/pubsub_test.go b/Server/ws/pubsub_test.go index 534aea0a..bc1ad58e 100644 --- a/Server/ws/pubsub_test.go +++ b/Server/ws/pubsub_test.go @@ -1,6 +1,7 @@ package ws import ( + "bytes" "sort" "sync" "testing" @@ -289,7 +290,7 @@ func assertChanMsg(t *testing.T, ch <-chan []byte, want []byte) { t.Helper() select { case got := <-ch: - if string(got) != string(want) { + if !bytes.Equal(got, want) { t.Errorf("got %q, want %q", got, want) } case <-time.After(100 * time.Millisecond): diff --git a/Server/ws/serve.go b/Server/ws/serve.go index c4e6a228..fde604eb 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -132,7 +132,7 @@ func (h *Hub) handleReconnect( channelIDs = append(channelIDs, cid) } const maxColdReplay = 5000 - persisted, dbErr := h.eventStore.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, maxColdReplay) + persisted, dbErr := h.eventStore.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, maxColdReplay) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64 if dbErr != nil { slog.Warn("ws handleReconnect: cold-tier replay query failed", "user_id", c.userID, "err", dbErr)