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
- 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>
152 lines
4.9 KiB
Go
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
|
|
}
|