Files
pipu-music/services/api/internal/auth/validate.go
T
mrleo1nidandClaude Opus 5.5 860072e542
ci / api / api (push) Successful in 36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / ts / ts (packages + web) (push) Canceled after 12s
ci / db / db (push) Successful in 2m43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 59s
feat(web): choose where the search sits, and controls no longer select text
- settings get a search position (left, center by default, right); the top bar
  is three zones and the suggestions open towards the free space
- buttons, chips, tabs, menu items and the home screen controls are not
  text-selectable, so clicks and double clicks no longer highlight labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:11:06 +03:00

152 lines
4.9 KiB
Go

package auth
import (
_ "embed"
"regexp"
"strings"
"unicode/utf8"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок валидации (переводятся клиентом, packages/i18n).
const (
CodePasswordTooShort = "validation.password_too_short"
CodePasswordTooLong = "validation.password_too_long"
CodePasswordSameAsUsername = "validation.password_same_as_username"
CodePasswordTooCommon = "validation.password_too_common"
CodePasswordUnchanged = "validation.password_unchanged"
CodeUsernameInvalid = "validation.username_invalid"
CodeLocaleUnsupported = "validation.locale_unsupported"
CodeDisplayNameTooLong = "validation.display_name_too_long"
CodeUIPrefsInvalid = "validation.ui_prefs_invalid"
)
// Пределы длины. Верхний предел пароля защищает argon2 от гигантских входов.
const (
MinPasswordUser = 8
MinPasswordPrivileged = 12
MaxPassword = 256
MaxDisplayName = 64
)
var usernameRe = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`)
// NormalizeUsername приводит логин к нижнему регистру без пробелов по краям.
func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// ValidateUsername проверяет нормализованный логин: 3..32 символа [a-z0-9._-].
func ValidateUsername(field, username string) error {
if !usernameRe.MatchString(username) {
return apperr.Validation(field, CodeUsernameInvalid, "username must be 3-32 chars of [a-z0-9._-]",
map[string]any{"min": 3, "max": 32})
}
return nil
}
// MinPasswordLen — минимальная длина пароля для роли.
func MinPasswordLen(r Role) int {
if r == RoleUser {
return MinPasswordUser
}
return MinPasswordPrivileged
}
// ValidatePassword проверяет новый пароль по политике: длина по роли, не совпадает с логином, не из списка частых.
func ValidatePassword(field, password, username string, role Role) error {
n := utf8.RuneCountInString(password)
if minLen := MinPasswordLen(role); n < minLen {
return apperr.Validation(field, CodePasswordTooShort, "password is too short", map[string]any{"min": minLen})
}
if n > MaxPassword {
return apperr.Validation(field, CodePasswordTooLong, "password is too long", map[string]any{"max": MaxPassword})
}
lower := strings.ToLower(password)
if username != "" && lower == strings.ToLower(username) {
return apperr.Validation(field, CodePasswordSameAsUsername, "password equals username", nil)
}
if _, ok := commonPasswords[lower]; ok {
return apperr.Validation(field, CodePasswordTooCommon, "password is too common", nil)
}
return nil
}
//go:embed common_passwords.txt
var commonPasswordsTxt string
var commonPasswords = func() map[string]struct{} {
m := make(map[string]struct{})
for line := range strings.SplitSeq(commonPasswordsTxt, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
m[strings.ToLower(line)] = struct{}{}
}
}
return m
}()
var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"search_position": {"center", "left", "right"},
"mobile_player": {"cover", "wave"},
}
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
func ValidateUIPrefs(p UIPrefs) error {
check := func(name string, v *string) error {
if v == nil {
return nil
}
for _, allowed := range uiPrefEnums[name] {
if *v == allowed {
return nil
}
}
return apperr.Validation("ui_prefs."+name, CodeUIPrefsInvalid, "unknown value", nil)
}
for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
"search_position": p.SearchPos,
} {
if err := check(name, v); err != nil {
return err
}
}
if p.CrossfadeS != nil && (*p.CrossfadeS < 0 || *p.CrossfadeS > 6) {
return apperr.Validation("ui_prefs.crossfade_s", CodeUIPrefsInvalid, "crossfade must be 0..6",
map[string]any{"min": 0, "max": 6})
}
return nil
}
// MergeUIPrefs накладывает заданные поля patch на base.
func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.Theme != nil {
base.Theme = patch.Theme
}
if patch.WaveVariant != nil {
base.WaveVariant = patch.WaveVariant
}
if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer
}
if patch.SearchPos != nil {
base.SearchPos = patch.SearchPos
}
if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer
}
if patch.Ambient != nil {
base.Ambient = patch.Ambient
}
if patch.CrossfadeS != nil {
base.CrossfadeS = patch.CrossfadeS
}
if patch.Normalize != nil {
base.Normalize = patch.Normalize
}
return base
}