All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
370 lines
8.2 KiB
Go
370 lines
8.2 KiB
Go
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()
|
|
}
|