Files

262 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
var validate *validator.Validate
func init() {
validate = validator.New()
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "" || name == "-" {
return field.Name
}
return name
})
mustRegister("peerName", validatePeerName)
mustRegister("credentialStr", validateCredentialStr)
mustRegister("adminPassword", validateAdminPassword)
}
func mustRegister(tag string, fn validator.Func) {
if err := validate.RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
}
}
// validatePeerName — единственное правило имени пира.
//
// Набор символов и длина берутся из service: имя пира проверяется на двух
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
// независимые копии правила уже расходились. Копия в слое контроллеров
// выглядела так:
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// validateCredentialStr — единственное правило имени администратора.
//
// Набор символов и длина берутся из service по той же причине, что и у имени
// пира: собственная копия правила в слое контроллеров уже расходилась с
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
// третье правило.
//
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
// была ловушка: у поля-указателя `required` считает непустым сам факт
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
// у него на самом деле.
func validateCredentialStr(f validator.FieldLevel) bool {
return credential.IsValidAdminUsername(f.Field().String())
}
// validateAdminPassword — единственное правило пароля администратора.
//
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
// системная ошибка на штатной смене пароля.
//
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
func validateAdminPassword(f validator.FieldLevel) bool {
return credential.IsValidAdminPassword(f.Field().String())
}
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
// правила.
//
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
// нарушение любого правила любого поля превращались в одну строку `invalid`:
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
// же и предлагала сделать.
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
var bindErr error
switch c.Request.Method {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field)
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = strictBindJSON(c, &field)
}
if bindErr != nil {
vo.FailValidation(
"запрос не разобран: проверьте формат и типы полей",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Message: bindErr.Error(),
}},
c,
)
return field, errors.New(constant.ErrCodeBodyInvalid)
}
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
// состояниями для валидатора и одинаковыми для человека.
if normalizable, ok := any(&field).(dto.Normalizable); ok {
normalizable.Normalize()
}
if err := validate.Struct(&field); err != nil {
vo.FailValidation(
"проверка данных не пройдена",
describeValidationErrors(err),
c,
)
return field, errors.New(constant.ErrCodeValidationFailed)
}
return field, nil
}
// describeValidationErrors переводит отказ валидатора в список причин.
func describeValidationErrors(err error) []vo.FieldError {
var validationErrors validator.ValidationErrors
if !errors.As(err, &validationErrors) {
// InvalidValidationError означает ошибку программиста (в проверку
// передали не структуру), а не плохой вход оператора. Скрывать её за
// сообщением о поле нельзя: она никогда не чинится правкой формы.
return []vo.FieldError{{
Code: constant.ErrCodeValidationFailed,
Message: err.Error(),
}}
}
out := make([]vo.FieldError, 0, len(validationErrors))
for _, fieldErr := range validationErrors {
out = append(out, describeFieldError(fieldErr))
}
return out
}
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
// величину. Указатели валидатор к этому моменту уже разыменовал.
func isTextField(fieldErr validator.FieldError) bool {
return fieldErr.Kind() == reflect.String
}
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
field := fieldErr.Field()
param := fieldErr.Param()
described := vo.FieldError{Field: field}
switch fieldErr.Tag() {
case "required":
described.Code = constant.ErrCodeRequired
described.Message = fmt.Sprintf("поле %q обязательно", field)
case "min":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMinLength
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
break
}
described.Code = constant.ErrCodeMin
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
case "max":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMaxLength
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
break
}
described.Code = constant.ErrCodeMax
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
case "len":
described.Code = constant.ErrCodeLen
described.Params = map[string]string{"len": param}
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
case "oneof":
described.Code = constant.ErrCodeOneOf
described.Params = map[string]string{"values": param}
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
case "gt":
described.Code = constant.ErrCodeGreaterThan
described.Params = map[string]string{"gt": param}
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
case "peerName":
described.Code = constant.ErrCodePeerName
described.Params = map[string]string{
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
"charset": service.PeerNameCharset,
}
described.Message = fmt.Sprintf(
"имя пира: от %d до %d символов из набора %s",
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
)
case "credentialStr":
// Причина называет набор и границы ровно так же, как это делает
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
// вопрос оператора: набор символов логина ему негде посмотреть, а
// отказ по длине этой фразой описывался как отказ по символам.
described.Code = constant.ErrCodeCredentialStr
described.Params = map[string]string{
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
"charset": credential.AdminUsernameCharset,
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов из набора %s",
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
)
case "adminPassword":
// Причина называет ОБЕ границы, потому что их две и они в разных
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
// такую причину, сокращал бы пароль, который отвергнут не за это.
described.Code = constant.ErrCodeAdminPassword
described.Params = map[string]string{
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
field,
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
}
return described
}