Files
patchpinglite/internal/web/server.go
jbergner 270c13af5b
All checks were successful
release-tag / release-image (push) Successful in 2m3s
init
2026-05-04 22:25:50 +02:00

299 lines
10 KiB
Go

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()})
}