package store import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "strings" ) // The panel's push notification tables (sql/push.sql). // EnsurePush creates the push tables if they're missing. The file holds several statements. func (s *Store) EnsurePush(ctx context.Context, ddl string) error { var lines []string for line := range strings.SplitSeq(ddl, "\n") { if !strings.HasPrefix(strings.TrimSpace(line), "--") { lines = append(lines, line) } } for stmt := range strings.SplitSeq(strings.Join(lines, "\n"), ";") { if stmt = strings.TrimSpace(stmt); stmt != "" { if _, err := s.db.ExecContext(ctx, stmt); err != nil { return err } } } return nil } // Setting reads a panel setting from saw_settings, "" if unset. func (s *Store) Setting(ctx context.Context, name string) (string, error) { var v string err := s.db.QueryRowContext(ctx, `SELECT value FROM saw_settings WHERE name = ?`, name).Scan(&v) if errors.Is(err, sql.ErrNoRows) { return "", nil } return v, err } // SettingOnce stores a setting unless it's already set, and returns whichever value is stored, so // two panels starting at once agree. func (s *Store) SettingOnce(ctx context.Context, name, value string) (string, error) { if _, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO saw_settings (name, value) VALUES (?, ?)`, name, value); err != nil { return "", err } return s.Setting(ctx, name) } // PushDevice is a browser or app that turned notifications on. type PushDevice struct { ID int64 SteamID string Endpoint string P256dh string Auth string } func endpointHash(endpoint string) string { h := sha256.Sum256([]byte(endpoint)) return hex.EncodeToString(h[:]) } // SavePushDevice adds a device, or moves it to this staff member if someone else had it. func (s *Store) SavePushDevice(ctx context.Context, steamid, endpoint, p256dh, auth, userAgent string) error { n, err := parseSID(steamid) if err != nil { return err } if len(userAgent) > 255 { userAgent = userAgent[:255] } _, err = s.db.ExecContext(ctx, `INSERT INTO saw_push_devices (steamid, endpoint, endpoint_hash, p256dh, auth, user_agent) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE steamid = VALUES(steamid), p256dh = VALUES(p256dh), auth = VALUES(auth), user_agent = VALUES(user_agent)`, n, endpoint, endpointHash(endpoint), p256dh, auth, userAgent) return err } // DeletePushDevice removes one of a staff member's devices, or all of them when endpoint is "". func (s *Store) DeletePushDevice(ctx context.Context, steamid, endpoint string) (int64, error) { n, err := parseSID(steamid) if err != nil { return 0, err } q, args := `DELETE FROM saw_push_devices WHERE steamid = ?`, []any{n} if endpoint != "" { q += ` AND endpoint_hash = ?` args = append(args, endpointHash(endpoint)) } res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return 0, err } return res.RowsAffected() } // DeletePushDeviceID removes a device the push service says is gone. func (s *Store) DeletePushDeviceID(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM saw_push_devices WHERE id = ?`, id) return err } // PushDeviceHashes lists the endpoint hashes of a staff member's devices, so a browser can tell // whether it's one of them without the endpoints being sent back. func (s *Store) PushDeviceHashes(ctx context.Context, steamid string) ([]string, error) { n, err := parseSID(steamid) if err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, `SELECT endpoint_hash FROM saw_push_devices WHERE steamid = ? ORDER BY id`, n) if err != nil { return nil, err } defer rows.Close() out := []string{} for rows.Next() { var h string if err := rows.Scan(&h); err != nil { return nil, err } out = append(out, h) } return out, rows.Err() } // PushDevices lists one staff member's devices, or everyone's when steamid is "". func (s *Store) PushDevices(ctx context.Context, steamid string) ([]PushDevice, error) { q, args := `SELECT id, steamid, endpoint, p256dh, auth FROM saw_push_devices`, []any{} if steamid != "" { n, err := parseSID(steamid) if err != nil { return nil, err } q += ` WHERE steamid = ?` args = append(args, n) } rows, err := s.db.QueryContext(ctx, q+` ORDER BY id`, args...) if err != nil { return nil, err } defer rows.Close() var out []PushDevice for rows.Next() { var d PushDevice var n sql.NullInt64 if err := rows.Scan(&d.ID, &n, &d.Endpoint, &d.P256dh, &d.Auth); err != nil { return nil, err } d.SteamID = sid(n) out = append(out, d) } return out, rows.Err() } // PushPrefs is what a staff member wants notifications for. type PushPrefs struct { Types []string QuietEnabled bool QuietStart int // minutes after midnight QuietEnd int Timezone string } // AllPushPrefs returns everyone's saved preferences by SteamID64. func (s *Store) AllPushPrefs(ctx context.Context) (map[string]PushPrefs, error) { return s.pushPrefs(ctx, "") } // PushPrefsFor returns one staff member's saved preferences, and false if they have none yet. func (s *Store) PushPrefsFor(ctx context.Context, steamid string) (PushPrefs, bool, error) { all, err := s.pushPrefs(ctx, steamid) if err != nil { return PushPrefs{}, false, err } p, ok := all[steamid] return p, ok, nil } func (s *Store) pushPrefs(ctx context.Context, steamid string) (map[string]PushPrefs, error) { q, args := `SELECT steamid, types, quiet_enabled, quiet_start, quiet_end, timezone FROM saw_push_prefs`, []any{} if steamid != "" { n, err := parseSID(steamid) if err != nil { return nil, err } q += ` WHERE steamid = ?` args = append(args, n) } rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() out := map[string]PushPrefs{} for rows.Next() { var n sql.NullInt64 var types string var p PushPrefs if err := rows.Scan(&n, &types, &p.QuietEnabled, &p.QuietStart, &p.QuietEnd, &p.Timezone); err != nil { return nil, err } p.Types = []string{} for t := range strings.SplitSeq(types, ",") { if t = strings.TrimSpace(t); t != "" { p.Types = append(p.Types, t) } } out[sid(n)] = p } return out, rows.Err() } // SavePushPrefs stores a staff member's preferences. func (s *Store) SavePushPrefs(ctx context.Context, steamid string, p PushPrefs) error { n, err := parseSID(steamid) if err != nil { return err } _, err = s.db.ExecContext(ctx, `INSERT INTO saw_push_prefs (steamid, types, quiet_enabled, quiet_start, quiet_end, timezone) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE types = VALUES(types), quiet_enabled = VALUES(quiet_enabled), quiet_start = VALUES(quiet_start), quiet_end = VALUES(quiet_end), timezone = VALUES(timezone)`, n, strings.Join(p.Types, ","), p.QuietEnabled, p.QuietStart, p.QuietEnd, p.Timezone) return err } // PenaltyEvent is a new ban, comm block, unban or unmute, for notifications. type PenaltyEvent struct { ID int64 Kind string // ban, comm, unban, unmute Type string // GAG, MUTE or SILENCE for comm and unmute Name string SteamID string AdminSteamID string AdminName string Reason string Duration int // minutes, 0 permanent; bans and comms only } // penaltyEventSQL selects rows after an id, oldest first, in PenaltyEvent's column order. Lifts // take the player from the penalty they lift and the admin from sa_admins. var penaltyEventSQL = map[string]string{ "ban": `SELECT b.id, '', ` + nameOr("b") + `, b.player_steamid, b.admin_steamid, b.admin_name, b.reason, b.duration FROM sa_bans b WHERE b.id > ? ORDER BY b.id LIMIT 50`, "comm": `SELECT m.id, m.type, ` + nameOr("m") + `, m.player_steamid, m.admin_steamid, m.admin_name, m.reason, m.duration FROM sa_mutes m WHERE m.id > ? ORDER BY m.id LIMIT 50`, "unban": `SELECT u.id, '', ` + nameOr("b") + `, b.player_steamid, COALESCE(a.player_steamid, 0), COALESCE(a.player_name, ''), COALESCE(u.reason, ''), 0 FROM sa_unbans u JOIN sa_bans b ON b.id = u.ban_id LEFT JOIN sa_admins a ON a.id = u.admin_id WHERE u.id > ? ORDER BY u.id LIMIT 50`, "unmute": `SELECT u.id, m.type, ` + nameOr("m") + `, m.player_steamid, COALESCE(a.player_steamid, 0), COALESCE(a.player_name, ''), COALESCE(u.reason, ''), 0 FROM sa_unmutes u JOIN sa_mutes m ON m.id = u.mute_id LEFT JOIN sa_admins a ON a.id = u.admin_id WHERE u.id > ? ORDER BY u.id LIMIT 50`, } // PenaltyEventTable is the table whose ids PenaltyEvents counts, for MaxID. var PenaltyEventTable = map[string]string{"ban": "sa_bans", "comm": "sa_mutes", "unban": "sa_unbans", "unmute": "sa_unmutes"} // PenaltyEvents lists rows of one kind with ids after `after`, oldest first. func (s *Store) PenaltyEvents(ctx context.Context, kind string, after int64) ([]PenaltyEvent, error) { q, ok := penaltyEventSQL[kind] if !ok { return nil, errors.New("unknown penalty event kind " + kind) } rows, err := s.db.QueryContext(ctx, q, after) if err != nil { return nil, err } defer rows.Close() var out []PenaltyEvent for rows.Next() { e := PenaltyEvent{Kind: kind} var psid, asid sql.NullInt64 if err := rows.Scan(&e.ID, &e.Type, &e.Name, &psid, &asid, &e.AdminName, &e.Reason, &e.Duration); err != nil { return nil, err } e.SteamID, e.AdminSteamID = sid(psid), sid(asid) out = append(out, e) } return out, rows.Err() }