This commit is contained in:
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()})
|
||||
}
|
||||
Reference in New Issue
Block a user