All checks were successful
release-tag / release-image (push) Successful in 2m3s
429 lines
12 KiB
Go
429 lines
12 KiB
Go
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
|
|
}
|