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 ©, 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 }