feat: auto-detect login text color based on background image brightness

This commit is contained in:
2026-04-09 22:09:30 +02:00
parent 2f55ec84b8
commit b4424b1e64
8 changed files with 104 additions and 9 deletions
+1 -1
View File
@@ -235,7 +235,7 @@ Navigate to **Admin Settings** (owner only) to configure:
### Login Page Customization
- **Background Image** — Upload a custom background image for the login page (max 5 MB, JPEG/PNG/WebP). The image is centered and fills the screen without distortion (`background-size: cover`).
- **Background Image** — Upload a custom background image for the login page (max 5 MB, JPEG/PNG/WebP). The image is centered and fills the screen without distortion (`background-size: cover`). The text color (heading, subtitle, footer) is automatically adjusted based on the image brightness — light text for dark images, dark text for bright images.
- **Card Style** — Choose between **Default** (solid card) and **Glass** (transparent, blurred backdrop).
- **Subtitle** — Custom text shown below the application name on the login page. Leave empty for the default subtitle.
+1
View File
@@ -121,4 +121,5 @@ In addition to environment variables, the following settings are configured thro
| `lockout_duration` | `15` | Lockout duration in minutes |
| `mfa_required` | `false` | Enforce MFA for all users |
| `login_card_style` | `default` | Login card style: `default` or `glass` |
| `login_text_color` | `light` | Login text color over background image: `light` or `dark` (auto-detected on upload) |
| `login_subtitle` | _(empty)_ | Custom subtitle on the login page |
+4 -1
View File
@@ -8,4 +8,7 @@ require (
golang.org/x/crypto v0.49.0
)
require golang.org/x/sys v0.42.0 // indirect
require (
golang.org/x/image v0.39.0 // indirect
golang.org/x/sys v0.42.0 // indirect
)
+2
View File
@@ -4,6 +4,8 @@ github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmIT
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+72 -1
View File
@@ -5,6 +5,7 @@
package handlers
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
@@ -15,6 +16,9 @@ import (
"encoding/json"
"fmt"
"html/template"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"io/fs"
"math"
@@ -28,6 +32,8 @@ import (
"sync"
"time"
_ "golang.org/x/image/webp"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
"git.techniverse.net/scriptos/keywarden/internal/cron"
@@ -341,6 +347,13 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
}
return style
},
"loginTextColor": func() string {
c, _ := h.auth.GetSetting("login_text_color")
if c == "" {
return "light"
}
return c
},
"loginSubtitle": func() string {
subtitle, _ := h.auth.GetSetting("login_subtitle")
return subtitle
@@ -3212,7 +3225,14 @@ func (h *Handler) handleLoginBrandingUpload(w http.ResponseWriter, r *http.Reque
return
}
h.audit.Log(userID, audit.ActionBrandingChanged, "Login background image uploaded", clientIP(r))
// Auto-detect brightness and set text color accordingly
textColor := analyzeImageBrightness(data)
if err := h.auth.SetSetting("login_text_color", textColor); err != nil {
logging.Warn("Failed to save auto-detected text color: %v", err)
}
logging.Info("Login background uploaded: auto-detected text color = %s", textColor)
h.audit.Log(userID, audit.ActionBrandingChanged, fmt.Sprintf("Login background image uploaded (auto text color: %s)", textColor), clientIP(r))
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image uploaded successfully."), http.StatusSeeOther)
}
@@ -3226,6 +3246,8 @@ func (h *Handler) handleLoginBrandingRemoveBg(w http.ResponseWriter, r *http.Req
userID := h.getUserID(r)
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
os.Remove(bgPath)
// Reset auto-detected text color
_ = h.auth.SetSetting("login_text_color", "light")
h.audit.Log(userID, audit.ActionBrandingChanged, "Login background image removed", clientIP(r))
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image removed."), http.StatusSeeOther)
@@ -3251,6 +3273,55 @@ func (h *Handler) handleLoginBgServe(w http.ResponseWriter, r *http.Request) {
w.Write(data)
}
// analyzeImageBrightness decodes an image and computes the average perceived
// brightness using the ITU-R BT.709 luminance formula. It samples every Nth
// pixel for performance. Returns "light" if the image is dark (bright text
// needed) or "dark" if the image is bright (dark text needed).
func analyzeImageBrightness(data []byte) string {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
// Cannot decode → assume dark image, use light text
return "light"
}
bounds := img.Bounds()
width := bounds.Max.X - bounds.Min.X
height := bounds.Max.Y - bounds.Min.Y
totalPixels := width * height
// Sample step: aim for ~10 000 pixels max for performance
step := 1
if totalPixels > 10000 {
step = int(math.Sqrt(float64(totalPixels) / 10000))
if step < 1 {
step = 1
}
}
var sum float64
var count int
for y := bounds.Min.Y; y < bounds.Max.Y; y += step {
for x := bounds.Min.X; x < bounds.Max.X; x += step {
r, g, b, _ := img.At(x, y).RGBA()
// ITU-R BT.709 perceived luminance (values are 065535)
lum := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
sum += lum
count++
}
}
if count == 0 {
return "light"
}
avg := sum / float64(count)
// 65535 / 2 = 32767.5 → threshold at ~40% brightness
if avg < 26214 {
return "light" // dark image → use light/white text
}
return "dark" // bright image → use dark text
}
func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
userID := h.getUserID(r)
user, _ := h.auth.GetUserByID(userID)
+3 -1
View File
@@ -34,6 +34,8 @@
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
/* Dimmed subtitle */
.login-subtitle { opacity: 0.55; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
@@ -65,7 +67,7 @@
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
+5 -2
View File
@@ -34,6 +34,9 @@
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
/* Dimmed subtitle & footer */
.login-subtitle { opacity: 0.55; }
.login-footer { opacity: 0.55; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
@@ -65,7 +68,7 @@
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
@@ -133,7 +136,7 @@
{{end}}
</div>
</div>
<div class="text-center text-secondary mt-3">
<div class="text-center text-secondary login-footer mt-3">
&copy; 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a>
</div>
</div>
+16 -3
View File
@@ -26,6 +26,9 @@
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
/* Dimmed subtitle & footer on login page */
.login-subtitle { opacity: 0.55; }
.login-footer { opacity: 0.55; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
@@ -38,9 +41,19 @@
background-repeat: no-repeat !important;
background-attachment: fixed !important;
}
.login-heading { text-shadow: 0 2px 8px rgba(0,0,0,0.5); color: #fff !important; }
.login-subtitle { text-shadow: 0 1px 4px rgba(0,0,0,0.4); color: rgba(255,255,255,0.85) !important; }
.login-footer { text-shadow: 0 1px 4px rgba(0,0,0,0.4); color: rgba(255,255,255,0.7) !important; }
{{if eq (loginTextColor) "dark"}}
/* Dark text for light background images */
.login-heading { text-shadow: 0 1px 6px rgba(255,255,255,0.7); color: #0a0f1a !important; }
.login-subtitle.text-secondary { text-shadow: 0 1px 4px rgba(255,255,255,0.6); color: rgba(10,15,26,0.85) !important; opacity: 0.75; }
.login-footer.text-secondary { text-shadow: 0 1px 4px rgba(255,255,255,0.6); color: rgba(10,15,26,0.80) !important; opacity: 0.70; }
.login-footer.text-secondary a.text-secondary { color: rgba(10,15,26,0.80) !important; }
{{else}}
/* Light text for dark background images (default) */
.login-heading { text-shadow: 0 2px 10px rgba(0,0,0,0.7); color: #fff !important; }
.login-subtitle.text-secondary { text-shadow: 0 2px 6px rgba(0,0,0,0.6); color: rgba(255,255,255,0.95) !important; opacity: 0.80; }
.login-footer.text-secondary { text-shadow: 0 2px 6px rgba(0,0,0,0.6); color: rgba(255,255,255,0.90) !important; opacity: 0.75; }
.login-footer.text-secondary a.text-secondary { color: rgba(255,255,255,0.90) !important; }
{{end}}
{{end}}
{{if eq (loginCardStyle) "glass"}}
/* Glass card effect */