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>
270 lines
9.4 KiB
Go
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)
|
|
}
|