Files
warpbox/lib/userstore/store.go

370 lines
8.2 KiB
Go
Raw Normal View History

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()
}