From 270c13af5b6383da5dc49fe265058355c8fb0caa Mon Sep 17 00:00:00 2001 From: jbergner Date: Mon, 4 May 2026 22:25:50 +0200 Subject: [PATCH] init --- .gitea/workflows/registry.yml | 51 ++++ Dockerfile | 27 ++ README.md | 130 ++++++++- cmd/releasewatcher/main.go | 93 +++++++ data/releasewatcher.json | 88 ++++++ go.mod | 11 + go.sum | 12 + internal/auth/auth.go | 143 ++++++++++ internal/discordbot/bot.go | 507 ++++++++++++++++++++++++++++++++++ internal/notify/notify.go | 41 +++ internal/store/store.go | 428 ++++++++++++++++++++++++++++ internal/web/server.go | 298 ++++++++++++++++++++ static/app.css | 1 + templates/audit.html | 1 + templates/dashboard.html | 1 + templates/layout.html | 3 + templates/login.html | 1 + templates/manufacturers.html | 1 + templates/releases.html | 1 + templates/software.html | 1 + templates/users.html | 1 + 21 files changed, 1839 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/registry.yml create mode 100644 Dockerfile create mode 100644 cmd/releasewatcher/main.go create mode 100644 data/releasewatcher.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/discordbot/bot.go create mode 100644 internal/notify/notify.go create mode 100644 internal/store/store.go create mode 100644 internal/web/server.go create mode 100644 static/app.css create mode 100644 templates/audit.html create mode 100644 templates/dashboard.html create mode 100644 templates/layout.html create mode 100644 templates/login.html create mode 100644 templates/manufacturers.html create mode 100644 templates/releases.html create mode 100644 templates/software.html create mode 100644 templates/users.html diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -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 }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..973c9c8 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 16edbb2..f169419 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/releasewatcher/main.go b/cmd/releasewatcher/main.go new file mode 100644 index 0000000..36783a7 --- /dev/null +++ b/cmd/releasewatcher/main.go @@ -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 + } +} diff --git a/data/releasewatcher.json b/data/releasewatcher.json new file mode 100644 index 0000000..6b49793 --- /dev/null +++ b/data/releasewatcher.json @@ -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.xml’s “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" + } + ] +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c94d7d8 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8e8eb37 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..afea988 --- /dev/null +++ b/internal/auth/auth.go @@ -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}) +} diff --git a/internal/discordbot/bot.go b/internal/discordbot/bot.go new file mode 100644 index 0000000..5951e1a --- /dev/null +++ b/internal/discordbot/bot.go @@ -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 "" +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..a01decf --- /dev/null +++ b/internal/notify/notify.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..fa36827 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..3a7b261 --- /dev/null +++ b/internal/web/server.go @@ -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()}) +} diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..b9822e2 --- /dev/null +++ b/static/app.css @@ -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}} diff --git a/templates/audit.html b/templates/audit.html new file mode 100644 index 0000000..8cb72ef --- /dev/null +++ b/templates/audit.html @@ -0,0 +1 @@ +{{define "content"}}

Audit-Log

{{range .Audit}}{{end}}
ZeitAktionObjektInfo
{{.CreatedAt}}{{.Action}}{{.Entity}}{{.Message}}
{{end}}{{template "base" .}} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..5d6643d --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1 @@ +{{define "content"}}

Dashboard

{{.ManufacturerCount}}Hersteller
{{.SoftwareCount}}Software
{{.ReleaseCount}}Releases

Letzte Releases

{{if .Releases}}{{range .Releases}}{{end}}
SoftwareVersionChannelArchitekturDatumLinks
{{softwareName .SoftwareID $.Software}}{{.Version}}{{.Channel}}{{.Architecture}}{{.ReleaseDate}}{{if .ReleaseURL}}Info{{end}} {{if .DownloadURL}}Download{{end}}
{{else}}

Noch keine Releases vorhanden.

{{end}}
{{end}}{{template "base" .}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..02f5d7d --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,3 @@ +{{define "base"}} +{{.Title}} · ReleaseWatcher
ReleaseWatcher{{if .User.Email}}{{.User.DisplayName}} · {{.User.Role}}{{end}}
{{if .Error}}
{{.Error}}
{{end}}{{if .Info}}
{{.Info}}
{{end}}{{template "content" .}}
+{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..51cb1d5 --- /dev/null +++ b/templates/login.html @@ -0,0 +1 @@ +{{define "content"}}

Anmelden

Standard-Login: admin@example.local / admin12345. Bitte in Produktion per Umgebungsvariablen ändern.

{{end}}{{template "base" .}} diff --git a/templates/manufacturers.html b/templates/manufacturers.html new file mode 100644 index 0000000..6fc8105 --- /dev/null +++ b/templates/manufacturers.html @@ -0,0 +1 @@ +{{define "content"}}

Hersteller

Hersteller pflegen

Liste

{{range .Manufacturers}}{{end}}
NameWebsiteNotizen
{{.Name}}{{if .Website}}Website{{end}}{{.Notes}}
{{end}}{{template "base" .}} diff --git a/templates/releases.html b/templates/releases.html new file mode 100644 index 0000000..d6da8ef --- /dev/null +++ b/templates/releases.html @@ -0,0 +1 @@ +{{define "content"}}

Releases

{{if eq .User.Role "admin"}}

Neues Release erstellen

{{else}}
Mitarbeiter können Daten pflegen. Neue Releases dürfen nur Admins erstellen.
{{end}}

Release-Historie

{{range .Releases}}{{end}}
SoftwareVersionChannelArchitekturSchwachstellenLinks
{{softwareName .SoftwareID $.Software}}{{.Version}}

{{.ReleaseDate}}

{{.Channel}}{{.Architecture}}{{range .Vulnerabilities}}
{{.CVE}} {{.Severity}}
{{.Description}}
{{else}}Keine angegeben{{end}}
{{if .ReleaseURL}}Info{{end}} {{if .DownloadURL}}Download{{end}}
{{end}}{{template "base" .}} diff --git a/templates/software.html b/templates/software.html new file mode 100644 index 0000000..04b6c49 --- /dev/null +++ b/templates/software.html @@ -0,0 +1 @@ +{{define "content"}}

Software

Software pflegen

Liste

{{range .Software}}{{else}}{{end}}
HerstellerNameArchitekturenChannelsDiscord-KanalHomepage
{{manufacturerName .ManufacturerID $.Manufacturers}}{{.Name}}{{join .Architectures ", "}}{{join .Channels ", "}}{{if .DiscordChannelID}}{{.DiscordChannelID}}{{else}}nicht verknüpft{{end}}{{if .Homepage}}Link{{end}}
Noch keine Software angelegt.

Wenn Discord aktiviert ist, wird beim Anlegen automatisch ein Kanal im Format hersteller-software erstellt und hier verknüpft.

{{end}}{{template "base" .}} diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..94af1e7 --- /dev/null +++ b/templates/users.html @@ -0,0 +1 @@ +{{define "content"}}

Benutzer

Benutzer anlegen

Benutzerliste

{{range .Users}}{{end}}
NameE-MailRolleErstellt
{{.DisplayName}}{{.Email}}{{.Role}}{{.CreatedAt}}

Discord-Empfänger

Diese Nutzer erhalten beim Erstellen eines Releases eine DM vom Bot.

{{range .DiscordSubscribers}}{{else}}{{end}}
Discord-NameUser-IDHinzugefügt
{{.Username}}{{.UserID}}{{.CreatedAt}}
Noch keine Empfänger registriert.
{{end}}{{template "base" .}}