Files
2026-07-20 21:41:51 +02:00

706 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
webassets "github.com/b1tsblog/license-platform/web"
)
type Server struct {
cfg Config
store *Store
vault *Vault
logger *slog.Logger
mux *http.ServeMux
templates *template.Template
loginLimiter *limiter
apiLimiter *limiter
}
type loginData struct {
Brand string
CSRF string
Error string
}
type portalData struct {
Brand, PublicURL, PortalTitle, RoleLabel, Headline, Subline string
User User
CSRF, Message, Error string
KeysReady bool
Keys *KeySet
TrustStoreJSON string
Users, Customers, Resellers []User
Licenses []LicenseRecord
Audit []AuditEvent
ActiveCount, ExpiringCount, RevokedCount int
}
type tokenData struct {
Brand string
License LicenseRecord
Token string
}
func New(cfg Config, store *Store, logger *slog.Logger) (*Server, error) {
if err := cfg.normalize(); err != nil {
return nil, err
}
if store == nil {
return nil, errors.New("store is required")
}
vault, err := NewVault(cfg.MasterKey)
if err != nil {
return nil, err
}
if logger == nil {
logger = slog.Default()
}
funcs := template.FuncMap{
"formatTime": func(value int64) string {
if value <= 0 {
return ""
}
return time.Unix(value, 0).UTC().Format("02.01.2006 · 15:04 UTC")
},
"initial": func(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "?"
}
return strings.ToUpper(string([]rune(value)[0]))
},
}
templates, err := template.New("portal").Funcs(funcs).ParseFS(webassets.FS, "templates/*.html")
if err != nil {
return nil, err
}
s := &Server{cfg: cfg, store: store, vault: vault, logger: logger, mux: http.NewServeMux(), templates: templates, loginLimiter: newLimiter(8, 15*time.Minute), apiLimiter: newLimiter(180, time.Minute)}
if err := s.bootstrap(); err != nil {
return nil, err
}
s.routes()
return s, nil
}
func (s *Server) Handler() http.Handler {
return s.securityHeaders(s.requestSizeLimit(s.requestLog(s.mux)))
}
func (s *Server) bootstrap() error {
users := s.store.ListUsers()
if len(users) > 0 {
return nil
}
if strings.TrimSpace(s.cfg.BootstrapUsername) == "" || strings.TrimSpace(s.cfg.BootstrapPassword) == "" {
return errors.New("bootstrap admin credentials are required for an empty data store")
}
hash, err := HashPassword(s.cfg.BootstrapPassword)
if err != nil {
return fmt.Errorf("bootstrap password: %w", err)
}
created, err := s.store.EnsureBootstrapAdmin(s.cfg.BootstrapUsername, s.cfg.BootstrapName, hash)
if err == nil && created {
s.logger.Warn("bootstrap administrator created", "username", normalizeUsername(s.cfg.BootstrapUsername))
}
return err
}
func (s *Server) routes() {
staticFS, _ := fs.Sub(webassets.FS, "static")
s.mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS))))
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("GET /.well-known/license-server", s.discovery)
s.mux.HandleFunc("GET /api/v1/trust-store", s.publicTrustStore)
s.mux.HandleFunc("GET /login", s.loginPage)
s.mux.HandleFunc("POST /login", s.login)
s.mux.HandleFunc("POST /logout", s.logout)
s.mux.HandleFunc("POST /account/password", s.changePassword)
s.mux.HandleFunc("GET /", s.portal)
s.mux.HandleFunc("POST /admin/keys/generate", s.generateKeysForm)
s.mux.HandleFunc("POST /admin/keys/import", s.importKeysForm)
s.mux.HandleFunc("POST /admin/users", s.createUserForm)
s.mux.HandleFunc("POST /reseller/customers", s.createCustomerForm)
s.mux.HandleFunc("POST /licenses", s.issueForm)
s.mux.HandleFunc("GET /licenses/{id}/token", s.tokenPage)
s.mux.HandleFunc("POST /licenses/{id}/revoke", s.revokeForm)
s.mux.HandleFunc("POST /licenses/{id}/restore", s.restoreForm)
s.mux.HandleFunc("POST /api/v1/licenses/validate", s.validateAPI)
s.mux.HandleFunc("POST /v1/introspect", s.validateAPI)
s.mux.HandleFunc("GET /api/v1/licenses", s.listAPI)
s.mux.HandleFunc("POST /api/v1/licenses", s.issueAPI)
s.mux.HandleFunc("POST /api/v1/licenses/import", s.importAPI)
s.mux.HandleFunc("POST /api/v1/licenses/{id}/revoke", s.revokeAPI)
s.mux.HandleFunc("POST /api/v1/licenses/{id}/restore", s.restoreAPI)
// Compatibility with the original reference server and licenseweb.
s.mux.HandleFunc("GET /v1/admin/licenses", s.listAPI)
s.mux.HandleFunc("POST /v1/admin/licenses", s.legacyRegisterAPI)
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/revoke", s.revokeAPI)
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/restore", s.restoreAPI)
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
_, ready := s.store.KeySet()
s.writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "keysReady": ready, "version": "1.0.0"})
}
func (s *Server) discovery(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"issuer": s.cfg.Brand, "serverUrl": s.cfg.PublicURL, "validationEndpoint": s.cfg.PublicURL + "/api/v1/licenses/validate", "trustStoreEndpoint": s.cfg.PublicURL + "/api/v1/trust-store"})
}
func (s *Server) publicTrustStore(w http.ResponseWriter, _ *http.Request) {
store, err := s.trustStore()
if err != nil {
s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
s.writeJSON(w, http.StatusOK, store)
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if _, _, ok := s.currentUser(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, "login.html", loginData{Brand: s.cfg.Brand, CSRF: s.loginCSRF(w, r), Error: r.URL.Query().Get("error")})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(remoteIP(r)) {
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil || !s.checkLoginCSRF(r) {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
username := normalizeUsername(r.FormValue("username"))
password := r.FormValue("password")
if len(username) > 200 || len(password) > 1024 {
http.Redirect(w, r, "/login?error=Anmeldung+fehlgeschlagen", http.StatusSeeOther)
return
}
user, ok := s.store.FindUserByUsername(username)
if !ok || !user.Active || !VerifyPassword(user.PasswordHash, password) {
_ = s.audit(User{}, "login.failed", username, "invalid credentials", r)
http.Redirect(w, r, "/login?error=Anmeldung+fehlgeschlagen", http.StatusSeeOther)
return
}
raw, session, err := s.newSession(user)
if err != nil {
http.Error(w, "session creation failed", http.StatusInternalServerError)
return
}
s.loginLimiter.Reset(remoteIP(r))
s.setSessionCookie(w, raw, time.Unix(session.ExpiresAt, 0))
_ = s.audit(user, "login.succeeded", user.ID, "portal session created", r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.requireUser(w, r)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
_ = s.store.DeleteSession(session.IDHash)
s.clearSessionCookie(w)
_ = s.audit(user, "logout", user.ID, "session closed", r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.requireUser(w, r)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if !VerifyPassword(user.PasswordHash, r.FormValue("currentPassword")) {
_ = s.audit(user, "password.change.failed", user.ID, "current password mismatch", r)
http.Redirect(w, r, "/?error=Aktuelles+Passwort+ist+falsch", http.StatusSeeOther)
return
}
hash, err := HashPassword(r.FormValue("newPassword"))
if err != nil {
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
if err := s.store.UpdatePassword(user.ID, hash); err != nil {
http.Error(w, "password update failed", http.StatusInternalServerError)
return
}
_ = s.store.DeleteSessionsForUser(user.ID)
s.clearSessionCookie(w)
_ = s.audit(user, "password.changed", user.ID, "all sessions invalidated", r)
http.Redirect(w, r, "/login?error=Passwort+geändert.+Bitte+neu+anmelden", http.StatusSeeOther)
}
func (s *Server) portal(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.requireUser(w, r)
if !ok {
return
}
licenses := s.store.ListLicensesFor(user)
keys, keysReady := s.store.KeySet()
data := portalData{Brand: s.cfg.Brand, PublicURL: s.cfg.PublicURL, User: user, CSRF: session.CSRFToken, KeysReady: keysReady, Keys: keys, Licenses: licenses, Message: r.URL.Query().Get("message"), Error: r.URL.Query().Get("error")}
data.PortalTitle, data.RoleLabel, data.Headline, data.Subline = roleCopy(user.Role)
for _, record := range licenses {
if record.Revoked {
data.RevokedCount++
} else {
data.ActiveCount++
}
if !record.Revoked && record.ExpiresAt <= time.Now().UTC().Add(30*24*time.Hour).Unix() {
data.ExpiringCount++
}
}
if user.Role == RoleAdmin {
data.Users = s.store.ListUsers()
data.Audit = s.store.ListAudit(50)
}
for _, candidate := range s.store.ListUsers() {
if candidate.Role == RoleReseller && candidate.Active && user.Role == RoleAdmin {
data.Resellers = append(data.Resellers, candidate)
}
if candidate.Role == RoleCustomer && candidate.Active {
if user.Role == RoleAdmin || (user.Role == RoleReseller && candidate.ParentUserID == user.ID) {
data.Customers = append(data.Customers, candidate)
}
}
}
if keysReady {
if trust, err := s.trustStore(); err == nil {
data.TrustStoreJSON = trustStoreJSON(trust)
}
}
s.render(w, "portal.html", data)
}
func roleCopy(role Role) (string, string, string, string) {
switch role {
case RoleAdmin:
return "Admin / Management", "Management", "Steuere Vertrauen, Zugänge und Lizenzbestand.", "Zentrale Kontrolle über Signaturschlüssel, Nutzer, Aussteller und den vollständigen Audit-Trail."
case RoleReseller:
return "Reseller / Autor", "Reseller", "Lizenzen ausstellen, ohne die Root Keys zu sehen.", "Erstelle und verwalte ausschließlich die von dir verantworteten Kundenlizenzen."
default:
return "Kunde / Client", "Kunde", "Deine Lizenzen. Transparent und jederzeit verfügbar.", "Prüfe Laufzeit, Status, Features und sichere Client-Konfigurationen für deine Produkte."
}
}
func (s *Server) generateKeysForm(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.requireRole(w, r, RoleAdmin)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
err := s.generateKeys(r.FormValue("issuerKeyId"), r.FormValue("leaseKeyId"))
if err != nil {
_ = s.audit(user, "keys.generate.denied", "keyset", err.Error(), r)
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
_ = s.audit(user, "keys.generated", "keyset", "issuer and lease key pairs initialized", r)
http.Redirect(w, r, "/?message=Schlüsselpaare+wurden+erzeugt+und+gesperrt", http.StatusSeeOther)
}
func (s *Server) importKeysForm(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.requireRole(w, r, RoleAdmin)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
err := s.importKeys(r.FormValue("issuerKeyId"), r.FormValue("issuerPrivate"), r.FormValue("leaseKeyId"), r.FormValue("leasePrivate"))
if err != nil {
_ = s.audit(user, "keys.import.denied", "keyset", err.Error(), r)
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
_ = s.audit(user, "keys.imported", "keyset", "existing issuer and lease keys imported and locked", r)
http.Redirect(w, r, "/?message=Bestehende+Schlüsselpaare+verschlüsselt+importiert+und+gesperrt", http.StatusSeeOther)
}
func (s *Server) createUserForm(w http.ResponseWriter, r *http.Request) {
actor, session, ok := s.requireRole(w, r, RoleAdmin)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
role := Role(strings.TrimSpace(r.FormValue("role")))
if role != RoleAdmin && role != RoleReseller && role != RoleCustomer {
http.Redirect(w, r, "/?error=Ungültige+Rolle", http.StatusSeeOther)
return
}
hash, err := HashPassword(r.FormValue("password"))
if err != nil {
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
parentUserID := ""
if role == RoleCustomer {
parentUserID = strings.TrimSpace(r.FormValue("resellerUserId"))
if parentUserID != "" {
parent, ok := s.store.GetUser(parentUserID)
if !ok || parent.Role != RoleReseller || !parent.Active {
http.Redirect(w, r, "/?error=Zugeordneter+Reseller+ist+ungültig", http.StatusSeeOther)
return
}
}
}
user := User{ID: newID("usr"), Username: r.FormValue("username"), DisplayName: strings.TrimSpace(r.FormValue("displayName")), Role: role, ParentUserID: parentUserID, PasswordHash: hash, Active: true}
if user.DisplayName == "" {
user.DisplayName = user.Username
}
if err := s.store.CreateUser(user); err != nil {
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
_ = s.audit(actor, "user.created", user.ID, string(role)+":"+user.Username, r)
http.Redirect(w, r, "/?message=Benutzer+angelegt", http.StatusSeeOther)
}
func (s *Server) createCustomerForm(w http.ResponseWriter, r *http.Request) {
actor, session, ok := s.requireRole(w, r, RoleReseller)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
hash, err := HashPassword(r.FormValue("password"))
if err != nil {
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
user := User{ID: newID("usr"), Username: r.FormValue("username"), DisplayName: strings.TrimSpace(r.FormValue("displayName")), Role: RoleCustomer, ParentUserID: actor.ID, PasswordHash: hash, Active: true}
if user.DisplayName == "" {
user.DisplayName = user.Username
}
if err := s.store.CreateUser(user); err != nil {
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
_ = s.audit(actor, "customer.created", user.ID, user.Username, r)
http.Redirect(w, r, "/?message=Kundenportal+angelegt", http.StatusSeeOther)
}
func (s *Server) issueForm(w http.ResponseWriter, r *http.Request) {
actor, session, ok := s.requireRole(w, r, RoleAdmin, RoleReseller)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
input, err := parseIssueForm(r.Form)
if err == nil {
_, _, err = s.issueLicense(actor, input)
}
if err != nil {
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
return
}
_ = s.audit(actor, "license.issued", input.Product, input.Customer+" / "+input.Edition, r)
http.Redirect(w, r, "/?message=Lizenz+signiert+und+registriert", http.StatusSeeOther)
}
func (s *Server) tokenPage(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.requireUser(w, r)
if !ok {
return
}
record, ok := s.store.GetLicense(r.PathValue("id"))
if !ok || !canAccessLicense(user, record) {
http.Error(w, "not found", http.StatusNotFound)
return
}
token, err := s.tokenFor(record)
if err != nil {
http.Error(w, "token unavailable", http.StatusInternalServerError)
return
}
s.render(w, "token.html", tokenData{Brand: s.cfg.Brand, License: record, Token: token})
}
func (s *Server) revokeForm(w http.ResponseWriter, r *http.Request) { s.mutateLicenseForm(w, r, true) }
func (s *Server) restoreForm(w http.ResponseWriter, r *http.Request) {
s.mutateLicenseForm(w, r, false)
}
func (s *Server) mutateLicenseForm(w http.ResponseWriter, r *http.Request, revoked bool) {
actor, session, ok := s.requireRole(w, r, RoleAdmin, RoleReseller)
if !ok {
return
}
if !s.verifyCSRF(r, session) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
record, ok := s.store.GetLicense(r.PathValue("id"))
if !ok || !canManageLicense(actor, record) {
http.Error(w, "not found", http.StatusNotFound)
return
}
reason := "manually revoked"
if !revoked {
reason = ""
}
if err := s.store.SetRevoked(record.LicenseID, revoked, reason); err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
action := "license.restored"
if revoked {
action = "license.revoked"
}
_ = s.audit(actor, action, record.LicenseID, reason, r)
http.Redirect(w, r, "/?message=Lizenzstatus+aktualisiert", http.StatusSeeOther)
}
func (s *Server) validateAPI(w http.ResponseWriter, r *http.Request) {
if !s.apiLimiter.Allow(remoteIP(r)) {
s.writeJSON(w, http.StatusTooManyRequests, validateResponse{Reason: "rate limit exceeded"})
return
}
var request validateRequest
if err := decodeJSON(r, &request); err != nil {
s.writeJSON(w, http.StatusBadRequest, validateResponse{Reason: err.Error()})
return
}
response, err := s.validateLicense(request)
if err != nil {
s.writeJSON(w, http.StatusForbidden, response)
return
}
s.writeJSON(w, http.StatusOK, response)
}
func (s *Server) listAPI(w http.ResponseWriter, r *http.Request) {
actor, err := s.apiActor(r)
if err != nil {
s.apiUnauthorized(w)
return
}
records := s.store.ListLicensesFor(actor)
views := make([]LicenseView, 0, len(records))
for _, record := range records {
views = append(views, licenseView(record, ""))
}
s.writeJSON(w, http.StatusOK, map[string]any{"licenses": views})
}
func (s *Server) issueAPI(w http.ResponseWriter, r *http.Request) {
actor, err := s.apiActor(r)
if err != nil {
s.apiUnauthorized(w)
return
}
var input IssueInput
if err := decodeJSON(r, &input); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record, token, err := s.issueLicense(actor, input)
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.audit(actor, "license.issued.api", record.LicenseID, record.Product, r)
s.writeJSON(w, http.StatusCreated, licenseView(record, token))
}
func (s *Server) importAPI(w http.ResponseWriter, r *http.Request) {
actor, err := s.apiActor(r)
if err != nil {
s.apiUnauthorized(w)
return
}
var payload struct {
Token string `json:"token"`
CustomerUserID string `json:"customerUserId,omitempty"`
}
if err := decodeJSON(r, &payload); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record, err := s.importLicense(actor, payload.Token, strings.TrimSpace(payload.CustomerUserID))
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.audit(actor, "license.imported.api", record.LicenseID, record.Product, r)
s.writeJSON(w, http.StatusCreated, licenseView(record, ""))
}
func (s *Server) legacyRegisterAPI(w http.ResponseWriter, r *http.Request) {
actor, err := s.apiActor(r)
if err != nil {
s.apiUnauthorized(w)
return
}
var payload struct {
Token string `json:"token"`
}
if err := decodeJSON(r, &payload); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record, err := s.importLicense(actor, payload.Token, "")
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.audit(actor, "license.registered.legacy", record.LicenseID, record.Product, r)
s.writeJSON(w, http.StatusCreated, licenseView(record, ""))
}
func (s *Server) revokeAPI(w http.ResponseWriter, r *http.Request) { s.mutateLicenseAPI(w, r, true) }
func (s *Server) restoreAPI(w http.ResponseWriter, r *http.Request) { s.mutateLicenseAPI(w, r, false) }
func (s *Server) mutateLicenseAPI(w http.ResponseWriter, r *http.Request, revoked bool) {
actor, err := s.apiActor(r)
if err != nil {
s.apiUnauthorized(w)
return
}
record, ok := s.store.GetLicense(r.PathValue("id"))
if !ok || !canManageLicense(actor, record) {
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "license not found"})
return
}
reason := ""
if revoked {
var payload struct {
Reason string `json:"reason"`
}
_ = decodeJSONAllowEmpty(r, &payload)
reason = strings.TrimSpace(payload.Reason)
if reason == "" {
reason = "revoked via API"
}
}
if err := s.store.SetRevoked(record.LicenseID, revoked, reason); err != nil {
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
action := "license.restored.api"
if revoked {
action = "license.revoked.api"
}
_ = s.audit(actor, action, record.LicenseID, reason, r)
s.writeJSON(w, http.StatusOK, map[string]any{"licenseId": record.LicenseID, "revoked": revoked})
}
func canAccessLicense(user User, record LicenseRecord) bool {
return user.Role == RoleAdmin || (user.Role == RoleReseller && record.IssuedByUserID == user.ID) || (user.Role == RoleCustomer && record.CustomerUserID == user.ID)
}
func canManageLicense(user User, record LicenseRecord) bool {
return user.Role == RoleAdmin || (user.Role == RoleReseller && record.IssuedByUserID == user.ID)
}
func (s *Server) audit(actor User, action, target, detail string, r *http.Request) error {
event := AuditEvent{ID: newID("evt"), ActorID: actor.ID, Action: action, Target: target, Detail: detail, RemoteIP: remoteIP(r), CreatedAt: unixNow()}
return s.store.AddAudit(event)
}
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Error("render template", "name", name, "error", err)
}
}
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (s *Server) apiUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", "Bearer")
s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
}
func decodeJSON(r *http.Request, target any) error {
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return err
}
if dec.Decode(&struct{}{}) != io.EOF {
return errors.New("request body must contain one JSON object")
}
return nil
}
func decodeJSONAllowEmpty(r *http.Request, target any) error {
err := decodeJSON(r, target)
if errors.Is(err, io.EOF) {
return nil
}
return err
}
func queryValue(value string) string { return url.QueryEscape(value) }
func (s *Server) requestSizeLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
}
next.ServeHTTP(w, r)
})
}
func (s *Server) 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", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; img-src 'self' data:; script-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
w.Header().Set("Cache-Control", "no-store")
if s.cfg.SecureCookies {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) requestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
s.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "remote_ip", remoteIP(r), "duration_ms", time.Since(started).Milliseconds())
})
}