Files
2026-07-20 21:41:51 +02:00

354 lines
8.3 KiB
Go

package platform
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("already exists")
ErrKeysLocked = errors.New("key pairs already exist and cannot be overwritten")
)
type Store struct {
mu sync.RWMutex
path string
doc document
}
func OpenStore(path string) (*Store, error) {
s := &Store{path: path, doc: document{Version: 1, Users: map[string]User{}, Licenses: map[string]LicenseRecord{}, Sessions: map[string]Session{}, Audit: []AuditEvent{}}}
if strings.TrimSpace(path) == "" {
return s, nil
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &s.doc); err != nil {
return nil, err
}
if s.doc.Version != 1 {
return nil, errors.New("unsupported data-store version")
}
if s.doc.Users == nil {
s.doc.Users = map[string]User{}
}
if s.doc.Licenses == nil {
s.doc.Licenses = map[string]LicenseRecord{}
}
if s.doc.Sessions == nil {
s.doc.Sessions = map[string]Session{}
}
return s, nil
}
func (s *Store) KeySet() (*KeySet, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.doc.Keys == nil {
return nil, false
}
copy := *s.doc.Keys
return &copy, true
}
func (s *Store) SetKeysOnce(keys KeySet) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.doc.Keys != nil {
return ErrKeysLocked
}
keys.CreatedAt = unixNow()
s.doc.Keys = &keys
return s.persistLocked()
}
func (s *Store) CreateUser(user User) error {
s.mu.Lock()
defer s.mu.Unlock()
username := normalizeUsername(user.Username)
if !validUsername(username) {
return errors.New("username must be 3-120 characters and contain only letters, numbers, dot, dash, underscore or @")
}
if len(strings.TrimSpace(user.DisplayName)) > 200 {
return errors.New("display name is too long")
}
for _, existing := range s.doc.Users {
if normalizeUsername(existing.Username) == username {
return ErrConflict
}
}
now := unixNow()
user.Username = username
user.CreatedAt = now
user.UpdatedAt = now
if !user.Active {
user.Active = true
}
s.doc.Users[user.ID] = user
return s.persistLocked()
}
func (s *Store) EnsureBootstrapAdmin(username, displayName, passwordHash string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, user := range s.doc.Users {
if user.Role == RoleAdmin {
return false, nil
}
}
username = normalizeUsername(username)
if !validUsername(username) {
return false, errors.New("bootstrap username is invalid")
}
now := unixNow()
user := User{ID: "usr_admin_bootstrap", Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdmin, PasswordHash: passwordHash, Active: true, CreatedAt: now, UpdatedAt: now}
if user.DisplayName == "" {
user.DisplayName = "Administrator"
}
s.doc.Users[user.ID] = user
return true, s.persistLocked()
}
func (s *Store) FindUserByUsername(username string) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
username = normalizeUsername(username)
for _, user := range s.doc.Users {
if normalizeUsername(user.Username) == username {
return user, true
}
}
return User{}, false
}
func (s *Store) GetUser(id string) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.doc.Users[id]
return user, ok
}
func (s *Store) UpdatePassword(userID, passwordHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.doc.Users[userID]
if !ok {
return ErrNotFound
}
user.PasswordHash = passwordHash
user.UpdatedAt = unixNow()
s.doc.Users[userID] = user
return s.persistLocked()
}
func (s *Store) ListUsers() []User {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]User, 0, len(s.doc.Users))
for _, user := range s.doc.Users {
out = append(out, user)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Role == out[j].Role {
return out[i].Username < out[j].Username
}
return out[i].Role < out[j].Role
})
return out
}
func (s *Store) PutLicense(record LicenseRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
now := unixNow()
if existing, ok := s.doc.Licenses[record.LicenseID]; ok {
record.CreatedAt = existing.CreatedAt
}
if record.CreatedAt == 0 {
record.CreatedAt = now
}
record.UpdatedAt = now
s.doc.Licenses[record.LicenseID] = record
return s.persistLocked()
}
func (s *Store) GetLicense(id string) (LicenseRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
record, ok := s.doc.Licenses[id]
return cloneLicense(record), ok
}
func (s *Store) ListLicensesFor(user User) []LicenseRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]LicenseRecord, 0, len(s.doc.Licenses))
for _, record := range s.doc.Licenses {
switch user.Role {
case RoleAdmin:
out = append(out, cloneLicense(record))
case RoleReseller:
if record.IssuedByUserID == user.ID {
out = append(out, cloneLicense(record))
}
case RoleCustomer:
if record.CustomerUserID == user.ID {
out = append(out, cloneLicense(record))
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
return out
}
func (s *Store) SetRevoked(id string, revoked bool, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.doc.Licenses[id]
if !ok {
return ErrNotFound
}
record.Revoked = revoked
if revoked {
record.RevocationReason = strings.TrimSpace(reason)
} else {
record.RevocationReason = ""
}
record.UpdatedAt = unixNow()
s.doc.Licenses[id] = record
return s.persistLocked()
}
func (s *Store) CreateSession(session Session) error {
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupSessionsLocked(time.Now().UTC())
s.doc.Sessions[session.IDHash] = session
return s.persistLocked()
}
func (s *Store) GetSession(idHash string, now time.Time) (Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.doc.Sessions[idHash]
if !ok || session.ExpiresAt <= now.Unix() {
return Session{}, false
}
return session, true
}
func (s *Store) DeleteSessionsForUser(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
for id, session := range s.doc.Sessions {
if session.UserID == userID {
delete(s.doc.Sessions, id)
}
}
return s.persistLocked()
}
func (s *Store) DeleteSession(idHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.doc.Sessions, idHash)
return s.persistLocked()
}
func (s *Store) AddAudit(event AuditEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
if event.CreatedAt == 0 {
event.CreatedAt = unixNow()
}
s.doc.Audit = append(s.doc.Audit, event)
if len(s.doc.Audit) > 2000 {
s.doc.Audit = append([]AuditEvent(nil), s.doc.Audit[len(s.doc.Audit)-2000:]...)
}
return s.persistLocked()
}
func (s *Store) ListAudit(limit int) []AuditEvent {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 200 {
limit = 50
}
start := len(s.doc.Audit) - limit
if start < 0 {
start = 0
}
out := append([]AuditEvent(nil), s.doc.Audit[start:]...)
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out
}
func (s *Store) persistLocked() error {
if strings.TrimSpace(s.path) == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil && filepath.Dir(s.path) != "." {
return err
}
data, err := json.MarshalIndent(s.doc, "", " ")
if err != nil {
return err
}
temp := s.path + ".tmp"
if err := os.WriteFile(temp, data, 0o600); err != nil {
return err
}
return os.Rename(temp, s.path)
}
func (s *Store) cleanupSessionsLocked(now time.Time) {
for key, session := range s.doc.Sessions {
if session.ExpiresAt <= now.Unix() {
delete(s.doc.Sessions, key)
}
}
}
func normalizeUsername(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func cloneLicense(record LicenseRecord) LicenseRecord {
record.Features = append([]string(nil), record.Features...)
record.Domains = append([]string(nil), record.Domains...)
record.InstanceIDs = append([]string(nil), record.InstanceIDs...)
if record.Limits != nil {
copy := make(map[string]int64, len(record.Limits))
for key, value := range record.Limits {
copy[key] = value
}
record.Limits = copy
}
return record
}
func validUsername(value string) bool {
if len(value) < 3 || len(value) > 120 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == '@' {
continue
}
return false
}
return true
}