fix(ci): resolve all golangci-lint and ESLint failures on PR #1132

Client:
- ci.yml: patch auto-generated events.ts to rename Event -> _Event
  so @typescript-eslint/no-unused-vars does not fail on generated code

Server (gocritic):
- service/channel.go: rangeValCopy (line 74), elseif (line 174)
- service/message.go: rangeValCopy (line 648), elseif (lines 438, 496)
- ws/emit.go: caseOrder — ChannelEvent before BroadcastAllEvent
- ws/handlers_command_test.go: stringXbytes — use bytes.Equal
- ws/pubsub_test.go: stringXbytes — use bytes.Equal

Server (nilerr):
- service/channel.go: nolint:nilerr for intentional silent drops in HandleTyping

Server (gosec):
- plugin/host_ui.go: G703 nolint — path already sanitized above
- plugin/registry.go: G302 — tighten plugin file permissions 0o640 → 0o600
- ws/hub.go, ws/serve.go: G115 nolint — seq counters never reach MaxInt64

Server (unused/unparam):
- plugin/registry.go: nolint:unused for wazero-tagged module field
- telemetry/metrics.go: nolint:unused for otel-tagged resetAppMetricsForInit
- ws/command.go: nolint:unparam for map entries whose error return is always nil

Server (staticcheck ST1000/ST1020):
- Add blank line before package declarations in phase-comment files
  (api/plugins_handler.go, plugin/host_{commands,events,http}.go,
   telemetry/metrics.go, telemetry/middleware.go,
   telemetry/telemetry_default.go, ws/event_persister.go)
- Fix GlobalTracer/GlobalMeter doc comments to start with function name
This commit is contained in:
J3vb
2026-04-07 10:10:38 +02:00
parent 64a6a8d4a4
commit ba66629077
20 changed files with 70 additions and 48 deletions
+18
View File
@@ -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
+1
View File
@@ -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 (
+1
View File
@@ -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 (
+1
View File
@@ -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 (
+1
View File
@@ -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 (
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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()
+10 -12
View File
@@ -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.
+9 -13
View File
@@ -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)
}
}
+2 -1
View File
@@ -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
+1
View File
@@ -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"
+9 -9
View File
@@ -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{}
+1
View File
@@ -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 (
+3 -3
View File
@@ -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
},
+2 -2
View File
@@ -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))
}
+1
View File
@@ -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 (
+2 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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):
+1 -1
View File
@@ -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)