Files
OwnCord/Server/db/dm_queries.go
T
J3vbandClaude Fable 5 6afa9e974c refactor(server): thread context.Context through the db layer and all callers
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>
2026-07-23 17:03:52 +02:00

270 lines
9.4 KiB
Go

package db
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/owncord/server/db/dbgen"
)
// ─── DM Models ──────────────────────────────────────────────────────────────
// DMChannelInfo holds a DM channel summary for the channel list.
type DMChannelInfo struct {
ChannelID int64 `json:"channel_id"`
Recipient DMUser `json:"recipient"`
LastMessageID *int64 `json:"last_message_id"`
LastMessage string `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int `json:"unread_count"`
}
// DMUser is the public-facing shape for a DM participant.
type DMUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
}
// ─── GetOrCreateDMChannel ───────────────────────────────────────────────────
// GetOrCreateDMChannel finds or creates a DM channel between two users.
// Returns the channel, whether it was newly created, and any error.
// The entire lookup+create is wrapped in a single IMMEDIATE transaction to
// prevent a TOCTOU race where two concurrent requests both see ErrNoRows and
// each create a separate DM channel for the same user pair.
func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*Channel, bool, error) {
tx, err := d.sqlDB.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return nil, false, fmt.Errorf("GetOrCreateDMChannel begin tx: %w", err)
}
// Check for an existing DM channel inside the transaction.
var existingID int64
err = tx.QueryRow(
`SELECT dp1.channel_id FROM dm_participants dp1
JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id
JOIN channels c ON c.id = dp1.channel_id
WHERE dp1.user_id = ? AND dp2.user_id = ? AND c.type = 'dm'
LIMIT 1`,
user1ID, user2ID,
).Scan(&existingID)
if err == nil {
// Existing channel found — ensure the calling user has it open (re-open
// is idempotent). Without this, a user who previously closed the DM would
// not see it in their sidebar after the other party re-initiates.
_, _ = tx.Exec(
`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?)`,
user1ID, existingID,
)
if commitErr := tx.Commit(); commitErr != nil {
return nil, false, fmt.Errorf("GetOrCreateDMChannel commit existing: %w", commitErr)
}
ch, getErr := d.GetChannel(ctx, existingID)
if getErr != nil {
return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch existing: %w", getErr)
}
if ch == nil {
return nil, false, fmt.Errorf("GetOrCreateDMChannel: channel %d vanished", existingID)
}
return ch, false, nil
}
if !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return nil, false, fmt.Errorf("GetOrCreateDMChannel lookup: %w", err)
}
// No existing DM — create one inside the same transaction.
// Insert channel with type 'dm' and empty name.
res, err := tx.Exec(
`INSERT INTO channels (name, type) VALUES ('', 'dm')`,
)
if err != nil {
_ = tx.Rollback()
return nil, false, fmt.Errorf("GetOrCreateDMChannel insert channel: %w", err)
}
channelID, err := res.LastInsertId()
if err != nil {
_ = tx.Rollback()
return nil, false, fmt.Errorf("GetOrCreateDMChannel last insert id: %w", err)
}
// Insert both participants.
_, err = tx.Exec(
`INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?), (?, ?)`,
channelID, user1ID, channelID, user2ID,
)
if err != nil {
_ = tx.Rollback()
return nil, false, fmt.Errorf("GetOrCreateDMChannel insert participants: %w", err)
}
// Open the DM for both users.
_, err = tx.Exec(
`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?)`,
user1ID, channelID, user2ID, channelID,
)
if err != nil {
_ = tx.Rollback()
return nil, false, fmt.Errorf("GetOrCreateDMChannel open dm: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, false, fmt.Errorf("GetOrCreateDMChannel commit: %w", err)
}
ch, err := d.GetChannel(ctx, channelID)
if err != nil {
return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch new: %w", err)
}
return ch, true, nil
}
// ─── GetUserDMChannels ──────────────────────────────────────────────────────
// GetUserDMChannels returns all open DM channels for a user with recipient info,
// last message preview, and unread count. Ordered by most recent activity.
//
// Note: the SQL JOIN on dm_open_state already restricts results to DM channels
// (dm_open_state only contains rows for DM channels), and the explicit
// "c.type = 'dm'" predicate in the JOIN provides a defensive second check.
// No additional channel-type validation is needed at the Go layer.
func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelInfo, error) {
rows, err := d.sqlDB.QueryContext(ctx,
`SELECT
c.id AS channel_id,
u.id AS recipient_id,
u.username AS recipient_username,
COALESCE(u.avatar, '') AS recipient_avatar,
u.status AS recipient_status,
lm.id AS last_message_id,
COALESCE(lm.content, '') AS last_message,
COALESCE(lm.timestamp, '') AS last_message_at,
COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0)
AND m_unread.deleted = 0 THEN 1 END) AS unread_count
FROM dm_open_state dos
JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm'
JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ?
JOIN users u ON u.id = dp.user_id
LEFT JOIN messages lm ON lm.id = (
SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0
)
LEFT JOIN messages m_unread ON m_unread.channel_id = c.id
LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ?
WHERE dos.user_id = ?
GROUP BY c.id
ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC`,
userID, userID, userID,
)
if err != nil {
return nil, fmt.Errorf("GetUserDMChannels: %w", err)
}
defer rows.Close() //nolint:errcheck
var result []DMChannelInfo
for rows.Next() {
var info DMChannelInfo
var lastMsgID sql.NullInt64
if scanErr := rows.Scan(
&info.ChannelID,
&info.Recipient.ID,
&info.Recipient.Username,
&info.Recipient.Avatar,
&info.Recipient.Status,
&lastMsgID,
&info.LastMessage,
&info.LastMessageAt,
&info.UnreadCount,
); scanErr != nil {
return nil, fmt.Errorf("GetUserDMChannels scan: %w", scanErr)
}
if lastMsgID.Valid {
id := lastMsgID.Int64
info.LastMessageID = &id
}
result = append(result, info)
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetUserDMChannels rows: %w", rows.Err())
}
if result == nil {
result = []DMChannelInfo{}
}
return result, nil
}
// ─── OpenDM / CloseDM ──────────────────────────────────────────────────────
// OpenDM adds a DM channel to a user's open list (idempotent).
func (d *DB) OpenDM(ctx context.Context, userID, channelID int64) error {
if err := d.q.OpenDM(ctx, dbgen.OpenDMParams{
UserID: userID,
ChannelID: channelID,
}); err != nil {
return fmt.Errorf("OpenDM: %w", err)
}
return nil
}
// CloseDM removes a DM channel from a user's open list.
func (d *DB) CloseDM(ctx context.Context, userID, channelID int64) error {
if err := d.q.CloseDM(ctx, dbgen.CloseDMParams{
UserID: userID,
ChannelID: channelID,
}); err != nil {
return fmt.Errorf("CloseDM: %w", err)
}
return nil
}
// ─── Participant helpers ────────────────────────────────────────────────────
// IsDMParticipant checks if a user is a participant in a DM channel.
func (d *DB) IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) {
_, err := d.q.IsDMParticipant(ctx, dbgen.IsDMParticipantParams{
UserID: userID,
ChannelID: channelID,
})
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("IsDMParticipant: %w", err)
}
return true, nil
}
// GetDMParticipantIDs returns all participant user IDs for a DM channel.
func (d *DB) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) {
ids, err := d.q.GetDMParticipantIDs(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("GetDMParticipantIDs: %w", err)
}
return ids, nil
}
// GetDMRecipient returns the other participant in a DM channel.
func (d *DB) GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*User, error) {
var recipientID int64
err := d.sqlDB.QueryRowContext(ctx,
`SELECT user_id FROM dm_participants
WHERE channel_id = ? AND user_id != ?
LIMIT 1`,
channelID, requestingUserID,
).Scan(&recipientID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetDMRecipient lookup: %w", err)
}
return d.GetUserByID(ctx, recipientID)
}