feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
This commit is contained in:
369
lib/userstore/store.go
Normal file
369
lib/userstore/store.go
Normal file
@@ -0,0 +1,369 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user