This commit is contained in:
2026-07-20 21:41:51 +02:00
parent 355266df01
commit 91081f6501
35 changed files with 4203 additions and 1 deletions
+705
View File
@@ -0,0 +1,705 @@
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())
})
}