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