package store import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "sort" "strings" "sync" "time" ) type Role string const ( RoleAdmin Role = "admin" RoleEmployee Role = "employee" ) type User struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"display_name"` Role Role `json:"role"` PasswordHash string `json:"password_hash"` CreatedAt time.Time `json:"created_at"` } type Manufacturer struct { ID string `json:"id"` Name string `json:"name"` Website string `json:"website"` Notes string `json:"notes"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type Software struct { ID string `json:"id"` ManufacturerID string `json:"manufacturer_id"` Name string `json:"name"` Homepage string `json:"homepage"` Description string `json:"description"` Architectures []string `json:"architectures"` Channels []string `json:"channels"` DiscordChannelID string `json:"discord_channel_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type Vulnerability struct { CVE string `json:"cve"` Severity string `json:"severity"` Description string `json:"description"` Reference string `json:"reference"` } type Release struct { ID string `json:"id"` SoftwareID string `json:"software_id"` Version string `json:"version"` Channel string `json:"channel"` Architecture string `json:"architecture"` ReleaseDate string `json:"release_date"` DownloadURL string `json:"download_url"` ReleaseURL string `json:"release_url"` Info string `json:"info"` Vulnerabilities []Vulnerability `json:"vulnerabilities"` CreatedBy string `json:"created_by"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type DiscordSubscriber struct { UserID string `json:"user_id"` Username string `json:"username"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type AuditEntry struct { ID string `json:"id"` ActorID string `json:"actor_id"` Action string `json:"action"` Entity string `json:"entity"` EntityID string `json:"entity_id"` Message string `json:"message"` CreatedAt time.Time `json:"created_at"` } type DB struct { Users []User `json:"users"` Manufacturers []Manufacturer `json:"manufacturers"` Software []Software `json:"software"` Releases []Release `json:"releases"` DiscordSubscribers []DiscordSubscriber `json:"discord_subscribers"` Audit []AuditEntry `json:"audit"` } type FileStore struct { mu sync.RWMutex path string db DB } var ErrNotFound = errors.New("not found") func Open(path string) (*FileStore, error) { fs := &FileStore{path: path} if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { fs.db = DB{} return fs, fs.saveLocked() } if err != nil { return nil, err } if len(strings.TrimSpace(string(b))) == 0 { return fs, nil } if err := json.Unmarshal(b, &fs.db); err != nil { return nil, err } return fs, nil } func (s *FileStore) saveLocked() error { tmp := s.path + ".tmp" b, err := json.MarshalIndent(s.db, "", " ") if err != nil { return err } if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, s.path) } func NewID() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } func CSV(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) seen := map[string]bool{} for _, p := range parts { v := strings.TrimSpace(p) if v != "" && !seen[strings.ToLower(v)] { out = append(out, v) seen[strings.ToLower(v)] = true } } return out } func (s *FileStore) EnsureUser(u User) error { s.mu.Lock() defer s.mu.Unlock() for _, existing := range s.db.Users { if strings.EqualFold(existing.Email, u.Email) { return nil } } if u.ID == "" { u.ID = NewID() } if u.CreatedAt.IsZero() { u.CreatedAt = time.Now().UTC() } s.db.Users = append(s.db.Users, u) return s.saveLocked() } func (s *FileStore) FindUserByEmail(email string) (User, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, u := range s.db.Users { if strings.EqualFold(u.Email, email) { return u, true } } return User{}, false } func (s *FileStore) FindUserByID(id string) (User, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, u := range s.db.Users { if u.ID == id { return u, true } } return User{}, false } func (s *FileStore) ListManufacturers() []Manufacturer { s.mu.RLock() defer s.mu.RUnlock() out := append([]Manufacturer(nil), s.db.Manufacturers...) sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out } func (s *FileStore) FindManufacturer(id string) (Manufacturer, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, m := range s.db.Manufacturers { if m.ID == id { return m, true } } return Manufacturer{}, false } func (s *FileStore) UpsertManufacturer(m Manufacturer, actor string) (Manufacturer, error) { s.mu.Lock() defer s.mu.Unlock() now := time.Now().UTC() if m.ID == "" { m.ID = NewID() m.CreatedAt = now } m.UpdatedAt = now for i := range s.db.Manufacturers { if s.db.Manufacturers[i].ID == m.ID { s.db.Manufacturers[i] = m s.auditLocked(actor, "update", "manufacturer", m.ID, m.Name) return m, s.saveLocked() } } s.db.Manufacturers = append(s.db.Manufacturers, m) s.auditLocked(actor, "create", "manufacturer", m.ID, m.Name) return m, s.saveLocked() } func (s *FileStore) ListSoftware() []Software { s.mu.RLock() defer s.mu.RUnlock() out := append([]Software(nil), s.db.Software...) sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out } func (s *FileStore) FindSoftware(id string) (Software, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, sw := range s.db.Software { if sw.ID == id { return sw, true } } return Software{}, false } func (s *FileStore) SetSoftwareDiscordChannelID(softwareID, channelID, actor string) (Software, error) { s.mu.Lock() defer s.mu.Unlock() softwareID = strings.TrimSpace(softwareID) channelID = strings.TrimSpace(channelID) if softwareID == "" { return Software{}, errors.New("software id is required") } for i := range s.db.Software { if s.db.Software[i].ID == softwareID { s.db.Software[i].DiscordChannelID = channelID s.db.Software[i].UpdatedAt = time.Now().UTC() s.auditLocked(actor, "update", "software_discord_channel", softwareID, channelID) return s.db.Software[i], s.saveLocked() } } return Software{}, ErrNotFound } func (s *FileStore) UpsertSoftware(sw Software, actor string) (Software, error) { s.mu.Lock() defer s.mu.Unlock() now := time.Now().UTC() if sw.ID == "" { sw.ID = NewID() sw.CreatedAt = now } sw.UpdatedAt = now for i := range s.db.Software { if s.db.Software[i].ID == sw.ID { s.db.Software[i] = sw s.auditLocked(actor, "update", "software", sw.ID, sw.Name) return sw, s.saveLocked() } } s.db.Software = append(s.db.Software, sw) s.auditLocked(actor, "create", "software", sw.ID, sw.Name) return sw, s.saveLocked() } func (s *FileStore) ListReleases() []Release { s.mu.RLock() defer s.mu.RUnlock() out := append([]Release(nil), s.db.Releases...) sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out } func (s *FileStore) UpsertRelease(r Release, actor string) (Release, error) { s.mu.Lock() defer s.mu.Unlock() now := time.Now().UTC() if r.ID == "" { r.ID = NewID() r.CreatedAt = now r.CreatedBy = actor } r.UpdatedAt = now for i := range s.db.Releases { if s.db.Releases[i].ID == r.ID { s.db.Releases[i] = r s.auditLocked(actor, "update", "release", r.ID, r.Version) return r, s.saveLocked() } } s.db.Releases = append(s.db.Releases, r) s.auditLocked(actor, "create", "release", r.ID, r.Version) return r, s.saveLocked() } func (s *FileStore) Dashboard() (int, int, int, []Release) { s.mu.RLock() defer s.mu.RUnlock() releases := append([]Release(nil), s.db.Releases...) sort.Slice(releases, func(i, j int) bool { return releases[i].CreatedAt.After(releases[j].CreatedAt) }) if len(releases) > 10 { releases = releases[:10] } return len(s.db.Manufacturers), len(s.db.Software), len(s.db.Releases), releases } func (s *FileStore) AuditLog(limit int) []AuditEntry { s.mu.RLock() defer s.mu.RUnlock() out := append([]AuditEntry(nil), s.db.Audit...) sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) if limit > 0 && len(out) > limit { out = out[:limit] } return out } func (s *FileStore) auditLocked(actor, action, entity, id, msg string) { s.db.Audit = append(s.db.Audit, AuditEntry{ID: NewID(), ActorID: actor, Action: action, Entity: entity, EntityID: id, Message: msg, CreatedAt: time.Now().UTC()}) } func (s *FileStore) ListUsers() []User { s.mu.RLock() defer s.mu.RUnlock() out := append([]User(nil), s.db.Users...) sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Email) < strings.ToLower(out[j].Email) }) return out } func (s *FileStore) CreateUser(u User, actor string) (User, error) { s.mu.Lock() defer s.mu.Unlock() for _, existing := range s.db.Users { if strings.EqualFold(existing.Email, u.Email) { return User{}, errors.New("user already exists") } } if u.ID == "" { u.ID = NewID() } if u.CreatedAt.IsZero() { u.CreatedAt = time.Now().UTC() } s.db.Users = append(s.db.Users, u) s.auditLocked(actor, "create", "user", u.ID, u.Email) return u, s.saveLocked() } func (s *FileStore) UpsertDiscordSubscriber(userID, username string) error { s.mu.Lock() defer s.mu.Unlock() userID = strings.TrimSpace(userID) username = strings.TrimSpace(username) if userID == "" { return errors.New("discord user id is required") } now := time.Now().UTC() for i := range s.db.DiscordSubscribers { if s.db.DiscordSubscribers[i].UserID == userID { s.db.DiscordSubscribers[i].Username = username s.db.DiscordSubscribers[i].UpdatedAt = now s.auditLocked("discord", "update", "discord_subscriber", userID, username) return s.saveLocked() } } s.db.DiscordSubscribers = append(s.db.DiscordSubscribers, DiscordSubscriber{UserID: userID, Username: username, CreatedAt: now, UpdatedAt: now}) s.auditLocked("discord", "create", "discord_subscriber", userID, username) return s.saveLocked() } func (s *FileStore) RemoveDiscordSubscriber(userID string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.db.DiscordSubscribers { if s.db.DiscordSubscribers[i].UserID == userID { s.db.DiscordSubscribers = append(s.db.DiscordSubscribers[:i], s.db.DiscordSubscribers[i+1:]...) s.auditLocked("discord", "delete", "discord_subscriber", userID, "unsubscribe") return s.saveLocked() } } return nil } func (s *FileStore) ListDiscordSubscribers() []DiscordSubscriber { s.mu.RLock() defer s.mu.RUnlock() out := append([]DiscordSubscriber(nil), s.db.DiscordSubscribers...) sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username) }) return out }