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