package userstore import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "warpbox/lib/helpers" ) const ( StatusActive = "active" StatusDisabled = "disabled" ) type Permissions struct { CanUseWeb bool `json:"can_use_web"` CanUseAPI bool `json:"can_use_api"` CanCreateBox bool `json:"can_create_box"` CanUploadFile bool `json:"can_upload_file"` } type Limits struct { MaxFileSizeBytes int64 `json:"max_file_size_bytes"` MaxBoxSizeBytes int64 `json:"max_box_size_bytes"` } type APIKey struct { ID string `json:"id"` Name string `json:"name"` Prefix string `json:"prefix"` KeyHash string `json:"key_hash"` CreatedAt time.Time `json:"created_at"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"` } type User struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` Status string `json:"status"` Permissions Permissions `json:"permissions"` Limits Limits `json:"limits"` APIKeys []APIKey `json:"api_keys"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` } type diskState struct { Users []User `json:"users"` } type Store struct { path string mu sync.RWMutex users map[string]User } func NewStore(path string) (*Store, error) { s := &Store{path: path, users: map[string]User{}} if err := s.load(); err != nil { return nil, err } return s, nil } func (s *Store) load() error { s.mu.Lock() defer s.mu.Unlock() bytes, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } if len(bytes) == 0 { return nil } var state diskState if err := json.Unmarshal(bytes, &state); err != nil { return err } for _, user := range state.Users { s.users[user.ID] = user } return nil } func (s *Store) saveLocked() error { state := diskState{Users: make([]User, 0, len(s.users))} for _, user := range s.users { state.Users = append(state.Users, user) } sort.Slice(state.Users, func(i, j int) bool { return state.Users[i].CreatedAt.After(state.Users[j].CreatedAt) }) bytes, err := json.MarshalIndent(state, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { return err } tmpPath := s.path + ".tmp" if err := os.WriteFile(tmpPath, bytes, 0644); err != nil { return err } return os.Rename(tmpPath, s.path) } func (s *Store) List() []User { s.mu.RLock() defer s.mu.RUnlock() users := make([]User, 0, len(s.users)) for _, user := range s.users { users = append(users, user) } sort.Slice(users, func(i, j int) bool { return users[i].CreatedAt.After(users[j].CreatedAt) }) return users } func normalizeStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case StatusDisabled: return StatusDisabled default: return StatusActive } } func normalizePermissions(p Permissions) Permissions { return Permissions{ CanUseWeb: p.CanUseWeb, CanUseAPI: p.CanUseAPI, CanCreateBox: p.CanCreateBox, CanUploadFile: p.CanUploadFile, } } func normalizeEmail(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func normalizeUsername(value string) string { return strings.TrimSpace(value) } func validateUserInput(username string, email string) error { if normalizeUsername(username) == "" { return fmt.Errorf("username is required") } if normalizeEmail(email) == "" || !strings.Contains(email, "@") { return fmt.Errorf("valid email is required") } return nil } func (s *Store) Create(username string, email string, permissions Permissions, limits Limits, status string) (User, error) { s.mu.Lock() defer s.mu.Unlock() if err := validateUserInput(username, email); err != nil { return User{}, err } normEmail := normalizeEmail(email) for _, existing := range s.users { if strings.EqualFold(existing.Email, normEmail) { return User{}, fmt.Errorf("email already exists") } } id, err := helpers.RandomHexID(8) if err != nil { return User{}, err } now := time.Now().UTC() user := User{ ID: "u_" + id, Username: normalizeUsername(username), Email: normEmail, Status: normalizeStatus(status), Permissions: normalizePermissions(permissions), Limits: limits, APIKeys: []APIKey{}, CreatedAt: now, UpdatedAt: now, } s.users[user.ID] = user if err := s.saveLocked(); err != nil { return User{}, err } return user, nil } func (s *Store) Update(id string, username string, email string, permissions Permissions, limits Limits, status string) (User, error) { s.mu.Lock() defer s.mu.Unlock() if err := validateUserInput(username, email); err != nil { return User{}, err } user, ok := s.users[id] if !ok { return User{}, fmt.Errorf("user not found") } normEmail := normalizeEmail(email) for _, existing := range s.users { if existing.ID == id { continue } if strings.EqualFold(existing.Email, normEmail) { return User{}, fmt.Errorf("email already exists") } } user.Username = normalizeUsername(username) user.Email = normEmail user.Status = normalizeStatus(status) user.Permissions = normalizePermissions(permissions) user.Limits = limits user.UpdatedAt = time.Now().UTC() s.users[id] = user if err := s.saveLocked(); err != nil { return User{}, err } return user, nil } func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.users[id]; !ok { return fmt.Errorf("user not found") } delete(s.users, id) return s.saveLocked() } func (s *Store) FindByID(id string) (User, bool) { s.mu.RLock() defer s.mu.RUnlock() user, ok := s.users[id] return user, ok } func hashKey(value string) string { digest := sha256.Sum256([]byte(value)) return hex.EncodeToString(digest[:]) } func (s *Store) CreateAPIKey(userID string, name string) (APIKey, string, error) { s.mu.Lock() defer s.mu.Unlock() user, ok := s.users[userID] if !ok { return APIKey{}, "", fmt.Errorf("user not found") } if strings.TrimSpace(name) == "" { name = "default" } rawSuffix, err := helpers.RandomHexID(20) if err != nil { return APIKey{}, "", err } keyValue := "wbk_" + rawSuffix id, err := helpers.RandomHexID(8) if err != nil { return APIKey{}, "", err } prefix := keyValue if len(prefix) > 12 { prefix = prefix[:12] } now := time.Now().UTC() key := APIKey{ ID: "k_" + id, Name: strings.TrimSpace(name), Prefix: prefix, KeyHash: hashKey(keyValue), CreatedAt: now, } user.APIKeys = append(user.APIKeys, key) user.UpdatedAt = now s.users[userID] = user if err := s.saveLocked(); err != nil { return APIKey{}, "", err } return key, keyValue, nil } func (s *Store) RevokeAPIKey(userID string, keyID string) error { s.mu.Lock() defer s.mu.Unlock() user, ok := s.users[userID] if !ok { return fmt.Errorf("user not found") } now := time.Now().UTC() for i := range user.APIKeys { if user.APIKeys[i].ID == keyID { user.APIKeys[i].RevokedAt = &now user.UpdatedAt = now s.users[userID] = user return s.saveLocked() } } return fmt.Errorf("api key not found") } func (s *Store) FindByAPIKey(raw string) (User, APIKey, bool) { h := hashKey(strings.TrimSpace(raw)) s.mu.RLock() defer s.mu.RUnlock() for _, user := range s.users { for _, key := range user.APIKeys { if key.RevokedAt != nil { continue } if key.KeyHash == h { return user, key, true } } } return User{}, APIKey{}, false } func (s *Store) TouchAPIKey(userID string, keyID string) { s.mu.Lock() defer s.mu.Unlock() user, ok := s.users[userID] if !ok { return } now := time.Now().UTC() for i := range user.APIKeys { if user.APIKeys[i].ID == keyID { user.APIKeys[i].LastUsedAt = &now break } } user.LastSeenAt = &now user.UpdatedAt = now s.users[userID] = user _ = s.saveLocked() } func (s *Store) TouchUser(userID string) { s.mu.Lock() defer s.mu.Unlock() user, ok := s.users[userID] if !ok { return } now := time.Now().UTC() user.LastSeenAt = &now user.UpdatedAt = now s.users[userID] = user _ = s.saveLocked() }