init
All checks were successful
release-tag / release-image (push) Successful in 2m3s

This commit is contained in:
2026-05-04 22:25:50 +02:00
parent be81c2bf92
commit 270c13af5b
21 changed files with 1839 additions and 1 deletions

428
internal/store/store.go Normal file
View 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
}