mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
145 lines
5.2 KiB
Go
145 lines
5.2 KiB
Go
// Phase C Step 9 — plugin slash-command dispatcher (V2).
|
|
//
|
|
// chat_command routes a slash command from a WS client to a registered plugin.
|
|
// If no plugin owns the command, an error is returned to the sender. If the
|
|
// 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/owncord/server/plugin"
|
|
"github.com/owncord/server/service"
|
|
)
|
|
|
|
const MsgTypeChatCommand = "chat_command"
|
|
|
|
// maxCommandArgs is the maximum number of arguments accepted in a
|
|
// chat_command payload. This prevents a malicious client from flooding
|
|
// the plugin's allocate/dispatch ABI with thousands of strings.
|
|
const maxCommandArgs = 64
|
|
|
|
// handleChatCommandV2 dispatches a slash command to the owning plugin via the
|
|
// live plugin registry (wired post-construction). It returns:
|
|
// - a ClientError when no plugin registry is wired, the command is unknown,
|
|
// or the invoking user may not post the plugin's broadcast;
|
|
// - Result.Reply for an ephemeral plugin reply (sender only);
|
|
// - Result.Events with a PluginBroadcastEvent for a channel broadcast, gated
|
|
// by MessageService.CanPost (same policy as a real message send).
|
|
func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps any) Result {
|
|
d := deps.(PluginDeps)
|
|
cc := cmd.(ChatCommandCmd)
|
|
|
|
var reg *plugin.Registry
|
|
if d.Registry != nil {
|
|
reg = d.Registry()
|
|
}
|
|
if reg == nil {
|
|
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: fmt.Sprintf("unknown command: %s (no plugins loaded)", cc.command)}}
|
|
}
|
|
|
|
result, handled := reg.DispatchCommand(ctx, cc.userID, cc.channelID, cc.command, cc.args)
|
|
if !handled {
|
|
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: fmt.Sprintf("unknown command: %s", cc.command)}}
|
|
}
|
|
if result == nil {
|
|
// Plugin acknowledged with no output.
|
|
return Result{}
|
|
}
|
|
|
|
var out Result
|
|
if result.Reply != "" {
|
|
// Ephemeral reply — sent only to the invoking client.
|
|
out.Reply = buildCommandReply(cc.reqID, result.Reply)
|
|
}
|
|
|
|
if result.Broadcast != "" && cc.channelID != 0 {
|
|
// Verify the invoking client can post to this channel before broadcasting
|
|
// (same gate a real send uses: channel role perms + DM membership/blocks).
|
|
// ponytail: if the plugin returned both a reply and a broadcast and the
|
|
// gate denies, the error wins and the ephemeral reply is dropped (Result
|
|
// carries either an error or a reply, not both) — an untested edge; V1
|
|
// sent both. Preserve the security signal (denial) over the ack.
|
|
if gate := canPluginBroadcast(ctx, d.MessageSvc, cc.userID, cc.channelID); gate != nil {
|
|
return *gate
|
|
}
|
|
msg := buildCommandBroadcast(cc.channelID, cc.userID, cc.command, result.Broadcast)
|
|
out.Events = append(out.Events, PluginBroadcastEvent{channelID: cc.channelID, payload: msg})
|
|
slog.Info("plugin command broadcast", "cmd", cc.command, "channel_id", cc.channelID, "user_id", cc.userID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// canPluginBroadcast reports whether userID may post to channelID by delegating
|
|
// to the SAME service-layer check a real message send runs (MessageService.CanPost:
|
|
// cached channel permissions; DM membership AND DM blocks). Returns nil when
|
|
// allowed, or a Result carrying the appropriate ClientError otherwise. A nil
|
|
// MessageSvc (bare test hub) fails closed rather than allowing an ungated
|
|
// broadcast.
|
|
func canPluginBroadcast(ctx context.Context, messageSvc *service.MessageService, userID, channelID int64) *Result {
|
|
if messageSvc == nil {
|
|
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "broadcast gate unavailable"}}
|
|
return &r
|
|
}
|
|
if err := messageSvc.CanPost(ctx, userID, channelID); err != nil {
|
|
if errors.Is(err, service.ErrNotFound) {
|
|
r := Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}}
|
|
return &r
|
|
}
|
|
slog.Warn("ws plugin broadcast permission denied",
|
|
"user_id", userID, "channel_id", channelID, "err", err)
|
|
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing permission to post in this channel"}}
|
|
return &r
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildCommandReply builds an ephemeral command_reply envelope.
|
|
func buildCommandReply(reqID, text string) []byte {
|
|
type payload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
type envelope struct {
|
|
Type string `json:"type"`
|
|
ReqID string `json:"req_id,omitempty"`
|
|
Payload payload `json:"payload"`
|
|
}
|
|
raw, _ := json.Marshal(envelope{
|
|
Type: "command_reply",
|
|
ReqID: reqID,
|
|
Payload: payload{Text: text},
|
|
})
|
|
return raw
|
|
}
|
|
|
|
// buildCommandBroadcast builds a plugin_broadcast envelope sent to a channel.
|
|
func buildCommandBroadcast(channelID, userID int64, cmd, text string) []byte {
|
|
type payload struct {
|
|
ChannelID int64 `json:"channel_id"`
|
|
UserID int64 `json:"user_id"`
|
|
Command string `json:"command"`
|
|
Text string `json:"text"`
|
|
}
|
|
type envelope struct {
|
|
Type string `json:"type"`
|
|
Payload payload `json:"payload"`
|
|
}
|
|
raw, _ := json.Marshal(envelope{
|
|
Type: "plugin_broadcast",
|
|
Payload: payload{
|
|
ChannelID: channelID,
|
|
UserID: userID,
|
|
Command: cmd,
|
|
Text: text,
|
|
},
|
|
})
|
|
return raw
|
|
}
|