Files
GL.iNet-Yongping.Xie f49525ee9c feat: add personal center with TOTP 2FA and trusted devices
- Personal Center page with profile editing and last-login info
- TOTP-based two-factor authentication with QR code setup
- 30-day trusted-device cookie to skip 2FA on familiar browsers
- Friendly browser/OS labels for the trusted device list

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-06 21:17:44 -07:00

112 lines
3.1 KiB
Go

// Package useragent provides a tiny, dependency-free helper that turns a raw
// HTTP User-Agent header into a short human-readable label such as
// "Chrome 146 · Windows" or "Safari 17 · iOS 17". It only recognises the
// browsers / OSes that we actually display in the trusted-device list — for
// anything unknown it falls back to a truncated copy of the original UA.
package useragent
import (
"regexp"
"strings"
)
// Friendly turns a raw User-Agent header into a short human-readable label.
// Returns "Unknown" for an empty input.
func Friendly(ua string) string {
ua = strings.TrimSpace(ua)
if ua == "" {
return "Unknown"
}
browser := parseBrowser(ua)
os := parseOS(ua)
switch {
case browser != "" && os != "":
return browser + " · " + os
case browser != "":
return browser
case os != "":
return os
}
// Fallback: truncated raw UA so we never lose information entirely.
if len(ua) > 80 {
return ua[:80] + "…"
}
return ua
}
// --- browser detection ----------------------------------------------------
// Order matters: Edge / Opera / Brave embed "Chrome" in their UA, so they must
// be checked first. Likewise Chrome embeds "Safari", so Safari is last.
var browserPatterns = []struct {
name string
re *regexp.Regexp
}{
{"Edge", regexp.MustCompile(`Edg(?:e|A|iOS)?/(\d+)`)},
{"Opera", regexp.MustCompile(`(?:OPR|Opera)/(\d+)`)},
{"Vivaldi", regexp.MustCompile(`Vivaldi/(\d+)`)},
{"Firefox", regexp.MustCompile(`Firefox/(\d+)`)},
{"Chrome", regexp.MustCompile(`(?:Chrome|CriOS)/(\d+)`)},
{"Safari", regexp.MustCompile(`Version/(\d+)[\d.]*\s+.*Safari/`)},
}
func parseBrowser(ua string) string {
for _, p := range browserPatterns {
m := p.re.FindStringSubmatch(ua)
if len(m) >= 2 {
return p.name + " " + m[1]
}
}
if strings.Contains(ua, "Safari/") {
return "Safari"
}
return ""
}
// --- OS detection ---------------------------------------------------------
var (
reAndroid = regexp.MustCompile(`Android (\d+)`)
reIOS = regexp.MustCompile(`(?:iPhone OS|CPU OS) (\d+)`)
reMac = regexp.MustCompile(`Mac OS X (\d+)[._](\d+)`)
)
func parseOS(ua string) string {
switch {
case strings.Contains(ua, "Windows NT"):
// Windows NT 10.0 covers Windows 10 and 11; Microsoft never bumped the
// NT version, so we can't tell them apart from the UA alone.
return "Windows"
case strings.Contains(ua, "Android"):
if m := reAndroid.FindStringSubmatch(ua); len(m) >= 2 {
return "Android " + m[1]
}
return "Android"
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad"):
if m := reIOS.FindStringSubmatch(ua); len(m) >= 2 {
return "iOS " + m[1]
}
return "iOS"
case strings.Contains(ua, "Mac OS X"):
if m := reMac.FindStringSubmatch(ua); len(m) >= 3 {
major := m[1]
minor := m[2]
// Mac OS X 10.x is "macOS", 11+ is also "macOS" but with the major
// number directly.
if major == "10" {
return "macOS"
}
return "macOS " + major + "." + minor
}
return "macOS"
case strings.Contains(ua, "CrOS"):
return "ChromeOS"
case strings.Contains(ua, "Linux"):
return "Linux"
}
return ""
}