This commit is contained in:
143
internal/auth/auth.go
Normal file
143
internal/auth/auth.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"releasewatcher/internal/store"
|
||||
)
|
||||
|
||||
const CookieName = "rw_session"
|
||||
|
||||
func randomBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return b
|
||||
}
|
||||
|
||||
func HashPassword(password string) string {
|
||||
salt := randomBytes(16)
|
||||
iters := 210000
|
||||
dk := pbkdf2Key([]byte(password), salt, iters, 32, sha256.New)
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", iters, hex.EncodeToString(salt), hex.EncodeToString(dk))
|
||||
}
|
||||
|
||||
func VerifyPassword(password, encoded string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iters, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
salt, err := hex.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := hex.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := pbkdf2Key([]byte(password), salt, iters, len(want), sha256.New)
|
||||
return hmac.Equal(got, want)
|
||||
}
|
||||
|
||||
func pbkdf2Key(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte {
|
||||
prf := hmac.New(h, password)
|
||||
hLen := prf.Size()
|
||||
numBlocks := (keyLen + hLen - 1) / hLen
|
||||
var dk []byte
|
||||
for block := 1; block <= numBlocks; block++ {
|
||||
prf.Reset()
|
||||
prf.Write(salt)
|
||||
prf.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
|
||||
u := prf.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for i := 1; i < iter; i++ {
|
||||
prf.Reset()
|
||||
prf.Write(u)
|
||||
u = prf.Sum(nil)
|
||||
for x := range t {
|
||||
t[x] ^= u[x]
|
||||
}
|
||||
}
|
||||
dk = append(dk, t...)
|
||||
}
|
||||
return dk[:keyLen]
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Secret []byte
|
||||
Store *store.FileStore
|
||||
}
|
||||
|
||||
func New(secret string, st *store.FileStore) *Manager {
|
||||
if secret == "" {
|
||||
secret = base64.RawURLEncoding.EncodeToString(randomBytes(32))
|
||||
}
|
||||
return &Manager{Secret: []byte(secret), Store: st}
|
||||
}
|
||||
|
||||
func (m *Manager) Sign(userID string) string {
|
||||
exp := time.Now().Add(12 * time.Hour).Unix()
|
||||
payload := fmt.Sprintf("%s.%d.%s", userID, exp, base64.RawURLEncoding.EncodeToString(randomBytes(12)))
|
||||
mac := hmac.New(sha256.New, m.Secret)
|
||||
mac.Write([]byte(payload))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload + "." + sig))
|
||||
}
|
||||
|
||||
func (m *Manager) Parse(token string) (store.User, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return store.User{}, err
|
||||
}
|
||||
parts := strings.Split(string(decoded), ".")
|
||||
if len(parts) != 4 {
|
||||
return store.User{}, errors.New("invalid token")
|
||||
}
|
||||
payload := strings.Join(parts[:3], ".")
|
||||
mac := hmac.New(sha256.New, m.Secret)
|
||||
mac.Write([]byte(payload))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(want), []byte(parts[3])) {
|
||||
return store.User{}, errors.New("bad signature")
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp {
|
||||
return store.User{}, errors.New("expired")
|
||||
}
|
||||
u, ok := m.Store.FindUserByID(parts[0])
|
||||
if !ok {
|
||||
return store.User{}, errors.New("unknown user")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *Manager) CurrentUser(r *http.Request) (store.User, bool) {
|
||||
c, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
}
|
||||
u, err := m.Parse(c.Value)
|
||||
return u, err == nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetCookie(w http.ResponseWriter, userID string) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: m.Sign(userID), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: false, MaxAge: 12 * 60 * 60})
|
||||
}
|
||||
|
||||
func ClearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||
}
|
||||
507
internal/discordbot/bot.go
Normal file
507
internal/discordbot/bot.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package discordbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"releasewatcher/internal/store"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
GuildID string
|
||||
CategoryID string
|
||||
CategoryName string
|
||||
ReleaseMention string
|
||||
SendDMs bool
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
Store *store.FileStore
|
||||
DG *discordgo.Session
|
||||
Log *slog.Logger
|
||||
Config Config
|
||||
}
|
||||
|
||||
func NewDiscordSession(token string) (*discordgo.Session, error) {
|
||||
dg, err := discordgo.New("Bot " + token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dg.Identify.Intents = 0
|
||||
return dg, nil
|
||||
}
|
||||
|
||||
func NewServices(st *store.FileStore, dg *discordgo.Session, log *slog.Logger, cfg Config) *Services {
|
||||
if cfg.CategoryName == "" {
|
||||
cfg.CategoryName = "ReleaseWatcher"
|
||||
}
|
||||
return &Services{Store: st, DG: dg, Log: log, Config: cfg}
|
||||
}
|
||||
|
||||
func (s *Services) AddSubscriber(ctx context.Context, userID, username string) error {
|
||||
if s.Store == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
return s.Store.UpsertDiscordSubscriber(userID, username)
|
||||
}
|
||||
|
||||
func (s *Services) RemoveSubscriber(ctx context.Context, userID string) error {
|
||||
if s.Store == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
return s.Store.RemoveDiscordSubscriber(userID)
|
||||
}
|
||||
|
||||
func (s *Services) SendDM(ctx context.Context, userID, message string) error {
|
||||
if s.DG == nil {
|
||||
return fmt.Errorf("discord session is nil")
|
||||
}
|
||||
ch, err := s.DG.UserChannelCreate(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.DG.ChannelMessageSend(ch.ID, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Services) EnsureSoftwareChannel(ctx context.Context, software store.Software, manufacturer store.Manufacturer) (string, error) {
|
||||
if s.DG == nil {
|
||||
return "", nil
|
||||
}
|
||||
if s.Config.GuildID == "" {
|
||||
return "", fmt.Errorf("RW_DISCORD_GUILD_ID fehlt; Software-Kanal kann nicht erstellt werden")
|
||||
}
|
||||
if strings.TrimSpace(software.DiscordChannelID) != "" {
|
||||
return software.DiscordChannelID, nil
|
||||
}
|
||||
|
||||
categoryID, err := s.ensureCategory(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
channelName := channelName(manufacturer.Name, software.Name)
|
||||
if channelName == "" {
|
||||
channelName = "release-" + software.ID[:min(len(software.ID), 8)]
|
||||
}
|
||||
|
||||
chs, err := s.DG.GuildChannels(s.Config.GuildID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, ch := range chs {
|
||||
if ch.Type == discordgo.ChannelTypeGuildText && ch.Name == channelName && ch.ParentID == categoryID {
|
||||
return ch.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
created, err := s.DG.GuildChannelCreateComplex(s.Config.GuildID, discordgo.GuildChannelCreateData{
|
||||
Name: channelName,
|
||||
Type: discordgo.ChannelTypeGuildText,
|
||||
ParentID: categoryID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Log != nil {
|
||||
s.Log.InfoContext(ctx, "discord software channel created", "software", software.Name, "manufacturer", manufacturer.Name, "channel_id", created.ID, "channel_name", channelName)
|
||||
}
|
||||
_, _ = s.DG.ChannelMessageSend(created.ID, fmt.Sprintf("📦 Release-Kanal für **%s - %s** wurde angelegt.", manufacturer.Name, software.Name))
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func (s *Services) ensureCategory(ctx context.Context) (string, error) {
|
||||
if s.Config.CategoryID != "" {
|
||||
return s.Config.CategoryID, nil
|
||||
}
|
||||
chs, err := s.DG.GuildChannels(s.Config.GuildID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, ch := range chs {
|
||||
if ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, s.Config.CategoryName) {
|
||||
return ch.ID, nil
|
||||
}
|
||||
}
|
||||
created, err := s.DG.GuildChannelCreateComplex(s.Config.GuildID, discordgo.GuildChannelCreateData{
|
||||
Name: s.Config.CategoryName,
|
||||
Type: discordgo.ChannelTypeGuildCategory,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Log != nil {
|
||||
s.Log.InfoContext(ctx, "discord category created", "category_id", created.ID, "category_name", s.Config.CategoryName)
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func (s *Services) NotifyReleaseCreated(ctx context.Context, release store.Release, software store.Software) error {
|
||||
if s.Store == nil || s.DG == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
|
||||
manufacturer, _ := s.Store.FindManufacturer(software.ManufacturerID)
|
||||
if software.DiscordChannelID == "" && s.Config.GuildID != "" {
|
||||
chID, err := s.EnsureSoftwareChannel(ctx, software, manufacturer)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("software channel: %w", err))
|
||||
} else if chID != "" {
|
||||
updated, err := s.Store.SetSoftwareDiscordChannelID(software.ID, chID, "discord")
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("store channel id: %w", err))
|
||||
} else {
|
||||
software = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
if software.DiscordChannelID != "" {
|
||||
if err := s.SendReleaseToChannel(ctx, software.DiscordChannelID, release, software, manufacturer); err != nil {
|
||||
errs = append(errs, fmt.Errorf("channel ping: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if s.Config.SendDMs {
|
||||
subscribers := s.Store.ListDiscordSubscribers()
|
||||
msg := releaseMessage(release, software, manufacturer)
|
||||
var failed int
|
||||
for _, sub := range subscribers {
|
||||
if err := s.SendDM(ctx, sub.UserID, msg); err != nil {
|
||||
failed++
|
||||
if s.Log != nil {
|
||||
s.Log.WarnContext(ctx, "discord dm failed", "discord_user_id", sub.UserID, "username", sub.Username, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
errs = append(errs, fmt.Errorf("%d von %d Discord-DMs konnten nicht gesendet werden", failed, len(subscribers)))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *Services) SendReleaseToChannel(ctx context.Context, channelID string, release store.Release, software store.Software, manufacturer store.Manufacturer) error {
|
||||
if s.DG == nil || channelID == "" {
|
||||
return nil
|
||||
}
|
||||
content := strings.TrimSpace(s.Config.ReleaseMention)
|
||||
embed := releaseEmbed(release, software, manufacturer)
|
||||
_, err := s.DG.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
||||
Content: content,
|
||||
Embed: embed,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Log != nil {
|
||||
s.Log.InfoContext(ctx, "discord release channel ping sent", "software", software.Name, "version", release.Version, "channel_id", channelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseEmbed(release store.Release, software store.Software, manufacturer store.Manufacturer) *discordgo.MessageEmbed {
|
||||
title := fmt.Sprintf("🚀 Neues Release: %s %s", software.Name, release.Version)
|
||||
if manufacturer.Name != "" {
|
||||
title = fmt.Sprintf("🚀 Neues Release: %s - %s %s", manufacturer.Name, software.Name, release.Version)
|
||||
}
|
||||
fields := []*discordgo.MessageEmbedField{}
|
||||
addField := func(name, value string, inline bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
fields = append(fields, &discordgo.MessageEmbedField{Name: name, Value: value, Inline: inline})
|
||||
}
|
||||
}
|
||||
addField("Channel", codeOrDash(release.Channel), true)
|
||||
addField("Architektur", codeOrDash(release.Architecture), true)
|
||||
addField("Release-Datum", emptyDash(release.ReleaseDate), true)
|
||||
if len(release.Vulnerabilities) > 0 {
|
||||
addField("Schwachstellen", vulnerabilitySummary(release.Vulnerabilities), false)
|
||||
}
|
||||
if release.ReleaseURL != "" {
|
||||
addField("Infos", release.ReleaseURL, false)
|
||||
}
|
||||
if release.DownloadURL != "" {
|
||||
addField("Download", release.DownloadURL, false)
|
||||
}
|
||||
return &discordgo.MessageEmbed{
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(release.Info),
|
||||
Color: 0x57F287,
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
func releaseMessage(release store.Release, software store.Software, manufacturer store.Manufacturer) string {
|
||||
var b strings.Builder
|
||||
prefix := software.Name
|
||||
if manufacturer.Name != "" {
|
||||
prefix = manufacturer.Name + " - " + software.Name
|
||||
}
|
||||
fmt.Fprintf(&b, "🚀 Neues Release veröffentlicht: **%s %s**\n", prefix, release.Version)
|
||||
if release.Channel != "" {
|
||||
fmt.Fprintf(&b, "Channel: `%s`\n", release.Channel)
|
||||
}
|
||||
if release.Architecture != "" {
|
||||
fmt.Fprintf(&b, "Architektur: `%s`\n", release.Architecture)
|
||||
}
|
||||
if release.ReleaseDate != "" {
|
||||
fmt.Fprintf(&b, "Release-Datum: %s\n", release.ReleaseDate)
|
||||
}
|
||||
if release.Info != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", release.Info)
|
||||
}
|
||||
if len(release.Vulnerabilities) > 0 {
|
||||
b.WriteString("\nSchwachstellen:\n")
|
||||
for _, v := range release.Vulnerabilities {
|
||||
label := strings.TrimSpace(v.CVE)
|
||||
if label == "" {
|
||||
label = "Eintrag"
|
||||
}
|
||||
if v.Severity != "" {
|
||||
label += " (" + v.Severity + ")"
|
||||
}
|
||||
fmt.Fprintf(&b, "• %s", label)
|
||||
if v.Reference != "" {
|
||||
fmt.Fprintf(&b, " – %s", v.Reference)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if release.ReleaseURL != "" {
|
||||
fmt.Fprintf(&b, "\nInfos: %s", release.ReleaseURL)
|
||||
}
|
||||
if release.DownloadURL != "" {
|
||||
fmt.Fprintf(&b, "\nDownload: %s", release.DownloadURL)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func vulnerabilitySummary(vulns []store.Vulnerability) string {
|
||||
var lines []string
|
||||
for _, v := range vulns {
|
||||
label := strings.TrimSpace(v.CVE)
|
||||
if label == "" {
|
||||
label = "Eintrag"
|
||||
}
|
||||
if v.Severity != "" {
|
||||
label += " (" + v.Severity + ")"
|
||||
}
|
||||
if v.Reference != "" {
|
||||
label += " – " + v.Reference
|
||||
}
|
||||
lines = append(lines, "• "+label)
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if len(out) > 1000 {
|
||||
out = out[:997] + "..."
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func codeOrDash(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return "`" + v + "`"
|
||||
}
|
||||
|
||||
func emptyDash(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func channelName(manufacturer, software string) string {
|
||||
raw := strings.TrimSpace(manufacturer + "-" + software)
|
||||
repl := strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", "Ä", "ae", "Ö", "oe", "Ü", "ue")
|
||||
raw = repl.Replace(raw)
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range strings.ToLower(raw) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
case unicode.IsSpace(r), r == '-', r == '_', r == '.', r == '/', r == '\\':
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
default:
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if len(out) > 90 {
|
||||
out = strings.Trim(out[:90], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func AttachDiscordHandlers(dg *discordgo.Session, svc *Services) {
|
||||
dg.AddHandler(func(_ *discordgo.Session, g *discordgo.GuildCreate) {
|
||||
chID := findWritableTextChannel(dg, g.Guild)
|
||||
if chID == "" {
|
||||
return
|
||||
}
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "👋 Willkommen!",
|
||||
Description: "Ich kann dir Updates per DM schicken.\n\n• Tippe `/subscribe` oder\n• klicke den Button unten, um eine DM mit mir zu starten.",
|
||||
Color: 0x5865F2,
|
||||
}
|
||||
components := []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{CustomID: "start_dm", Label: "DM starten", Style: discordgo.PrimaryButton},
|
||||
}},
|
||||
}
|
||||
_, _ = dg.ChannelMessageSendComplex(chID, &discordgo.MessageSend{Embed: embed, Components: components})
|
||||
})
|
||||
|
||||
dg.AddHandler(func(_ *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.Type == discordgo.InteractionMessageComponent && i.MessageComponentData().CustomID == "start_dm" {
|
||||
u := actor(i)
|
||||
if u == nil {
|
||||
respondEphemeral(dg, i, "Konnte dich nicht ermitteln.")
|
||||
return
|
||||
}
|
||||
if err := svc.SendDM(context.Background(), u.ID, "Hey! Schön, dass du da bist. Ab jetzt kann ich dir DMs schicken. ✨"); err != nil {
|
||||
respondEphemeral(dg, i, "DM konnte nicht zugestellt werden (Privacy-Einstellungen?).")
|
||||
return
|
||||
}
|
||||
_ = svc.AddSubscriber(context.Background(), u.ID, userLabel(u))
|
||||
respondEphemeral(dg, i, "Ich habe dir eine DM geschickt und dich zu den Empfängern hinzugefügt. 👍")
|
||||
return
|
||||
}
|
||||
|
||||
if i.Type != discordgo.InteractionApplicationCommand {
|
||||
return
|
||||
}
|
||||
cmd := i.ApplicationCommandData()
|
||||
|
||||
switch cmd.CommandType {
|
||||
case discordgo.UserApplicationCommand:
|
||||
targetID := cmd.TargetID
|
||||
if targetID == "" {
|
||||
respondEphemeral(dg, i, "Kein Zielbenutzer erhalten.")
|
||||
return
|
||||
}
|
||||
username := ""
|
||||
if cmd.Resolved != nil {
|
||||
if u, ok := cmd.Resolved.Users[targetID]; ok && u != nil {
|
||||
username = userLabel(u)
|
||||
}
|
||||
}
|
||||
if err := svc.AddSubscriber(context.Background(), targetID, username); err != nil {
|
||||
respondEphemeral(dg, i, "Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondEphemeral(dg, i, "✅ Nutzer wurde zu den Empfängern hinzugefügt.")
|
||||
|
||||
case discordgo.ChatApplicationCommand:
|
||||
switch cmd.Name {
|
||||
case "subscribe":
|
||||
u := actor(i)
|
||||
if u == nil {
|
||||
respondEphemeral(dg, i, "Konnte deinen Benutzer nicht ermitteln.")
|
||||
return
|
||||
}
|
||||
if err := svc.AddSubscriber(context.Background(), u.ID, userLabel(u)); err != nil {
|
||||
respondEphemeral(dg, i, "Fehler beim Subscribe: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondEphemeral(dg, i, "✅ Du erhältst nun DMs. Mit `/unsubscribe` meldest du dich ab. Beachte: dieser Bot liest keine eingehenden Nachrichten.")
|
||||
case "unsubscribe":
|
||||
u := actor(i)
|
||||
if u == nil {
|
||||
respondEphemeral(dg, i, "Konnte deinen Benutzer nicht ermitteln.")
|
||||
return
|
||||
}
|
||||
if err := svc.RemoveSubscriber(context.Background(), u.ID); err != nil {
|
||||
respondEphemeral(dg, i, "Fehler beim Unsubscribe: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondEphemeral(dg, i, "✅ Du erhältst keine DMs mehr.")
|
||||
}
|
||||
default:
|
||||
log.Printf("Unbekannter CommandType: %v (%q)", cmd.CommandType, cmd.Name)
|
||||
respondEphemeral(dg, i, "Unbekannter Command-Typ.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func UpsertCommands(s *discordgo.Session, appID string) error {
|
||||
perms := int64(discordgo.PermissionManageGuild)
|
||||
_, err := s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
|
||||
Name: "Zu Empfängern hinzufügen", Type: discordgo.UserApplicationCommand,
|
||||
DefaultMemberPermissions: &perms, DMPermission: ptrBool(false),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
|
||||
Name: "subscribe", Description: "Opt-in: Nachrichten per DM erhalten", Type: discordgo.ChatApplicationCommand,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
|
||||
Name: "unsubscribe", Description: "Opt-out: Keine DMs mehr erhalten", Type: discordgo.ChatApplicationCommand,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
|
||||
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: content, Flags: discordgo.MessageFlagsEphemeral},
|
||||
})
|
||||
}
|
||||
|
||||
func userLabel(u *discordgo.User) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
if u.Discriminator != "" && u.Discriminator != "0" {
|
||||
return u.Username + "#" + u.Discriminator
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func actor(i *discordgo.InteractionCreate) *discordgo.User {
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
return i.Member.User
|
||||
}
|
||||
return i.User
|
||||
}
|
||||
|
||||
func findWritableTextChannel(s *discordgo.Session, g *discordgo.Guild) string {
|
||||
chs, err := s.GuildChannels(g.ID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, c := range chs {
|
||||
if c.Type == discordgo.ChannelTypeGuildText {
|
||||
perms, err := s.State.UserChannelPermissions(s.State.User.ID, c.ID)
|
||||
if err == nil && (perms&discordgo.PermissionSendMessages) != 0 {
|
||||
return c.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
41
internal/notify/notify.go
Normal file
41
internal/notify/notify.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"releasewatcher/internal/store"
|
||||
)
|
||||
|
||||
type ReleaseNotifier interface {
|
||||
NotifyReleaseCreated(ctx context.Context, release store.Release, software store.Software) error
|
||||
}
|
||||
|
||||
// SoftwareChannelProvisioner ist optional. Implementierungen können beim Anlegen
|
||||
// einer Software automatisch einen Zielkanal bereitstellen, z. B. in Discord.
|
||||
type SoftwareChannelProvisioner interface {
|
||||
EnsureSoftwareChannel(ctx context.Context, software store.Software, manufacturer store.Manufacturer) (channelID string, err error)
|
||||
}
|
||||
|
||||
type NoopNotifier struct{}
|
||||
|
||||
func (NoopNotifier) NotifyReleaseCreated(ctx context.Context, release store.Release, software store.Software) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type LoggingNotifier struct{ Log *slog.Logger }
|
||||
|
||||
func (n LoggingNotifier) NotifyReleaseCreated(ctx context.Context, release store.Release, software store.Software) error {
|
||||
if n.Log != nil {
|
||||
n.Log.InfoContext(ctx, "release notification", "software", software.Name, "version", release.Version, "channel", release.Channel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscordWebhookNotifier ist absichtlich nur als Adapter-Schnittstelle vorbereitet.
|
||||
// Implementiere hier später deinen Bot/Webhook-Aufruf und binde ihn in cmd/releasewatcher/main.go ein.
|
||||
type DiscordWebhookNotifier struct{}
|
||||
|
||||
func (DiscordWebhookNotifier) NotifyReleaseCreated(ctx context.Context, release store.Release, software store.Software) error {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
298
internal/web/server.go
Normal file
298
internal/web/server.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"releasewatcher/internal/auth"
|
||||
"releasewatcher/internal/notify"
|
||||
"releasewatcher/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Store *store.FileStore
|
||||
Auth *auth.Manager
|
||||
Notifier notify.ReleaseNotifier
|
||||
Log *slog.Logger
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const userKey ctxKey = "user"
|
||||
|
||||
type ViewData struct {
|
||||
Title string
|
||||
User store.User
|
||||
Error string
|
||||
Info string
|
||||
Manufacturers []store.Manufacturer
|
||||
Software []store.Software
|
||||
Releases []store.Release
|
||||
Audit []store.AuditEntry
|
||||
Users []store.User
|
||||
DiscordSubscribers []store.DiscordSubscriber
|
||||
ManufacturerCount int
|
||||
SoftwareCount int
|
||||
ReleaseCount int
|
||||
}
|
||||
|
||||
func New(st *store.FileStore, am *auth.Manager, notifier notify.ReleaseNotifier, log *slog.Logger) (*Server, error) {
|
||||
funcs := template.FuncMap{
|
||||
"join": strings.Join,
|
||||
"softwareName": func(id string, list []store.Software) string {
|
||||
for _, s := range list {
|
||||
if s.ID == id {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
return id
|
||||
},
|
||||
"manufacturerName": func(id string, list []store.Manufacturer) string {
|
||||
for _, m := range list {
|
||||
if m.ID == id {
|
||||
return m.Name
|
||||
}
|
||||
}
|
||||
return id
|
||||
},
|
||||
}
|
||||
tpl, err := template.New("layout.html").Funcs(funcs).ParseFiles("templates/layout.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notifier == nil {
|
||||
notifier = notify.NoopNotifier{}
|
||||
}
|
||||
return &Server{Store: st, Auth: am, Notifier: notifier, Log: log, tpl: tpl}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
mux.HandleFunc("/login", s.login)
|
||||
mux.HandleFunc("/logout", s.logout)
|
||||
mux.Handle("/", s.requireLogin(http.HandlerFunc(s.dashboard)))
|
||||
mux.Handle("/manufacturers", s.requireLogin(http.HandlerFunc(s.manufacturers)))
|
||||
mux.Handle("/software", s.requireLogin(http.HandlerFunc(s.software)))
|
||||
mux.Handle("/releases", s.requireLogin(http.HandlerFunc(s.releases)))
|
||||
mux.Handle("/audit", s.requireRole(store.RoleAdmin, http.HandlerFunc(s.audit)))
|
||||
mux.Handle("/users", s.requireRole(store.RoleAdmin, http.HandlerFunc(s.users)))
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireLogin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := s.Auth.CurrentUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireRole(role store.Role, next http.Handler) http.Handler {
|
||||
return s.requireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u.Role != role {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) store.User {
|
||||
u, _ := r.Context().Value(userKey).(store.User)
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data ViewData) {
|
||||
data.User = currentUser(r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
tpl, err := s.tpl.Clone()
|
||||
if err != nil {
|
||||
s.Log.Error("clone template", "error", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := tpl.ParseFiles("templates/" + name); err != nil {
|
||||
s.Log.Error("parse template", "name", name, "error", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
s.Log.Error("render", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
s.render(w, r, "login.html", ViewData{Title: "Login"})
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
u, ok := s.Store.FindUserByEmail(email)
|
||||
if !ok || !auth.VerifyPassword(password, u.PasswordHash) {
|
||||
s.render(w, r, "login.html", ViewData{Title: "Login", Error: "E-Mail oder Passwort ist falsch."})
|
||||
return
|
||||
}
|
||||
s.Auth.SetCookie(w, u.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
auth.ClearCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
mc, sc, rc, rel := s.Store.Dashboard()
|
||||
s.render(w, r, "dashboard.html", ViewData{Title: "Dashboard", ManufacturerCount: mc, SoftwareCount: sc, ReleaseCount: rc, Releases: rel, Software: s.Store.ListSoftware()})
|
||||
}
|
||||
|
||||
func (s *Server) manufacturers(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
_, err := s.Store.UpsertManufacturer(store.Manufacturer{Name: strings.TrimSpace(r.FormValue("name")), Website: strings.TrimSpace(r.FormValue("website")), Notes: strings.TrimSpace(r.FormValue("notes"))}, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manufacturers", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, "manufacturers.html", ViewData{Title: "Hersteller", Manufacturers: s.Store.ListManufacturers()})
|
||||
}
|
||||
|
||||
func (s *Server) software(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
sw, err := s.Store.UpsertSoftware(store.Software{ManufacturerID: r.FormValue("manufacturer_id"), Name: strings.TrimSpace(r.FormValue("name")), Homepage: strings.TrimSpace(r.FormValue("homepage")), Description: strings.TrimSpace(r.FormValue("description")), Architectures: store.CSV(r.FormValue("architectures")), Channels: store.CSV(r.FormValue("channels"))}, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if provisioner, ok := s.Notifier.(notify.SoftwareChannelProvisioner); ok {
|
||||
if manufacturer, ok := s.Store.FindManufacturer(sw.ManufacturerID); ok {
|
||||
channelID, err := provisioner.EnsureSoftwareChannel(r.Context(), sw, manufacturer)
|
||||
if err != nil {
|
||||
if s.Log != nil {
|
||||
s.Log.WarnContext(r.Context(), "discord software channel provisioning failed", "software", sw.ID, "error", err)
|
||||
}
|
||||
} else if channelID != "" && sw.DiscordChannelID == "" {
|
||||
if _, err := s.Store.SetSoftwareDiscordChannelID(sw.ID, channelID, user.ID); err != nil && s.Log != nil {
|
||||
s.Log.WarnContext(r.Context(), "discord channel id could not be stored", "software", sw.ID, "channel_id", channelID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/software", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, "software.html", ViewData{Title: "Software", Manufacturers: s.Store.ListManufacturers(), Software: s.Store.ListSoftware()})
|
||||
}
|
||||
|
||||
func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if r.Method == http.MethodPost {
|
||||
if user.Role != store.RoleAdmin {
|
||||
http.Error(w, "Nur Admins dürfen neue Releases erstellen.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
vulns := parseVulns(r.FormValue("vulnerabilities"))
|
||||
rel, err := s.Store.UpsertRelease(store.Release{SoftwareID: r.FormValue("software_id"), Version: strings.TrimSpace(r.FormValue("version")), Channel: strings.TrimSpace(r.FormValue("channel")), Architecture: strings.TrimSpace(r.FormValue("architecture")), ReleaseDate: strings.TrimSpace(r.FormValue("release_date")), DownloadURL: strings.TrimSpace(r.FormValue("download_url")), ReleaseURL: strings.TrimSpace(r.FormValue("release_url")), Info: strings.TrimSpace(r.FormValue("info")), Vulnerabilities: vulns}, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if sw, ok := s.Store.FindSoftware(rel.SoftwareID); ok {
|
||||
if err := s.Notifier.NotifyReleaseCreated(r.Context(), rel, sw); err != nil && s.Log != nil {
|
||||
s.Log.WarnContext(r.Context(), "release notification failed", "release", rel.ID, "error", err)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/releases", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, "releases.html", ViewData{Title: "Releases", Releases: s.Store.ListReleases(), Software: s.Store.ListSoftware()})
|
||||
}
|
||||
|
||||
func parseVulns(input string) []store.Vulnerability {
|
||||
var out []store.Vulnerability
|
||||
for _, line := range strings.Split(input, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
v := store.Vulnerability{CVE: strings.TrimSpace(parts[0])}
|
||||
if len(parts) > 1 {
|
||||
v.Severity = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
v.Description = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
v.Reference = strings.TrimSpace(parts[3])
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) audit(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, "audit.html", ViewData{Title: "Audit", Audit: s.Store.AuditLog(200)})
|
||||
}
|
||||
|
||||
func (s *Server) users(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
role := store.Role(r.FormValue("role"))
|
||||
if role != store.RoleAdmin && role != store.RoleEmployee {
|
||||
role = store.RoleEmployee
|
||||
}
|
||||
_, err := s.Store.CreateUser(store.User{Email: strings.TrimSpace(r.FormValue("email")), DisplayName: strings.TrimSpace(r.FormValue("display_name")), Role: role, PasswordHash: auth.HashPassword(r.FormValue("password"))}, actor.ID)
|
||||
if err != nil {
|
||||
s.render(w, r, "users.html", ViewData{Title: "Benutzer", Users: s.Store.ListUsers(), DiscordSubscribers: s.Store.ListDiscordSubscribers(), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, "users.html", ViewData{Title: "Benutzer", Users: s.Store.ListUsers(), DiscordSubscribers: s.Store.ListDiscordSubscribers()})
|
||||
}
|
||||
Reference in New Issue
Block a user