Evict/cap limiter keys, replace hard username lockouts with IP+user progressive delays cleared on success, and create bootstrap admins under the same advisory/mutex lock as role changes.
350 lines
7.6 KiB
Go
350 lines
7.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"plumber/internal/pacific"
|
|
)
|
|
|
|
// Memory is an in-process Store for tests.
|
|
type Memory struct {
|
|
mu sync.Mutex
|
|
users map[string]*User // id -> user
|
|
byName map[string]string // username -> id
|
|
questions map[string]*RankedQuestion // id -> question
|
|
answers map[string]*Answer // questionID -> answer
|
|
votes map[string]map[string]int // questionID -> userID -> value
|
|
}
|
|
|
|
// NewMemory returns an empty Memory store.
|
|
func NewMemory() *Memory {
|
|
return &Memory{
|
|
users: map[string]*User{},
|
|
byName: map[string]string{},
|
|
questions: map[string]*RankedQuestion{},
|
|
answers: map[string]*Answer{},
|
|
votes: map[string]map[string]int{},
|
|
}
|
|
}
|
|
|
|
func (m *Memory) CreateUser(_ context.Context, u *User) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if u.Role != RoleUser && u.Role != RoleAdmin {
|
|
return fmt.Errorf("invalid role")
|
|
}
|
|
u.Username = NormalizeUsername(u.Username)
|
|
if _, ok := m.byName[u.Username]; ok {
|
|
return fmt.Errorf("username taken")
|
|
}
|
|
if u.ID == "" {
|
|
u.ID = uuid.NewString()
|
|
}
|
|
if u.Name == "" {
|
|
u.Name = u.Username
|
|
}
|
|
if u.CreatedAt == "" {
|
|
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
role := u.Role
|
|
if role == RoleAdmin {
|
|
for _, existing := range m.users {
|
|
if existing.Role == RoleAdmin {
|
|
role = RoleUser
|
|
break
|
|
}
|
|
}
|
|
}
|
|
cp := *u
|
|
cp.Role = role
|
|
cp.db = nil
|
|
m.users[cp.ID] = &cp
|
|
m.byName[cp.Username] = cp.ID
|
|
*u = cp
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) UserByID(_ context.Context, id string) (*User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
u, ok := m.users[id]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *Memory) UserByUsername(_ context.Context, username string) (*User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
id, ok := m.byName[NormalizeUsername(username)]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *m.users[id]
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *Memory) ListUsers(_ context.Context) ([]User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]User, 0, len(m.users))
|
|
for _, u := range m.users {
|
|
out = append(out, *u)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
|
if len(out) > AdminUsersLimit {
|
|
out = out[:AdminUsersLimit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *Memory) CountAdmins(_ context.Context) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
n := 0
|
|
for _, u := range m.users {
|
|
if u.Role == RoleAdmin {
|
|
n++
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// SetUserRole serializes demotions under m.mu (same critical section as count).
|
|
func (m *Memory) SetUserRole(_ context.Context, id string, role Role) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if role != RoleUser && role != RoleAdmin {
|
|
return fmt.Errorf("invalid role")
|
|
}
|
|
u, ok := m.users[id]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
if u.Role == RoleAdmin && role == RoleUser {
|
|
n := 0
|
|
for _, x := range m.users {
|
|
if x.Role == RoleAdmin {
|
|
n++
|
|
}
|
|
}
|
|
if n <= 1 {
|
|
return ErrLastAdmin
|
|
}
|
|
}
|
|
u.Role = role
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
cur, ok := m.users[u.ID]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
cur.State = strings.TrimSpace(u.State)
|
|
if u.AvatarURL != "" {
|
|
cur.AvatarURL = u.AvatarURL
|
|
}
|
|
u.State = cur.State
|
|
u.AvatarURL = cur.AvatarURL
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
q.Title = strings.TrimSpace(q.Title)
|
|
q.Body = strings.TrimSpace(q.Body)
|
|
q.City = strings.TrimSpace(q.City)
|
|
if q.ID == "" {
|
|
q.ID = uuid.NewString()
|
|
}
|
|
if q.HuntDate == "" {
|
|
q.HuntDate = pacific.Today()
|
|
}
|
|
if q.CreatedAt == "" {
|
|
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
author, ok := m.users[q.AuthorID]
|
|
if !ok {
|
|
return fmt.Errorf("unknown author")
|
|
}
|
|
cp := *q
|
|
cp.AuthorName = author.Name
|
|
cp.db = nil
|
|
m.questions[cp.ID] = &cp
|
|
*q = cp
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
|
|
out := *q
|
|
score := 0
|
|
userVote := 0
|
|
if votes, ok := m.votes[q.ID]; ok {
|
|
for uid, v := range votes {
|
|
score += v
|
|
if uid == viewerID {
|
|
userVote = v
|
|
}
|
|
}
|
|
}
|
|
_, answered := m.answers[q.ID]
|
|
out.Score = score
|
|
out.Answered = answered
|
|
out.UserVote = userVote
|
|
out.db = nil
|
|
return out
|
|
}
|
|
|
|
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
q, ok := m.questions[id]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
out := m.annotate(q, viewerID)
|
|
return &out, nil
|
|
}
|
|
|
|
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]RankedQuestion, 0)
|
|
for _, q := range m.questions {
|
|
if q.HuntDate != huntDate || q.Hidden {
|
|
continue
|
|
}
|
|
out = append(out, m.annotate(q, viewerID))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Score != out[j].Score {
|
|
return out[i].Score > out[j].Score
|
|
}
|
|
return out[i].CreatedAt < out[j].CreatedAt
|
|
})
|
|
if len(out) > HuntListLimit {
|
|
out = out[:HuntListLimit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]RankedQuestion, 0)
|
|
for _, q := range m.questions {
|
|
if q.AuthorID != authorID || q.Hidden {
|
|
continue
|
|
}
|
|
out = append(out, m.annotate(q, ""))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
|
if len(out) > ProfileListLimit {
|
|
out = out[:ProfileListLimit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]RankedQuestion, 0)
|
|
for qid, a := range m.answers {
|
|
if a.AuthorID != adminID {
|
|
continue
|
|
}
|
|
q, ok := m.questions[qid]
|
|
if !ok || q.Hidden {
|
|
continue
|
|
}
|
|
rq := m.annotate(q, "")
|
|
rq.Answered = true
|
|
out = append(out, rq)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
|
if len(out) > ProfileListLimit {
|
|
out = out[:ProfileListLimit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *Memory) HideQuestion(_ context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
q, ok := m.questions[id]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
q.Hidden = true
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
a, ok := m.answers[questionID]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *a
|
|
if u, ok := m.users[a.AuthorID]; ok {
|
|
cp.AuthorName = u.Name
|
|
}
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.questions[a.QuestionID]; !ok {
|
|
return fmt.Errorf("unknown question")
|
|
}
|
|
a.Body = strings.TrimSpace(a.Body)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if existing, ok := m.answers[a.QuestionID]; ok {
|
|
a.CreatedAt = existing.CreatedAt
|
|
} else if a.CreatedAt == "" {
|
|
a.CreatedAt = now
|
|
}
|
|
a.UpdatedAt = now
|
|
cp := *a
|
|
cp.db = nil
|
|
m.answers[a.QuestionID] = &cp
|
|
*a = cp
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if value != 1 && value != -1 {
|
|
return fmt.Errorf("invalid vote")
|
|
}
|
|
if _, ok := m.questions[questionID]; !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
if m.votes[questionID] == nil {
|
|
m.votes[questionID] = map[string]int{}
|
|
}
|
|
if cur, ok := m.votes[questionID][userID]; ok && cur == value {
|
|
delete(m.votes[questionID], userID)
|
|
return nil
|
|
}
|
|
m.votes[questionID][userID] = value
|
|
return nil
|
|
}
|