From 28f55960de566a0c77df42b90a44a5c9977a6eb1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 15:24:59 +0700 Subject: [PATCH 1/5] feat(security): add altcha proof-of-work CAPTCHA to all sensitive forms Integrate go-altcha to protect login, change password, lost password, and reset password forms against automated submissions. Serves the altcha widget JS from the embedded library, exposes a challenge endpoint, validates responses server-side with replay prevention, and updates the CSP to allow self-hosted scripts and WebAssembly. Co-Authored-By: Claude Sonnet 4.6 --- altcha.go | 27 +++++++++++++++++++++++++++ change.go | 6 ++++++ go.mod | 2 ++ go.sum | 4 ++++ login.go | 5 +++++ lost.go | 5 +++++ main.go | 2 ++ reset.go | 5 +++++ static.go | 2 +- static/change.html | 3 +++ static/header.html | 1 + static/login.html | 3 +++ static/lost.html | 3 +++ static/reset.html | 3 +++ 14 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 altcha.go diff --git a/altcha.go b/altcha.go new file mode 100644 index 0000000..ea5e50b --- /dev/null +++ b/altcha.go @@ -0,0 +1,27 @@ +package main + +import ( + "net/http" + + goaltcha "github.com/k42-software/go-altcha" + altchahttp "github.com/k42-software/go-altcha/http" +) + +func serveAltchaJS(w http.ResponseWriter, r *http.Request) { + altchahttp.ServeJavascript(w, r) +} + +func serveAltchaChallenge(w http.ResponseWriter, r *http.Request) { + challenge := goaltcha.NewChallenge() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") + _, _ = w.Write([]byte(challenge.Encode())) +} + +func validateAltcha(r *http.Request) bool { + encoded := r.PostFormValue("altcha") + if encoded == "" { + return false + } + return goaltcha.ValidateResponse(encoded, true) +} diff --git a/change.go b/change.go index 8d1b32f..0a9e7e6 100644 --- a/change.go +++ b/change.go @@ -53,6 +53,12 @@ func changePassword(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + csrfToken, _ := setCSRFToken(w) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) + return + } + renderError := func(status int, msg string) { csrfToken, _ := setCSRFToken(w) displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken}) diff --git a/go.mod b/go.mod index 17e467e..eb358eb 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/k42-software/go-altcha v0.1.1 + github.com/pkg/errors v0.9.1 // indirect golang.org/x/crypto v0.36.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect ) diff --git a/go.sum b/go.sum index ea772ab..318fb83 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/k42-software/go-altcha v0.1.1 h1:vfA+0+0gr7jK4vp21Q7xvEpIjDsx8PqzxS0obgIToQs= +github.com/k42-software/go-altcha v0.1.1/go.mod h1:2aX+0PkUSI0YPDVfjapZeuGELWt8ugEXkg8gr6QejMU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/login.go b/login.go index cd72b3c..44ffe66 100644 --- a/login.go +++ b/login.go @@ -53,6 +53,11 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + return + } + if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { log.Println(err) displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) diff --git a/lost.go b/lost.go index 250ac42..1e4c9d3 100644 --- a/lost.go +++ b/lost.go @@ -107,6 +107,11 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + return + } + // Connect to the LDAP server conn, err := myLDAP.Connect() if err != nil || conn == nil { diff --git a/main.go b/main.go index 843dadf..ec6e888 100644 --- a/main.go +++ b/main.go @@ -213,6 +213,8 @@ func main() { signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Register handlers + http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS) + http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge) http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete) diff --git a/reset.go b/reset.go index c2172b0..a37f99d 100644 --- a/reset.go +++ b/reset.go @@ -44,6 +44,11 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + renderError(http.StatusForbidden, "Invalid or missing altcha response. Please try again.") + return + } + // Check the two new passwords are identical if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") diff --git a/static.go b/static.go index e808fe1..0ecdcf4 100644 --- a/static.go +++ b/static.go @@ -12,7 +12,7 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98=' https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com; worker-src blob:") w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") next.ServeHTTP(w, r) }) diff --git a/static/change.html b/static/change.html index 60ffe7e..424b171 100644 --- a/static/change.html +++ b/static/change.html @@ -40,6 +40,9 @@ +
+ +
Forgot your password? diff --git a/static/header.html b/static/header.html index ba37563..be8afa0 100644 --- a/static/header.html +++ b/static/header.html @@ -10,6 +10,7 @@ nemunai.re password change +
diff --git a/static/login.html b/static/login.html index b8c9366..ec0678a 100644 --- a/static/login.html +++ b/static/login.html @@ -9,6 +9,9 @@
+
+ +
Forgot your password? diff --git a/static/lost.html b/static/lost.html index e924c3c..bba8878 100644 --- a/static/lost.html +++ b/static/lost.html @@ -7,6 +7,9 @@
+
+ +
Just want to change your password? diff --git a/static/reset.html b/static/reset.html index 641f179..382ab00 100644 --- a/static/reset.html +++ b/static/reset.html @@ -15,6 +15,9 @@
+
+ +
{{template "footer"}} From 89330553582de7e4ede078c7b5a568be0bf36f51 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 15:27:39 +0700 Subject: [PATCH 2/5] feat: add -dev flag for local HTTP testing In development mode (-dev): - HSTS header is omitted (prevents browser caching HTTPS-only requirement) - CSRF cookie Secure flag is cleared (allows cookies over plain HTTP) - A warning is logged on startup Co-Authored-By: Claude Sonnet 4.6 --- csrf.go | 1 + main.go | 6 ++++++ static.go | 4 +++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/csrf.go b/csrf.go index a94be83..42f46d6 100644 --- a/csrf.go +++ b/csrf.go @@ -25,6 +25,7 @@ func setCSRFToken(w http.ResponseWriter) (string, error) { Path: "/", HttpOnly: false, // must be readable via form hidden field comparison SameSite: http.SameSiteStrictMode, + Secure: !devMode, }) return token, nil } diff --git a/main.go b/main.go index ec6e888..36ab61d 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( ) var myPublicURL = "https://ldap.nemunai.re" +var devMode bool // dockerRegistrySecret is required for X-Special-Auth anonymous access. // If empty, the feature is disabled. @@ -80,9 +81,14 @@ func main() { var baseURL = flag.String("baseurl", "/", "URL prepended to each URL") var configfile = flag.String("config", "", "path to the configuration file") var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails") + var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing") flag.Parse() myPublicURL = *publicURL + devMode = *dev + if devMode { + log.Println("WARNING: running in development mode — security features relaxed, do not use in production") + } // Sanitize options log.Println("Checking paths...") diff --git a/static.go b/static.go index 0ecdcf4..aa8b8a2 100644 --- a/static.go +++ b/static.go @@ -13,7 +13,9 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98=' https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com; worker-src blob:") - w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + if !devMode { + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + } next.ServeHTTP(w, r) }) } From 439dc2cd07fdac27ed2e3c6230fd10c8b561b783 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 8 Mar 2026 10:56:58 +0700 Subject: [PATCH 3/5] refactor: modernize Go idioms across codebase Replace map[string]interface{} with map[string]any, ioutil.ReadAll with io.ReadAll, and simplify redundant fmt.Sprintf/w.Write calls. Co-Authored-By: Claude Sonnet 4.6 --- addy.go | 10 ++-------- change.go | 10 +++++----- login.go | 12 ++++++------ lost.go | 14 +++++++------- main.go | 8 ++++---- reset.go | 2 +- static.go | 6 +++--- 7 files changed, 28 insertions(+), 34 deletions(-) diff --git a/addy.go b/addy.go index c2878e1..7a01e9a 100644 --- a/addy.go +++ b/addy.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "os" + "slices" "strings" ) @@ -134,14 +135,7 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) { http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable) return } - domainAllowed := false - for _, d := range allowedAliasDomains { - if body.Domain == d { - domainAllowed = true - break - } - } - if !domainAllowed { + if !slices.Contains(allowedAliasDomains, body.Domain) { http.Error(w, "Domain not allowed", http.StatusBadRequest) return } diff --git a/change.go b/change.go index 0a9e7e6..d915aba 100644 --- a/change.go +++ b/change.go @@ -33,7 +33,7 @@ func checkPasswdConstraint(password string) error { func changePassword(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]any{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) return } @@ -43,25 +43,25 @@ func changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken}) + displayTmpl(w, "change.html", map[string]any{"csrf_token": csrfToken}) return } if !validateCSRF(r) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) return } if !validateAltcha(r) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) return } renderError := func(status int, msg string) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken}) + displayTmplError(w, status, "change.html", map[string]any{"error": msg, "csrf_token": csrfToken}) } // Check the two new passwords are identical diff --git a/login.go b/login.go index 44ffe66..af883d7 100644 --- a/login.go +++ b/login.go @@ -44,23 +44,23 @@ func login(login string, password string) ([]*ldap.EntryAttribute, error) { func tryLogin(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - displayTmpl(w, "login.html", map[string]interface{}{}) + displayTmpl(w, "login.html", map[string]any{}) return } if !authLimiter.Allow(remoteIP(r)) { - displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."}) + displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]any{"error": "Too many login attempts. Please try again later."}) return } if !validateAltcha(r) { - displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + displayTmplError(w, http.StatusForbidden, "login.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."}) return } if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) + displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]any{"error": err.Error()}) } else { apiToken := AddyAPIToken(r.PostFormValue("login")) @@ -83,7 +83,7 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { } } } - displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok

Here are the information we have about you:` + cnt + "

To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "

")}) + displayTmpl(w, "message.html", map[string]any{"details": template.HTML(`Login ok

Here are the information we have about you:` + cnt + "

To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "

")}) } } @@ -107,7 +107,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) { for _, e := range entries { for _, v := range e.Values { if e.Name != "userPassword" { - w.Write([]byte(fmt.Sprintf("%s: %s", e.Name, v))) + fmt.Fprintf(w, "%s: %s", e.Name, v) } } } diff --git a/lost.go b/lost.go index 1e4c9d3..7afe093 100644 --- a/lost.go +++ b/lost.go @@ -88,7 +88,7 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { func lostPassword(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) { - displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."}) + displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]any{"error": "Too many requests. Please try again later."}) return } @@ -98,17 +98,17 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken}) + displayTmpl(w, "lost.html", map[string]any{"csrf_token": csrfToken}) return } if !validateCSRF(r) { - displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."}) + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again."}) return } if !validateAltcha(r) { - displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."}) return } @@ -116,7 +116,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to process your request. Please try again later."}) return } @@ -167,7 +167,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { s, err = d.Dial() if err != nil { log.Println("Unable to connect to email server: " + err.Error()) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."}) return } } else { @@ -202,7 +202,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { if err := gomail.Send(s, m); err != nil { log.Println("Unable to send email: " + err.Error()) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."}) return } diff --git a/main.go b/main.go index 36ab61d..42c2ef9 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "encoding/json" "flag" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -104,7 +104,7 @@ func main() { if configfile != nil && *configfile != "" { if fd, err := os.Open(*configfile); err != nil { log.Fatal(err) - } else if cnt, err := ioutil.ReadAll(fd); err != nil { + } else if cnt, err := io.ReadAll(fd); err != nil { log.Fatal(err) } else if err := json.Unmarshal(cnt, &myLDAP); err != nil { log.Fatal(err) @@ -137,7 +137,7 @@ func main() { if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD_FILE"); ok { if fd, err := os.Open(val); err != nil { log.Fatal(err) - } else if cnt, err := ioutil.ReadAll(fd); err != nil { + } else if cnt, err := io.ReadAll(fd); err != nil { log.Fatal(err) } else { myLDAP.ServicePassword = string(cnt) @@ -239,7 +239,7 @@ func main() { go func() { log.Fatal(srv.ListenAndServe()) }() - log.Println(fmt.Sprintf("Ready, listening on %s", *bind)) + log.Printf("Ready, listening on %s", *bind) // Wait shutdown signal <-interrupt diff --git a/reset.go b/reset.go index a37f99d..a65c27c 100644 --- a/reset.go +++ b/reset.go @@ -16,7 +16,7 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { return } - base := map[string]interface{}{ + base := map[string]any{ "login": r.URL.Query().Get("l"), "token": r.URL.Query().Get("t"), } diff --git a/static.go b/static.go index aa8b8a2..7713995 100644 --- a/static.go +++ b/static.go @@ -23,7 +23,7 @@ func securityHeaders(next http.Handler) http.Handler { //go:embed all:static var assets embed.FS -func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{}) { +func displayTmpl(w http.ResponseWriter, page string, vars map[string]any) { data, err := assets.ReadFile("static/" + page) if err != nil { log.Fatalf("Unable to find %q: %s", page, err.Error()) @@ -45,7 +45,7 @@ func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{} tpl.ExecuteTemplate(w, "page", vars) } -func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]interface{}) { +func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]any) { w.WriteHeader(statusCode) displayTmpl(w, page, vars) } @@ -58,5 +58,5 @@ func displayMsg(w http.ResponseWriter, msg string, statusCode int) { label = "message" } - displayTmpl(w, "message.html", map[string]interface{}{label: msg}) + displayTmpl(w, "message.html", map[string]any{label: msg}) } From 910dd7b47a8ae3e0af8e181a8fd26bedf27f92d7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 8 Mar 2026 11:18:54 +0700 Subject: [PATCH 4/5] Add log to know LDAP server in use --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index 42c2ef9..b455f7a 100644 --- a/main.go +++ b/main.go @@ -239,6 +239,7 @@ func main() { go func() { log.Fatal(srv.ListenAndServe()) }() + log.Printf("Using LDAP server at %s:%d (baseDN: %s)", myLDAP.Host, myLDAP.Port, myLDAP.BaseDN) log.Printf("Ready, listening on %s", *bind) // Wait shutdown signal From 99def55e8081ca7933791d339f652f1b043ca0cc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 8 Mar 2026 11:49:51 +0700 Subject: [PATCH 5/5] feat: replace Bootstrap with custom CSS and add profile page - Add self-hosted style.css replacing Bootstrap CDN dependency - Add profile.html with tabbed view (account info, emails/aliases, API token) - Refactor login handler to pass structured data to template instead of building HTML strings - Add brand-name and brand-logo flags/env vars for UI customization - Update CSP to allow brand logo domain and remove CDN references - Update all templates to pass template vars to header/footer and use new CSS classes Co-Authored-By: Claude Sonnet 4.6 --- login.go | 131 +++++++++--- main.go | 13 ++ static.go | 32 ++- static/change.html | 67 +++---- static/footer.html | 1 - static/header.html | 27 ++- static/login.html | 23 ++- static/lost.html | 19 +- static/message.html | 12 +- static/profile.html | 76 +++++++ static/reset.html | 21 +- static/style.css | 479 ++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 795 insertions(+), 106 deletions(-) create mode 100644 static/profile.html create mode 100644 static/style.css diff --git a/login.go b/login.go index af883d7..63af618 100644 --- a/login.go +++ b/login.go @@ -2,8 +2,6 @@ package main import ( "fmt" - "html" - "html/template" "log" "net/http" "net/url" @@ -12,6 +10,69 @@ import ( "github.com/go-ldap/ldap/v3" ) +type profileField struct { + Name string + Label string + Value string +} + +type profileAlias struct { + Value string + URLSafe string + ElemID string + Token string +} + +var ldapLabels = map[string]string{ + "cn": "Full name", + "uid": "Username", + "givenName": "First name", + "sn": "Last name", + "displayName": "Display name", + "telephoneNumber": "Phone", + "mobile": "Mobile", + "employeeNumber": "Employee ID", + "o": "Organization", + "ou": "Department", + "title": "Title", + "description": "Description", + "labeledURI": "Website", +} + +// ldapSkip lists attributes that should never be shown to the user. +var ldapSkip = map[string]bool{ + "userPassword": true, + "krbPrincipalKey": true, + "objectClass": true, + "entryUUID": true, + "entryDN": true, + "structuralObjectClass": true, + "hasSubordinates": true, + "krbExtraData": true, +} + +// isGeneratedAlias returns true for auto-generated alias local parts: +// exactly 10 characters and containing at least one digit or uppercase letter, +// which distinguishes them from plain words like "postmaster" or "abonnement". +func isGeneratedAlias(local string) bool { + if len(local) != 10 { + return false + } + for _, c := range local { + if c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' { + return true + } + } + return false +} + +func ldapLabel(name string) string { + if l, ok := ldapLabels[name]; ok { + return l + } + return name +} + func login(login string, password string) ([]*ldap.EntryAttribute, error) { conn, err := myLDAP.Connect() if err != nil || conn == nil { @@ -58,33 +119,57 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { return } - if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { + loginName := r.PostFormValue("login") + entries, err := login(loginName, r.PostFormValue("password")) + if err != nil { log.Println(err) displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]any{"error": err.Error()}) - } else { - apiToken := AddyAPIToken(r.PostFormValue("login")) + return + } - cnt := "
    " - for _, e := range entries { - for i, v := range e.Values { - safeName := html.EscapeString(e.Name) - safeVal := html.EscapeString(v) - elemID := fmt.Sprintf("mailAlias-%d", i) - if e.Name == "userPassword" || e.Name == "krbPrincipalKey" { - cnt += "
  • " + safeName + ": [...]
  • " - } else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 { - safeURL := url.PathEscape(v) - safeToken := html.EscapeString(apiToken) - safeElemID := html.EscapeString(elemID) - cnt += `
  • ` + safeName + `: ` + safeVal + - `
  • ` - } else { - cnt += "
  • " + safeName + ": " + safeVal + "
  • " - } + apiToken := AddyAPIToken(loginName) + var fields []profileField + var emails []string + var aliases []profileAlias + aliasIdx := 0 + + for _, e := range entries { + if ldapSkip[e.Name] { + continue + } + for _, v := range e.Values { + switch { + case e.Name == "mail": + emails = append(emails, v) + case e.Name == "mailAlias" && isGeneratedAlias(strings.SplitN(v, "@", 2)[0]): + elemID := fmt.Sprintf("alias-%d", aliasIdx) + aliasIdx++ + aliases = append(aliases, profileAlias{ + Value: v, + URLSafe: url.PathEscape(v), + ElemID: elemID, + Token: apiToken, + }) + case e.Name == "mailAlias": + emails = append(emails, v) + default: + fields = append(fields, profileField{ + Name: e.Name, + Label: ldapLabel(e.Name), + Value: v, + }) } } - displayTmpl(w, "message.html", map[string]any{"details": template.HTML(`Login ok

    Here are the information we have about you:` + cnt + "

To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "

")}) } + + displayTmpl(w, "profile.html", map[string]any{ + "login": loginName, + "fields": fields, + "emails": emails, + "aliases": aliases, + "api_token": apiToken, + "card_wide": true, + }) } func httpBasicAuth(w http.ResponseWriter, r *http.Request) { diff --git a/main.go b/main.go index b455f7a..32a1124 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,8 @@ import ( var myPublicURL = "https://ldap.nemunai.re" var devMode bool +var brandName = "chldapasswd" +var brandLogo = "" // dockerRegistrySecret is required for X-Special-Auth anonymous access. // If empty, the feature is disabled. @@ -82,10 +84,20 @@ func main() { var configfile = flag.String("config", "", "path to the configuration file") var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails") var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing") + var bname = flag.String("brand-name", "chldapasswd", "Brand name displayed in the UI") + var blogo = flag.String("brand-logo", "", "URL of brand logo displayed in the UI (added to CSP img-src)") flag.Parse() myPublicURL = *publicURL devMode = *dev + brandName = *bname + brandLogo = *blogo + if val, ok := os.LookupEnv("BRAND_NAME"); ok { + brandName = val + } + if val, ok := os.LookupEnv("BRAND_LOGO"); ok { + brandLogo = val + } if devMode { log.Println("WARNING: running in development mode — security features relaxed, do not use in production") } @@ -220,6 +232,7 @@ func main() { // Register handlers http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS) + http.HandleFunc(fmt.Sprintf("GET %s/style.css", *baseURL), serveStyleCSS) http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge) http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) diff --git a/static.go b/static.go index 7713995..6c5f316 100644 --- a/static.go +++ b/static.go @@ -5,6 +5,8 @@ import ( "html/template" "log" "net/http" + "net/url" + "strings" ) func securityHeaders(next http.Handler) http.Handler { @@ -12,7 +14,16 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98=' https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com; worker-src blob:") + + imgSrc := "'self' data:" + if strings.HasPrefix(brandLogo, "http://") || strings.HasPrefix(brandLogo, "https://") { + if u, err := url.Parse(brandLogo); err == nil { + imgSrc += " " + u.Scheme + "://" + u.Host + } + } + csp := "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; style-src 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98='; img-src " + imgSrc + "; worker-src blob:" + w.Header().Set("Content-Security-Policy", csp) + if !devMode { w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") } @@ -23,7 +34,26 @@ func securityHeaders(next http.Handler) http.Handler { //go:embed all:static var assets embed.FS +func serveStyleCSS(w http.ResponseWriter, r *http.Request) { + data, err := assets.ReadFile("static/style.css") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + func displayTmpl(w http.ResponseWriter, page string, vars map[string]any) { + if vars == nil { + vars = map[string]any{} + } + vars["brand_name"] = brandName + if brandLogo != "" { + vars["brand_logo"] = brandLogo + } + data, err := assets.ReadFile("static/" + page) if err != nil { log.Fatalf("Unable to find %q: %s", page, err.Error()) diff --git a/static/change.html b/static/change.html index 424b171..5b6fcaf 100644 --- a/static/change.html +++ b/static/change.html @@ -1,49 +1,46 @@ -{{template "header"}} -

Change your password Fill the following fields!

+{{template "header" .}} +

Change your password

+

Fill the following fields!

- {{if .error}}{{end}} + {{if .error}}{{end}} -
+
-
+
-
- -
+
-
+
-
- -
+
-
+
-
- -
+
-
+
- - Forgot your password? +
+ + Forgot your password? +
-{{template "footer"}} +{{template "footer" .}} diff --git a/static/footer.html b/static/footer.html index e7c1c45..2d6e1aa 100644 --- a/static/footer.html +++ b/static/footer.html @@ -1,5 +1,4 @@ {{define "footer"}} -
diff --git a/static/header.html b/static/header.html index be8afa0..6591d91 100644 --- a/static/header.html +++ b/static/header.html @@ -1,18 +1,17 @@ {{define "header"}} - - - - - - - - - nemunai.re password change - - - -
-
+ + + + {{if .brand_name}}{{.brand_name}} - {{end}}Password management + + + + +
+
+ {{if .brand_logo}}{{end}} + {{if .brand_name}}{{.brand_name}}{{else}}Password management{{end}} +
{{end}} diff --git a/static/login.html b/static/login.html index ec0678a..10ffa71 100644 --- a/static/login.html +++ b/static/login.html @@ -1,18 +1,21 @@ -{{template "header"}} -

Sign in Don't have an account? Create one!

+{{template "header" .}} +

Sign in

+

Don't have an account? Create one!

- {{if .error}}{{end}} -
+ {{if .error}}{{end}} +
-
+
-
+
- - Forgot your password? - -{{template "footer"}} +
+ + Forgot your password? +
+ +{{template "footer" .}} diff --git a/static/lost.html b/static/lost.html index bba8878..2044c1c 100644 --- a/static/lost.html +++ b/static/lost.html @@ -1,16 +1,19 @@ -{{template "header"}} -

Forgot your password? We'll send you a link by e-mail to reset it!

+{{template "header" .}} +

Forgot your password?

+

We'll send you a link by e-mail to reset it!

- {{if .error}}{{end}} + {{if .error}}{{end}} -
+
-
+
- - Just want to change your password? +
+ + Just want to change your password? +
-{{template "footer"}} +{{template "footer" .}} diff --git a/static/message.html b/static/message.html index f377b6e..081b18f 100644 --- a/static/message.html +++ b/static/message.html @@ -1,5 +1,7 @@ -{{template "header"}} - {{if .message}}
{{.message}}
{{end}} - {{if .error}}
{{.error}}
{{end}} - {{if .details}}

{{.details}}

{{end}} -{{template "footer"}} +{{template "header" .}} +
+ {{if .message}}
{{.message}}
{{end}} + {{if .error}}
{{.error}}
{{end}} + {{if .details}}

{{.details}}

{{end}} +
+{{template "footer" .}} diff --git a/static/profile.html b/static/profile.html new file mode 100644 index 0000000..680c197 --- /dev/null +++ b/static/profile.html @@ -0,0 +1,76 @@ +{{template "header" .}} +

Welcome, {{.login}}

+ + + +
+ {{if .fields}} + + {{range .fields}} + + + + + {{end}} +
{{.Label}}{{.Value}}
+ {{else}} +

No account information available.

+ {{end}} +
+ + {{if or .emails .aliases}} + + {{end}} + + + + +{{template "footer" .}} diff --git a/static/reset.html b/static/reset.html index 382ab00..dee828f 100644 --- a/static/reset.html +++ b/static/reset.html @@ -1,23 +1,26 @@ -{{template "header"}} -

Forgot your password? Define a new one!

+{{template "header" .}} +

Forgot your password?

+

Define a new one!

- {{if .error}}{{end}} + {{if .error}}{{end}} -
+
-
+
-
+
-
+
- +
+ +
-{{template "footer"}} +{{template "footer" .}} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..2f24d06 --- /dev/null +++ b/static/style.css @@ -0,0 +1,479 @@ +/* ============================================================ + CSS custom properties + ============================================================ */ +:root { + --bg: #f4f6f9; + --card-bg: #ffffff; + --card-shadow: 0 4px 24px rgba(0,0,0,.10); + --text: #1a1d23; + --text-muted: #6b7280; + --border: #d1d5db; + --input-bg: #ffffff; + --input-focus-border: #4f46e5; + --input-focus-shadow: 0 0 0 3px rgba(79,70,229,.15); + --btn-primary-bg: #4f46e5; + --btn-primary-hover: #4338ca; + --btn-secondary-bg: #f3f4f6; + --btn-secondary-hover: #e5e7eb; + --btn-secondary-text: #374151; + --alert-error-bg: #fef2f2; + --alert-error-border: #fca5a5; + --alert-error-text: #991b1b; + --alert-success-bg: #f0fdf4; + --alert-success-border: #86efac; + --alert-success-text: #166534; + --brand-border: #e5e7eb; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1117; + --card-bg: #1e2130; + --card-shadow: 0 4px 24px rgba(0,0,0,.40); + --text: #e5e7eb; + --text-muted: #9ca3af; + --border: #374151; + --input-bg: #111827; + --input-focus-border: #6366f1; + --input-focus-shadow: 0 0 0 3px rgba(99,102,241,.20); + --btn-primary-bg: #6366f1; + --btn-primary-hover: #4f46e5; + --btn-secondary-bg: #374151; + --btn-secondary-hover: #4b5563; + --btn-secondary-text: #d1d5db; + --alert-error-bg: #3b1515; + --alert-error-border: #7f1d1d; + --alert-error-text: #fca5a5; + --alert-success-bg: #052e16; + --alert-success-border: #14532d; + --alert-success-text: #86efac; + --brand-border: #374151; + } +} + +/* ============================================================ + Base + ============================================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +/* ============================================================ + Card + ============================================================ */ +.card { + background: var(--card-bg); + border-radius: 12px; + box-shadow: var(--card-shadow); + padding: 2rem 2.5rem; + width: 100%; + max-width: 440px; +} + +/* ============================================================ + Brand + ============================================================ */ +.brand { + display: flex; + align-items: center; + gap: 0.625rem; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--brand-border); +} + +.brand-logo { + height: 2rem; + width: auto; + object-fit: contain; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text); +} + +/* ============================================================ + Page title / subtitle + ============================================================ */ +.page-title { + font-size: 1.375rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.25rem; +} + +.page-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 1.5rem; +} + +.page-subtitle a { + color: var(--btn-primary-bg); + text-decoration: none; +} + +.page-subtitle a:hover { + text-decoration: underline; +} + +/* ============================================================ + Alerts + ============================================================ */ +.alert { + border-radius: 8px; + padding: 0.75rem 1rem; + font-size: 0.9rem; + margin-bottom: 1rem; + border: 1px solid transparent; +} + +.alert-error { + background: var(--alert-error-bg); + border-color: var(--alert-error-border); + color: var(--alert-error-text); +} + +.alert-success { + background: var(--alert-success-bg); + border-color: var(--alert-success-border); + color: var(--alert-success-text); +} + +/* ============================================================ + Forms + ============================================================ */ +.form-field { + margin-bottom: 1rem; +} + +.form-control { + display: block; + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 0.9375rem; + font-family: inherit; + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color .15s, box-shadow .15s; + outline: none; + -webkit-appearance: none; +} + +.form-control:focus { + border-color: var(--input-focus-border); + box-shadow: var(--input-focus-shadow); +} + +.form-control::placeholder { + color: var(--text-muted); +} + +.form-control:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Input group with toggle button */ +.input-group { + position: relative; +} + +.input-group .form-control { + padding-right: 3rem; +} + +.btn-toggle-password { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + border-radius: 0 8px 8px 0; + transition: color .15s; + padding: 0; +} + +.btn-toggle-password:hover { + color: var(--text); +} + +/* ============================================================ + Buttons + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + font-family: inherit; + font-weight: 500; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: background .15s, color .15s, border-color .15s, box-shadow .15s; + white-space: nowrap; +} + +.btn-primary { + background: var(--btn-primary-bg); + color: #ffffff; + border-color: var(--btn-primary-bg); +} + +.btn-primary:hover { + background: var(--btn-primary-hover); + border-color: var(--btn-primary-hover); +} + +.btn-secondary { + background: var(--btn-secondary-bg); + color: var(--btn-secondary-text); + border-color: var(--border); +} + +.btn-secondary:hover { + background: var(--btn-secondary-hover); +} + +.btn-group { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.5rem; +} + +/* ============================================================ + Message page + ============================================================ */ +.message-page { + padding: 0.5rem 0; +} + +.details-text { + color: var(--text-muted); + font-size: 0.9rem; + margin-top: 0.75rem; +} + +/* ============================================================ + altcha widget + ============================================================ */ +.form-field altcha-widget { + display: block; +} + +/* ============================================================ + Card wide (profile page) + ============================================================ */ +.card-wide { + max-width: 680px; +} + +/* ============================================================ + Tabs + ============================================================ */ +.tabs { + display: flex; + gap: 0.25rem; + border-bottom: 2px solid var(--border); + margin-bottom: 1.5rem; +} + +.tab-btn { + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-family: inherit; + font-weight: 500; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + cursor: pointer; + color: var(--text-muted); + border-radius: 4px 4px 0 0; + transition: color .15s, border-color .15s; +} + +.tab-btn:hover { + color: var(--text); +} + +.tab-btn.active { + color: var(--btn-primary-bg); + border-bottom-color: var(--btn-primary-bg); +} + +.tab-panel.hidden { + display: none; +} + +/* ============================================================ + Profile: Account tab + ============================================================ */ +.info-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.info-table th, +.info-table td { + padding: 0.5rem 0.625rem; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.info-table th { + width: 38%; + color: var(--text-muted); + font-weight: 500; + white-space: nowrap; +} + +.info-table tr:last-child th, +.info-table tr:last-child td { + border-bottom: none; +} + +.section-empty { + color: var(--text-muted); + font-size: 0.9rem; +} + +/* ============================================================ + Profile: Email & Aliases tab + ============================================================ */ +.section-title { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.625rem; +} + +.section-title + .section-title, +.email-list + .section-title, +.alias-list + .section-title { + margin-top: 1.25rem; +} + +.email-list { + list-style: none; + margin-bottom: 0.5rem; +} + +.email-list li { + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.email-list li:last-child { + border-bottom: none; +} + +.alias-list { + list-style: none; +} + +.alias-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} + +.alias-item:last-child { + border-bottom: none; +} + +.alias-value { + font-size: 0.875rem; + word-break: break-all; +} + +/* ============================================================ + Profile: API tab + ============================================================ */ +.api-token-box { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem 1rem; + word-break: break-all; + margin: 0.75rem 0; + user-select: all; +} + +.section-desc { + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.section-desc code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + background: var(--btn-secondary-bg); + padding: 0.1em 0.35em; + border-radius: 4px; + color: var(--text); +} + +/* ============================================================ + Button variants + ============================================================ */ +.btn-danger { + background: #dc2626; + color: #ffffff; + border-color: #dc2626; +} + +.btn-danger:hover { + background: #b91c1c; + border-color: #b91c1c; +} + +.btn-sm { + padding: 0.3rem 0.75rem; + font-size: 0.8125rem; + border-radius: 6px; + white-space: nowrap; + flex-shrink: 0; +}