This commit is contained in:
428
internal/store/store.go
Normal file
428
internal/store/store.go
Normal file
@@ -0,0 +1,428 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user