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

View File

@@ -0,0 +1,51 @@
name: release-tag
on:
push:
branches:
- 'main'
jobs:
release-image:
runs-on: ubuntu-fast
env:
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
DOCKER_LATEST: latest
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with: # replace it with your local IP
config-inline: |
[registry."${{ vars.DOCKER_REGISTRY }}"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM golang:1.25.3 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/patchping ./cmd/releasewatcher
#FROM gcr.io/distroless/static:nonroot
FROM alpine:3.22.2
WORKDIR /
RUN mkdir /data
VOLUME ["/data"]
EXPOSE 8080
ENV RW_ADDR=":8080" \
RW_DATA="/data/releasewatcher.json" \
RW_ADMIN_EMAIL="admin@example.local" \
RW_ADMIN_PASSWORD="admin12345" \
RW_SECRET="change-me-in-production" \
RW_DISCORD_TOKEN="" \
RW_DISCORD_APP_ID="" \
RW_DISCORD_GUILD_ID="" \
RW_DISCORD_CATEGORY_ID="" \
RW_DISCORD_CATEGORY_NAME="" \
RW_DISCORD_RELEASE_MENTION="@here" \
RW_DISCORD_SEND_DMS="false"
COPY --from=build /out/patchping /patchping
ENTRYPOINT ["/patchping"]

130
README.md
View File

@@ -1,2 +1,130 @@
# patchpinglite # ReleaseWatcher
ReleaseWatcher ist eine webbasierte Go-Anwendung zur Pflege und Veröffentlichung von Software-Releases.
## Funktionen
- Hersteller, Software und Release-Historie verwalten
- Release-Felder: Software, Version, Channel, Architektur, Release-Datum, Download-Link, Release-Link, Informationen
- Schwachstellen je Release im Format `CVE | Schweregrad | Beschreibung | Referenz`
- Rollenmodell:
- `admin`: Releases erstellen, Benutzer anlegen, Audit-Log ansehen
- `employee`: Hersteller- und Softwaredaten pflegen, Releases ansehen
- HMAC-signierte Session-Cookies
- Passwort-Hashing mit PBKDF2-SHA256 ohne externe Abhängigkeiten
- JSON-Dateipersistenz mit atomarem Schreiben
- Audit-Log
- Discord-Bot-Integration:
- DMs per `/subscribe` und `/unsubscribe`
- automatische Software-Kanäle pro Software
- Release-Pings in den passenden Software-Kanal
## Start
```bash
go mod tidy
go run ./cmd/releasewatcher
```
Danach im Browser öffnen:
```text
http://localhost:8080
```
Standard-Admin:
```text
E-Mail: admin@example.local
Passwort: admin12345
```
## Produktion
Setze mindestens diese Variablen:
```bash
export RW_SECRET='lange-zufaellige-session-secret'
export RW_ADMIN_EMAIL='admin@firma.example'
export RW_ADMIN_PASSWORD='langes-sicheres-passwort'
export RW_ADDR=':8080'
export RW_DATA='data/releasewatcher.json'
```
Baue und starte:
```bash
go mod tidy
go build -o releasewatcher ./cmd/releasewatcher
./releasewatcher
```
Empfehlung: Hinter einem Reverse Proxy mit HTTPS betreiben. In `internal/auth/auth.go` kann `Secure: true` für Cookies aktiviert werden, sobald HTTPS erzwungen ist.
## Discord-Integration
Die Integration liegt in:
```text
internal/discordbot/bot.go
```
Aktivierung über Umgebungsvariablen:
```bash
export RW_DISCORD_TOKEN='dein-bot-token'
export RW_DISCORD_APP_ID='deine-discord-application-id'
export RW_DISCORD_GUILD_ID='deine-server-id'
export RW_DISCORD_CATEGORY_NAME='Software Releases'
# optional, wenn die Kategorie schon existiert und explizit genutzt werden soll:
# export RW_DISCORD_CATEGORY_ID='deine-kategorie-id'
# optionaler Ping-Text; Standard ist @here
export RW_DISCORD_RELEASE_MENTION='@here'
# optional DMs deaktivieren, Kanäle bleiben aktiv:
# export RW_DISCORD_SEND_DMS='false'
go run ./cmd/releasewatcher
```
Wenn `RW_DISCORD_TOKEN` gesetzt ist, startet die Webapp zusätzlich die Discord-Session. Wenn `RW_DISCORD_APP_ID` gesetzt ist, werden diese Commands registriert:
- `/subscribe`: Nutzer trägt sich selbst als DM-Empfänger ein
- `/unsubscribe`: Nutzer trägt sich aus
- User-Command `Zu Empfängern hinzufügen`: Nutzer mit Manage-Guild-Recht kann andere Empfänger hinzufügen
### Automatische Software-Kanäle
Wenn zusätzlich `RW_DISCORD_GUILD_ID` gesetzt ist, erstellt der Bot beim Anlegen einer Software automatisch einen Textkanal unter der konfigurierten Kategorie.
Das Kanalformat ist:
```text
hersteller-software
```
Beispiele:
```text
microsoft-edge
mozilla-firefox
adobe-acrobat-reader
```
Die erzeugte Discord-Channel-ID wird direkt am Software-Datensatz gespeichert (`discord_channel_id`). Beim Erstellen eines Releases postet der Bot dann einen Embed in genau diesen Kanal. Zusätzlich werden weiterhin DMs an Subscriber versendet, außer `RW_DISCORD_SEND_DMS=false` ist gesetzt.
Der Bot benötigt auf dem Discord-Server mindestens diese Rechte:
- `Manage Channels`, wenn Kategorien oder Kanäle automatisch erstellt werden sollen
- `Send Messages`
- `Embed Links`
- optional `Mention @everyone, @here, and All Roles`, wenn `RW_DISCORD_RELEASE_MENTION='@here'` oder Rollen-Mentions genutzt werden
## Datenhaltung
Die Anwendung speichert Daten standardmäßig in:
```text
data/releasewatcher.json
```
Für größere Installationen wäre der nächste professionelle Schritt ein Wechsel auf PostgreSQL oder SQLite mit Migrationen. Die Store-Schicht ist dafür bewusst gekapselt.

View File

@@ -0,0 +1,93 @@
package main
import (
"flag"
"log"
"log/slog"
"net/http"
"os"
"releasewatcher/internal/auth"
"releasewatcher/internal/discordbot"
"releasewatcher/internal/notify"
"releasewatcher/internal/store"
"releasewatcher/internal/web"
)
func main() {
addr := flag.String("addr", env("RW_ADDR", ":8080"), "HTTP listen address")
dataFile := flag.String("data", env("RW_DATA", "/data/releasewatcher.json"), "JSON data file")
adminEmail := flag.String("admin-email", env("RW_ADMIN_EMAIL", "admin@example.local"), "initial admin email")
adminPass := flag.String("admin-pass", env("RW_ADMIN_PASSWORD", "admin12345"), "initial admin password")
secret := flag.String("secret", env("RW_SECRET", "change-me-in-production"), "session secret")
discordToken := flag.String("discord-token", env("RW_DISCORD_TOKEN", ""), "Discord bot token; empty disables Discord")
discordAppID := flag.String("discord-app-id", env("RW_DISCORD_APP_ID", ""), "Discord application ID for command registration")
discordGuildID := flag.String("discord-guild-id", env("RW_DISCORD_GUILD_ID", ""), "Discord guild/server ID for release channels")
discordCategoryID := flag.String("discord-category-id", env("RW_DISCORD_CATEGORY_ID", ""), "Discord category ID for software release channels; empty creates/uses category name")
discordCategoryName := flag.String("discord-category-name", env("RW_DISCORD_CATEGORY_NAME", "ReleaseWatcher"), "Discord category name for software release channels")
discordReleaseMention := flag.String("discord-release-mention", env("RW_DISCORD_RELEASE_MENTION", "@here"), "Mention text for release channel pings, e.g. @here or <@&ROLE_ID>; empty disables pings")
discordSendDMs := flag.Bool("discord-send-dms", envBool("RW_DISCORD_SEND_DMS", true), "also send release notifications as DMs to subscribers")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
st, err := store.Open(*dataFile)
if err != nil {
log.Fatal(err)
}
if err := st.EnsureUser(store.User{Email: *adminEmail, DisplayName: "Administrator", Role: store.RoleAdmin, PasswordHash: auth.HashPassword(*adminPass)}); err != nil {
log.Fatal(err)
}
am := auth.New(*secret, st)
var notifier notify.ReleaseNotifier = notify.LoggingNotifier{Log: logger}
if *discordToken != "" {
dg, err := discordbot.NewDiscordSession(*discordToken)
if err != nil {
log.Fatal(err)
}
discordServices := discordbot.NewServices(st, dg, logger, discordbot.Config{
GuildID: *discordGuildID,
CategoryID: *discordCategoryID,
CategoryName: *discordCategoryName,
ReleaseMention: *discordReleaseMention,
SendDMs: *discordSendDMs,
})
discordbot.AttachDiscordHandlers(dg, discordServices)
if err := dg.Open(); err != nil {
log.Fatal(err)
}
defer dg.Close()
if *discordAppID != "" {
if err := discordbot.UpsertCommands(dg, *discordAppID); err != nil {
logger.Warn("Discord commands konnten nicht registriert werden", "error", err)
}
} else {
logger.Warn("RW_DISCORD_APP_ID fehlt; Slash-Commands werden nicht registriert")
}
notifier = discordServices
logger.Info("Discord-Bot gestartet")
}
srv, err := web.New(st, am, notifier, logger)
if err != nil {
log.Fatal(err)
}
logger.Info("ReleaseWatcher gestartet", "addr", *addr, "admin", *adminEmail)
log.Fatal(http.ListenAndServe(*addr, srv.Routes()))
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
switch os.Getenv(key) {
case "1", "true", "TRUE", "yes", "YES", "on", "ON":
return true
case "0", "false", "FALSE", "no", "NO", "off", "OFF":
return false
default:
return fallback
}
}

88
data/releasewatcher.json Normal file
View File

@@ -0,0 +1,88 @@
{
"users": [
{
"id": "5fc8f54e5578f301a7d73e9fedeafb98",
"email": "admin@example.local",
"display_name": "Administrator",
"role": "admin",
"password_hash": "pbkdf2-sha256$210000$130f914c57f7ad30b7d87f57f07cbfa2$54905dbdb022fa5b37bcffd681ad99564c01ce001f93386e978bb02ec8d73c03",
"created_at": "2026-05-04T19:25:49.7023088Z"
}
],
"manufacturers": [
{
"id": "88b0e5662aa0a89a765836fc2c2ab18a",
"name": "Don Ho",
"website": "https://notepad-plus-plus.org/author/",
"notes": "Notepad++",
"created_at": "2026-05-04T20:11:12.9399744Z",
"updated_at": "2026-05-04T20:11:12.9399744Z"
}
],
"software": [
{
"id": "c76b5939e1e1242313a7da0b1e59d424",
"manufacturer_id": "88b0e5662aa0a89a765836fc2c2ab18a",
"name": "Notepad++",
"homepage": "https://notepad-plus-plus.org/",
"description": "",
"architectures": [
"x64",
"x86",
"ARM64"
],
"channels": [
"stable"
],
"created_at": "2026-05-04T20:13:04.4057166Z",
"updated_at": "2026-05-04T20:13:04.4057166Z"
}
],
"releases": [
{
"id": "7503332bcb66d5fe1be6aaddceb915de",
"software_id": "c76b5939e1e1242313a7da0b1e59d424",
"version": "v8.9.4",
"channel": "stable",
"architecture": "x64",
"release_date": "2026-04-26",
"download_url": "https://github.com/notepad-plus-plus/notepad-plus-plus/releases/download/v8.9.4/npp.8.9.4.Installer.x64.exe",
"release_url": "https://notepad-plus-plus.org/downloads/v8.9.4/",
"info": "Notepad++ v8.9.4 crash-fixexs, bug-fixes \u0026 new improvements:\r\nFix crashes in FindInFiles when nativeLang.xmls “find-result-hits” contains “%s”. (Fix #17960, CVE-2026-3008, CVE-2026-6539)\r\nFix drop-file crash when file path length reaches 259 characters. (Fix #17921)\r\nFix crash caused by undoing column editor bad input in virtual space. (Fix #17915)\r\nFix bad column editor input in reverse-direction column selection on virtual space. (Fix #17915)\r\nUpdate to Scintilla 5.6.1 \u0026 Lexilla 5.4.8. (Fix #17920, #17864, #13522, #11746)\r\nFix EOL conversion to Windows format not working (Scintilla update related). (Fix #17920)\r\nFix rendering corruption in .bat files (Lexilla update related). (Fix #17864)\r\nFix quote escaping causing incorrect JSON syntax highlighting (Lexilla update related). (Fix #11746, #13522)\r\nFix MSI installation error due to context menu item registration. (Fix #17918)\r\nFix NSIS installation stalling caused by context menu registration issue. (Fix #17308, #17885)\r\nAdd NPP_LANG property to install a specific localization file for MSI. (Fix issue reported in comment)\r\nFix MSI installer display random Hexadecimal number as name on UAC. (Fix #17967)\r\nAdd version info into MSI file property (as value of “Comments”). (Fix #17803)\r\nFix minimized window not restoring in administrator mode. (Fix #17945)\r\nFix Unicode search mismatching ANSI character ?. (Fix #17125)\r\nFix Column Editor regression with empty fields. (Fix #17912)\r\nFix floating dialog content not displaying in certain situations. (Fix #17563)\r\nFix visual glitch when toggling group view in Document List. (Fix #14285)\r\nSupport improved C++ 11 raw string literal handling. (Fix #17875)\r\nFix visual glitch in the Mark dialog. (Fix #16084, #17886)",
"vulnerabilities": null,
"created_by": "5fc8f54e5578f301a7d73e9fedeafb98",
"created_at": "2026-05-04T20:14:29.1378124Z",
"updated_at": "2026-05-04T20:14:29.1378124Z"
}
],
"discord_subscribers": null,
"audit": [
{
"id": "35c28b82db56743aff40bacf66b464b5",
"actor_id": "5fc8f54e5578f301a7d73e9fedeafb98",
"action": "create",
"entity": "manufacturer",
"entity_id": "88b0e5662aa0a89a765836fc2c2ab18a",
"message": "Don Ho",
"created_at": "2026-05-04T20:11:12.9399744Z"
},
{
"id": "f6a9263a03d5b8586c751b67a3d226cf",
"actor_id": "5fc8f54e5578f301a7d73e9fedeafb98",
"action": "create",
"entity": "software",
"entity_id": "c76b5939e1e1242313a7da0b1e59d424",
"message": "Notepad++",
"created_at": "2026-05-04T20:13:04.4057166Z"
},
{
"id": "f63207c22b23280d64c416e22f6a14ec",
"actor_id": "5fc8f54e5578f301a7d73e9fedeafb98",
"action": "create",
"entity": "release",
"entity_id": "7503332bcb66d5fe1be6aaddceb915de",
"message": "v8.9.4",
"created_at": "2026-05-04T20:14:29.1378124Z"
}
]
}

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module releasewatcher
go 1.23.2
require github.com/bwmarrin/discordgo v0.29.0
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
)

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

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

298
internal/web/server.go Normal file
View 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()})
}

1
static/app.css Normal file
View File

@@ -0,0 +1 @@
:root{--bg:#f6f7fb;--card:#fff;--text:#172033;--muted:#667085;--line:#d9dee8;--accent:#1f6feb;--danger:#b42318}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}.top{display:flex;gap:1rem;align-items:center;padding:1rem 1.5rem;background:#0f172a;color:#fff;box-shadow:0 2px 12px #0002}.brand{font-weight:800;color:#fff;text-decoration:none;font-size:1.2rem}nav{display:flex;gap:.75rem;flex-wrap:wrap}nav a{color:#dbeafe;text-decoration:none}.user{margin-left:auto;color:#cbd5e1;font-size:.9rem}.container{max-width:1200px;margin:2rem auto;padding:0 1rem}h1{font-size:2rem;margin:.2rem 0 1rem}h2{margin-top:0}.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:1.25rem;box-shadow:0 10px 25px #0f172a0d}.narrow{max-width:460px;margin:4rem auto}.grid{display:grid;grid-template-columns:390px 1fr;gap:1rem}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem}.stat{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:1.2rem}.stat strong{display:block;font-size:2.1rem}.stat span,.muted{color:var(--muted)}.form{display:grid;gap:.9rem}label{display:grid;gap:.35rem;font-weight:650}input,textarea,select{width:100%;border:1px solid var(--line);border-radius:12px;padding:.72rem;font:inherit;background:#fff}button{background:var(--accent);color:#fff;border:0;border-radius:12px;padding:.8rem 1rem;font-weight:800;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:.7rem}th{font-size:.85rem;color:var(--muted)}a{color:var(--accent)}.alert{padding:.8rem 1rem;border-radius:12px;margin:0 0 1rem}.error{background:#fee4e2;color:var(--danger)}.info{background:#e0f2fe;color:#075985}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.9rem}.release-form{gap:1rem}@media(max-width:860px){.grid,.stats,.two{grid-template-columns:1fr}.top{align-items:flex-start;flex-direction:column}.user{margin-left:0}table{display:block;overflow-x:auto}}

1
templates/audit.html Normal file
View File

@@ -0,0 +1 @@
{{define "content"}}<h1>Audit-Log</h1><section class="card"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Objekt</th><th>Info</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{.CreatedAt}}</td><td>{{.Action}}</td><td>{{.Entity}}</td><td>{{.Message}}</td></tr>{{end}}</tbody></table></section>{{end}}{{template "base" .}}

1
templates/dashboard.html Normal file
View File

@@ -0,0 +1 @@
{{define "content"}}<h1>Dashboard</h1><div class="stats"><div class="stat"><strong>{{.ManufacturerCount}}</strong><span>Hersteller</span></div><div class="stat"><strong>{{.SoftwareCount}}</strong><span>Software</span></div><div class="stat"><strong>{{.ReleaseCount}}</strong><span>Releases</span></div></div><section class="card"><h2>Letzte Releases</h2>{{if .Releases}}<table><thead><tr><th>Software</th><th>Version</th><th>Channel</th><th>Architektur</th><th>Datum</th><th>Links</th></tr></thead><tbody>{{range .Releases}}<tr><td>{{softwareName .SoftwareID $.Software}}</td><td><strong>{{.Version}}</strong></td><td>{{.Channel}}</td><td>{{.Architecture}}</td><td>{{.ReleaseDate}}</td><td>{{if .ReleaseURL}}<a href="{{.ReleaseURL}}" rel="noopener noreferrer">Info</a>{{end}} {{if .DownloadURL}}<a href="{{.DownloadURL}}" rel="noopener noreferrer">Download</a>{{end}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">Noch keine Releases vorhanden.</p>{{end}}</section>{{end}}{{template "base" .}}

3
templates/layout.html Normal file
View File

@@ -0,0 +1,3 @@
{{define "base"}}
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · ReleaseWatcher</title><link rel="stylesheet" href="/static/app.css"></head><body><header class="top"><a class="brand" href="/">ReleaseWatcher</a>{{if .User.Email}}<nav><a href="/manufacturers">Hersteller</a><a href="/software">Software</a><a href="/releases">Releases</a>{{if eq .User.Role "admin"}}<a href="/users">Benutzer</a><a href="/audit">Audit</a>{{end}}<a href="/logout">Logout</a></nav><span class="user">{{.User.DisplayName}} · {{.User.Role}}</span>{{end}}</header><main class="container">{{if .Error}}<div class="alert error">{{.Error}}</div>{{end}}{{if .Info}}<div class="alert info">{{.Info}}</div>{{end}}{{template "content" .}}</main></body></html>
{{end}}

1
templates/login.html Normal file
View File

@@ -0,0 +1 @@
{{define "content"}}<section class="card narrow"><h1>Anmelden</h1><p class="muted">Standard-Login: admin@example.local / admin12345. Bitte in Produktion per Umgebungsvariablen ändern.</p><form method="post" class="form"><label>E-Mail<input name="email" type="email" required autofocus></label><label>Passwort<input name="password" type="password" required></label><button>Anmelden</button></form></section>{{end}}{{template "base" .}}

View File

@@ -0,0 +1 @@
{{define "content"}}<h1>Hersteller</h1><section class="grid"><form method="post" class="card form"><h2>Hersteller pflegen</h2><label>Name<input name="name" required></label><label>Website<input name="website" type="url" placeholder="https://..."></label><label>Notizen<textarea name="notes" rows="4"></textarea></label><button>Speichern</button></form><section class="card"><h2>Liste</h2><table><thead><tr><th>Name</th><th>Website</th><th>Notizen</th></tr></thead><tbody>{{range .Manufacturers}}<tr><td><strong>{{.Name}}</strong></td><td>{{if .Website}}<a href="{{.Website}}" rel="noopener noreferrer">Website</a>{{end}}</td><td>{{.Notes}}</td></tr>{{end}}</tbody></table></section></section>{{end}}{{template "base" .}}

1
templates/releases.html Normal file
View File

@@ -0,0 +1 @@
{{define "content"}}<h1>Releases</h1>{{if eq .User.Role "admin"}}<section class="card"><form method="post" class="form release-form"><h2>Neues Release erstellen</h2><div class="two"><label>Software<select name="software_id" required>{{range .Software}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label><label>Version<input name="version" required placeholder="1.2.3"></label><label>Channel<input name="channel" required placeholder="stable"></label><label>Architektur<input name="architecture" required placeholder="x86_64"></label><label>Release-Datum<input name="release_date" type="date"></label><label>Download-Link<input name="download_url" type="url" placeholder="https://..."></label><label>Release-Link<input name="release_url" type="url" placeholder="https://..."></label></div><label>Release-Informationen<textarea name="info" rows="4"></textarea></label><label>Schwachstellen, eine pro Zeile: CVE | Schweregrad | Beschreibung | Referenz<textarea name="vulnerabilities" rows="4" placeholder="CVE-2026-1234 | high | Beispiel | https://..."></textarea></label><button>Release veröffentlichen</button></form></section>{{else}}<div class="alert info">Mitarbeiter können Daten pflegen. Neue Releases dürfen nur Admins erstellen.</div>{{end}}<section class="card"><h2>Release-Historie</h2><table><thead><tr><th>Software</th><th>Version</th><th>Channel</th><th>Architektur</th><th>Schwachstellen</th><th>Links</th></tr></thead><tbody>{{range .Releases}}<tr><td>{{softwareName .SoftwareID $.Software}}</td><td><strong>{{.Version}}</strong><p class="muted">{{.ReleaseDate}}</p></td><td>{{.Channel}}</td><td>{{.Architecture}}</td><td>{{range .Vulnerabilities}}<div><strong>{{.CVE}}</strong> {{.Severity}}<br><span class="muted">{{.Description}}</span></div>{{else}}<span class="muted">Keine angegeben</span>{{end}}</td><td>{{if .ReleaseURL}}<a href="{{.ReleaseURL}}" rel="noopener noreferrer">Info</a>{{end}} {{if .DownloadURL}}<a href="{{.DownloadURL}}" rel="noopener noreferrer">Download</a>{{end}}</td></tr>{{end}}</tbody></table></section>{{end}}{{template "base" .}}

1
templates/software.html Normal file
View File

@@ -0,0 +1 @@
{{define "content"}}<h1>Software</h1><section class="grid"><form method="post" class="card form"><h2>Software pflegen</h2><label>Hersteller<select name="manufacturer_id" required>{{range .Manufacturers}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label><label>Name<input name="name" required></label><label>Homepage<input name="homepage" type="url" placeholder="https://..."></label><label>Architekturen<input name="architectures" placeholder="x86_64, arm64, windows"></label><label>Channels<input name="channels" placeholder="stable, beta, nightly"></label><label>Informationen<textarea name="description" rows="5"></textarea></label><button>Speichern</button></form><section class="card"><h2>Liste</h2><table><thead><tr><th>Hersteller</th><th>Name</th><th>Architekturen</th><th>Channels</th><th>Discord-Kanal</th><th>Homepage</th></tr></thead><tbody>{{range .Software}}<tr><td>{{manufacturerName .ManufacturerID $.Manufacturers}}</td><td><strong>{{.Name}}</strong></td><td>{{join .Architectures ", "}}</td><td>{{join .Channels ", "}}</td><td>{{if .DiscordChannelID}}<code>{{.DiscordChannelID}}</code>{{else}}<span class="muted">nicht verknüpft</span>{{end}}</td><td>{{if .Homepage}}<a href="{{.Homepage}}" rel="noopener noreferrer">Link</a>{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">Noch keine Software angelegt.</td></tr>{{end}}</tbody></table><p class="muted">Wenn Discord aktiviert ist, wird beim Anlegen automatisch ein Kanal im Format <code>hersteller-software</code> erstellt und hier verknüpft.</p></section></section>{{end}}{{template "base" .}}

1
templates/users.html Normal file
View File

@@ -0,0 +1 @@
{{define "content"}}<h1>Benutzer</h1><section class="grid"><form method="post" class="card form"><h2>Benutzer anlegen</h2><label>Name<input name="display_name" required></label><label>E-Mail<input name="email" type="email" required></label><label>Rolle<select name="role"><option value="employee">Mitarbeiter</option><option value="admin">Admin</option></select></label><label>Initiales Passwort<input name="password" type="password" minlength="10" required></label><button>Anlegen</button></form><section class="card"><h2>Benutzerliste</h2><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Erstellt</th></tr></thead><tbody>{{range .Users}}<tr><td><strong>{{.DisplayName}}</strong></td><td>{{.Email}}</td><td>{{.Role}}</td><td>{{.CreatedAt}}</td></tr>{{end}}</tbody></table></section></section><section class="card"><h2>Discord-Empfänger</h2><p class="muted">Diese Nutzer erhalten beim Erstellen eines Releases eine DM vom Bot.</p><table><thead><tr><th>Discord-Name</th><th>User-ID</th><th>Hinzugefügt</th></tr></thead><tbody>{{range .DiscordSubscribers}}<tr><td><strong>{{.Username}}</strong></td><td><code>{{.UserID}}</code></td><td>{{.CreatedAt}}</td></tr>{{else}}<tr><td colspan="3" class="muted">Noch keine Empfänger registriert.</td></tr>{{end}}</tbody></table></section>{{end}}{{template "base" .}}