fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+2
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
@@ -29,6 +30,7 @@ type apiResult struct {
Code int `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
Errors []vo.FieldError `json:"errors"`
Data json.RawMessage `json:"data"`
}
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// failService переводит отказ сервисного слоя в ответ панели.
//
// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля
// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с
// человеческим сообщением — панель покажет его как есть, но разбирать текст ей
// при этом не придётся ни в одном известном случае.
func failService(err error, c *gin.Context) {
var peerErr *service.PeerError
if errors.As(err, &peerErr) {
vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c)
return
}
vo.Fail(err.Error(), c)
}
+44 -14
View File
@@ -3,6 +3,7 @@ package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
@@ -18,18 +19,31 @@ import (
"hy2xs-admin/service"
)
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
//
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
// вызывала validateField, который читает тело запроса, а обработчик следом
// читает то же тело второй раз — gin его не буферизует, и второй разбор
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
// когда понадобился бы.
func resolveID(c *gin.Context) (int64, error) {
if raw := strings.TrimSpace(c.Param("id")); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err == nil && parsed > 0 {
return parsed, nil
}
raw := strings.TrimSpace(c.Param("id"))
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
vo.FailValidation(
"идентификатор пира в адресе некорректен",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Field: "id",
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
}},
c,
)
return 0, errors.New(constant.ErrCodeBodyInvalid)
}
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return 0, err
}
return *idDto.Id, nil
return parsed, nil
}
func Login(c *gin.Context) {
@@ -39,6 +53,14 @@ func Login(c *gin.Context) {
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
@@ -65,7 +87,7 @@ func SavePeer(c *gin.Context) {
}
peerVo, err := service.CreatePeer(peerSaveDto)
if err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(peerVo, c)
@@ -100,12 +122,12 @@ func UpdatePeer(c *gin.Context) {
return
}
if taken {
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
return
}
}
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(nil, c)
@@ -158,7 +180,15 @@ func ImportPeer(c *gin.Context) {
return
}
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
vo.Fail(constant.InvalidError, c)
vo.FailValidation(
"импорт принимает только файлы .json",
[]vo.FieldError{{
Code: constant.ErrCodeImportFileExtension,
Field: "file",
Message: "импорт принимает только файлы .json",
}},
c,
)
return
}
+505
View File
@@ -0,0 +1,505 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/service"
)
// Контракт формы пира: необязательный секрет и внятный отказ.
//
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
// слоёв и ни один из них по отдельности его не показывал: панель обещала
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
func newPeerControllerDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
}
// peerPayload — тело создания пира со всеми обязательными полями.
// Тесты меняют в нём ровно то, что проверяют.
func peerPayload(name string) map[string]any {
return map[string]any{
"name": name,
"quotaBytes": -1,
"expiresAt": 0,
"maxDevices": 3,
"disabled": 0,
"remark": "",
}
}
func createPeer(t *testing.T, body map[string]any) apiResult {
t.Helper()
return postJSON(t, SavePeer, "/peers", body)
}
// errorFor возвращает причину отказа по имени поля.
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
t.Helper()
for _, item := range result.Errors {
if item.Field == field {
return item.Code, true
}
}
return "", false
}
func storedPeer(t *testing.T, name string) entity.Peer {
t.Helper()
peer, err := dao.GetPeer("name = ?", name)
if err != nil {
t.Fatalf("пир %q не найден в базе: %v", name, err)
}
return peer
}
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
// генерация в CreatePeer была недостижима.
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
cases := map[string]func(map[string]any){
"поле отсутствует": func(body map[string]any) {},
"пустая строка": func(body map[string]any) { body["secret"] = "" },
"только пробелы": func(body map[string]any) { body["secret"] = " " },
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
}
for label, mutate := range cases {
t.Run(label, func(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
mutate(body)
result := createPeer(t, body)
if result.Type != "ok" {
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
result.Code, result.Message, result.Errors)
}
peer := storedPeer(t, "client-01")
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
t.Fatal("секрет не сохранён")
}
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
}
if len(secret) < 6 {
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
}
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
// записан, ничего не значит, пока по нему не проходит проверка
// доступа. Это же связывает digest и шифртекст между собой.
id, authID, authErr := service.Hysteria2Auth(secret)
if authErr != nil {
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
}
if id != *peer.Id || authID != *peer.AuthId {
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
}
})
}
}
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
// обязан быть случайным, а не производной от имени.
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
newPeerControllerDB(t)
secrets := make(map[string]struct{}, 5)
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
t.Fatalf("создание %q отклонено: %+v", name, result)
}
peer := storedPeer(t, name)
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
}
if _, seen := secrets[secret]; seen {
t.Fatalf("сгенерированный секрет повторился: %q", secret)
}
secrets[secret] = struct{}{}
}
}
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
cases := []struct {
label string
secret string
accepted bool
expectCode string
}{
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
{"6 символов", strings.Repeat("a", 6), true, ""},
{"128 символов", strings.Repeat("a", 128), true, ""},
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
body["secret"] = tc.secret
result := createPeer(t, body)
if tc.accepted {
if result.Type != "ok" {
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
}
peer := storedPeer(t, "client-01")
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("секрет не расшифровывается: %v", err)
}
if stored != tc.secret {
t.Fatalf("сохранён не тот секрет, который передали")
}
return
}
if result.Type != "no" {
t.Fatalf("секрет длиной %d принят", len(tc.secret))
}
code, ok := errorFor(t, result, "secret")
if !ok {
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
}
if code != tc.expectCode {
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
}
})
}
}
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
// вынуждена была бы разбирать текст, чтобы попытаться.
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
cases := []struct {
label string
body func() map[string]any
field string
code string
}{
{
label: "имя не передано",
body: func() map[string]any {
body := peerPayload("client-01")
delete(body, "name")
return body
},
field: "name",
code: constant.ErrCodeRequired,
},
{
label: "имя короче допустимого",
body: func() map[string]any { return peerPayload("pc1") },
field: "name",
code: constant.ErrCodePeerName,
},
{
label: "имя длиннее допустимого",
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
field: "name",
code: constant.ErrCodePeerName,
},
{
label: "лимит устройств меньше единицы",
body: func() map[string]any {
body := peerPayload("client-01")
body["maxDevices"] = 0
return body
},
field: "maxDevices",
code: constant.ErrCodeMin,
},
{
label: "disabled вне множества значений",
body: func() map[string]any {
body := peerPayload("client-01")
body["disabled"] = 7
return body
},
field: "disabled",
code: constant.ErrCodeOneOf,
},
{
label: "квота меньше минимума",
body: func() map[string]any {
body := peerPayload("client-01")
body["quotaBytes"] = -2
return body
},
field: "quotaBytes",
code: constant.ErrCodeMin,
},
{
label: "комментарий длиннее допустимого",
body: func() map[string]any {
body := peerPayload("client-01")
body["remark"] = strings.Repeat("я", 65)
return body
},
field: "remark",
code: constant.ErrCodeMaxLength,
},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, tc.body())
if result.Type != "no" {
t.Fatalf("некорректный ввод принят: %+v", result)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
}
code, ok := errorFor(t, result, tc.field)
if !ok {
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
}
if code != tc.code {
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
}
// Сообщение остаётся человекочитаемым для клиента без панели, но
// панель им не пользуется: у неё есть код.
if strings.TrimSpace(result.Message) == "" {
t.Fatal("отказ без человекочитаемого сообщения")
}
})
}
}
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
for _, name := range []string{
"peer/name",
"peer:name",
"peer;name",
"peer,name",
"peer.name",
"peer<name",
"peer name",
"пир-01",
} {
t.Run(name, func(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, peerPayload(name))
if result.Type != "no" {
t.Fatalf("имя %q принято", name)
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
}
// Обе двери в таблицу пиров обязаны требовать одного и того же.
if service.IsValidPeerName(name) {
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
}
})
}
}
func TestCreatePeerReportsTakenName(t *testing.T) {
newPeerControllerDB(t)
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
t.Fatalf("первое создание отклонено: %+v", result)
}
result := createPeer(t, peerPayload("client-01"))
if result.Type != "no" {
t.Fatal("повторное имя принято")
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
}
}
func TestCreatePeerReportsReservedName(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
if result.Type != "no" {
t.Fatal("зарезервированное имя принято")
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
}
}
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
// Панели важно различать: в первом случае подсвечивать нечего.
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
newPeerControllerDB(t)
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.POST("/peers", SavePeer)
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
if result.Type != "no" {
t.Fatal("неразбираемое тело принято")
}
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
}
if result.Errors[0].Field != "" {
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
}
}
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
engine := gin.New()
engine.PATCH("/peers/:id", UpdatePeer)
target := "/peers/" + strconv.FormatInt(id, 10)
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(payload)))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return result
}
// При изменении пустой секрет означает «не менять», и это то же самое
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
// когда оператор открыл форму и не трогал поле секрета.
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
newPeerControllerDB(t)
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
t.Fatalf("создание пира отклонено: %+v", result)
}
before := storedPeer(t, "client-01")
for _, blank := range []string{"", " "} {
result := patchPeer(t, *before.Id, map[string]any{
"name": "client-01",
"secret": blank,
"remark": "рабочее устройство",
})
if result.Type != "ok" {
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
}
after := storedPeer(t, "client-01")
if *after.SecretDigest != *before.SecretDigest {
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
}
if after.Remark == nil || *after.Remark != "рабочее устройство" {
t.Fatal("остальные поля формы не применились")
}
}
}
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
// которой нормализация проходит для каждого поля отдельно.
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
body["remark"] = "временная пометка"
if result := createPeer(t, body); result.Type != "ok" {
t.Fatalf("создание пира отклонено: %+v", result)
}
peer := storedPeer(t, "client-01")
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
t.Fatalf("очистка комментария отклонена: %+v", result)
}
after := storedPeer(t, "client-01")
if after.Remark != nil && *after.Remark != "" {
t.Fatalf("комментарий не очищен: %q", *after.Remark)
}
}
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
// «invalid» — то есть список пиров ломался в один клик по крестику.
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
newPeerControllerDB(t)
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/peers", PagePeer)
query := url.Values{}
query.Set("pageNum", "1")
query.Set("pageSize", "10")
query.Set("name", "")
query.Set("remark", "")
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
if result.Type != "ok" {
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
result.Code, result.Message, result.Errors)
}
}
+183 -17
View File
@@ -1,47 +1,213 @@
package controller
import (
"errors"
"fmt"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"net/http"
"regexp"
"hy2xs-admin/service"
)
var validate *validator.Validate
func init() {
validate = validator.New()
_ = validate.RegisterValidation("validateStr", validateStr)
// Имя поля в отказе — это имя из 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)
}
func validateStr(f validator.FieldLevel) bool {
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())
}
// credentialStrPattern — набор символов логина и пароля администратора.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
// пира.
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
func validateCredentialStr(f validator.FieldLevel) bool {
field := f.Field().String()
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
return field == "" || credentialStrPattern.MatchString(field)
}
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
// правила.
//
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
// нарушение любого правила любого поля превращались в одну строку `invalid`:
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
// же и предлагала сделать.
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
var bindErr error
if c.Request.Method == http.MethodGet {
switch c.Request.Method {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field)
} else if c.Request.Method == http.MethodPost ||
c.Request.Method == http.MethodPut ||
c.Request.Method == http.MethodPatch ||
c.Request.Method == http.MethodDelete {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = c.ShouldBindJSON(&field)
}
if bindErr != nil {
vo.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
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.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
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":
described.Code = constant.ErrCodeCredentialStr
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
}
return described
}
+78
View File
@@ -0,0 +1,78 @@
package controller
import (
"strings"
"testing"
"hy2xs-admin/service"
)
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
// набор символов не ограничивает, и сужение правила означало бы, что установка
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestCredentialCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !credentialStrPattern.MatchString(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} {
if credentialStrPattern.MatchString(rejected) {
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
}
}
}
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
// одну таблицу и не имеют права требовать разного.
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
accepted := []string{
"client-01",
"alpha1",
"bootstrap-admin-peer",
strings.Repeat("a", 6),
strings.Repeat("a", 32),
}
for _, name := range accepted {
if !service.IsValidPeerName(name) {
t.Errorf("имя %q отклонено, ожидался приём", name)
}
}
rejected := []string{
"",
" ",
"pc1",
strings.Repeat("a", 33),
"peer name",
"peer\nname",
"peer/name",
"peer:name",
"peer.name",
"пир-01",
}
for _, name := range rejected {
if service.IsValidPeerName(name) {
t.Errorf("имя %q принято, ожидался отказ", name)
}
}
}
+4
View File
@@ -16,11 +16,14 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
});
}
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
// ей не нужен: он повторял бы то же самое вторым сигналом.
export function savePeerApi(data: PeerSaveDto): AxiosPromise {
return request({
url: "/peers",
method: "post",
data,
skipErrorToast: true,
});
}
@@ -44,6 +47,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
url: `/peers/${data.id}`,
method: "patch",
data,
skipErrorToast: true,
});
}
+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="currentColor"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="currentColor"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 783 B

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 445 B

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="currentColor"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="currentColor"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="currentColor"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="currentColor"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="currentColor"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+18 -9
View File
@@ -1,33 +1,42 @@
<template>
<svg
aria-hidden="true"
focusable="false"
class="svg-icon"
:style="'width:' + size + ';height:' + size"
>
<use :xlink:href="symbolId" :fill="color" />
<use :xlink:href="symbolId" />
</svg>
</template>
<script setup lang="ts">
import { SYMBOL_PREFIX } from "./symbol";
/**
* Цвет иконке не передаётся — и это контракт, а не упущение.
*
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
* пользовался ни разу, а существование такого пропа приглашает чинить
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
* иконка обязана получать цвет ровно одним способом — наследованием
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
*
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
* объявлен рядом с кодом, который этот id создаёт.
*/
const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: {
type: String,
required: false,
},
color: {
type: String,
},
size: {
type: String,
default: "1em",
},
});
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
</script>
<style scoped>
+6 -63
View File
@@ -20,9 +20,14 @@
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке.
*
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
* релизным гейтом, а не только глазами на живой странице.
*/
const SYMBOL_PREFIX = "icon";
import { iconName, toSymbol } from "./symbol";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
@@ -33,68 +38,6 @@ const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
eager: true,
});
function iconName(filePath: string): string {
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
* тоже отбрасываются — размер задаёт компонент.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый плагин.
*/
function toSymbol(raw: string, name: string): string {
const withoutProlog = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
* содержимое, а не добавляет второй элемент с теми же id.
@@ -0,0 +1,206 @@
/**
* Превращение исходного SVG-файла в `<symbol>` и контракт, которому исходник
* обязан соответствовать.
*
* Модуль намеренно ЧИСТЫЙ: ни `import.meta.glob`, ни `document`, ни любого
* другого Vite/DOM API здесь нет. Сборка спрайта из файлов живёт в `sprite.ts`,
* а сюда вынесено ровно то, что можно выполнить вне браузера и вне Vite —
* то есть проверить тестом (`tools/test/frontend-sprite.test.ts`) и релизным
* гейтом.
*
* Разделение появилось не ради красоты. Цвет иконок был сломан молча: контракт
* `fill: currentcolor` существовал в двух местах (`SvgIcon/index.vue` и
* `styles/sidebar.scss`), но восемь из семнадцати ассетов несли литеральный
* атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления
* перебивает унаследованное CSS-свойство. Все семь иконок бокового меню
* рисовались чёрным по `--menuBg: #181818`. Ни одна существующая проверка
* этого не видела, потому что проверять было нечего: сам файл иконки под
* гейтом не был.
*/
/** Префикс id у `<symbol>`; `SvgIcon` строит по нему `<use href="#icon-…">`. */
export const SYMBOL_PREFIX = "icon";
/**
* Иконки, которые многоцветны НАМЕРЕННО.
*
* Для них собственная палитра — часть ассета, а не дефект, поэтому проверка
* цвета к ним не применяется. Список закрытый и явный: «многоцветность»
* обязана быть решением, а не следствием того, что иконку скачали с готовыми
* значениями fill.
*
* Всё остальное — монохромный UI: цвет наследуется от компонента и темы через
* `currentColor`, и это единственный способ, которым иконка может получить
* цвет. Ни CSS-фильтров, ни правил на конкретное имя иконки.
*/
export const MULTICOLOR_ICONS: ReadonlySet<string> = new Set([
"download",
"upload",
]);
/**
* Значения `fill`/`stroke`, которые цветом не являются и потому разрешены
* монохромной иконке.
*
* `none` — это «не закрашивать», а не цвет: у `refresh` контур рисуется
* штрихом, и `fill="none"` там обязателен.
*/
const NON_COLOR_PAINT = new Set(["currentcolor", "none", "inherit", "transparent"]);
/** Атрибуты, любое литеральное значение которых задаёт цвет. */
const PAINT_ATTRIBUTES = [
"fill",
"stroke",
"stop-color",
"flood-color",
"lighting-color",
];
/** `icons/log-system.svg` → `log-system`. */
export function iconName(filePath: string): string {
return filePath.replace(/^.*[\\/]/, "").replace(/\.svg$/i, "");
}
/**
* Убирает то, что внутри уже существующего документа не только бесполезно, но
* и делает разметку невалидной: XML-пролог, DOCTYPE и комментарии.
*/
function stripProlog(raw: string): string {
return raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* `width` и `height` отбрасываются вместе с корневым тегом — размер задаёт
* компонент. Цвета НЕ переписываются: источник истины — сам файл, а
* молчаливая нормализация в рантайме скрывала бы ровно тот дефект, который
* этот модуль обязан делать видимым. За соответствие отвечает
* `findIconContractViolations`, вызываемая тестом и релизным гейтом.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый `vite-plugin-svg-icons`.
*/
export function toSymbol(raw: string, name: string): string {
const withoutProlog = stripProlog(raw);
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
export function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Проверяет ассет на соответствие контракту спрайта.
*
* Возвращает список нарушений; пустой список означает, что иконка пригодна.
* Проверка одна на всех потребителей — тест и релизный гейт зовут её, а не
* повторяют правила у себя. Второй экземпляр этих правил неизбежно разошёлся
* бы с первым, и разошёлся бы молча.
*/
export function findIconContractViolations(raw: string, name: string): string[] {
const violations: string[] = [];
const source = stripProlog(raw);
const openTag = source.match(/<svg\b[^>]*>/i);
if (!openTag) {
return [`${name}: нет корневого <svg>`];
}
// Система координат: либо объявленный viewBox, либо пара width/height, из
// которой он синтезируется. Иконка без обоих способов сломала бы отрисовку
// молча.
if (!resolveViewBox(openTag[0])) {
violations.push(`${name}: нет ни viewBox, ни пары width/height`);
}
if (MULTICOLOR_ICONS.has(name)) {
return violations;
}
for (const attribute of PAINT_ATTRIBUTES) {
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*"([^"]*)"`, "gi");
for (const match of source.matchAll(pattern)) {
const value = match[1].trim();
if (value === "") {
continue;
}
if (!NON_COLOR_PAINT.has(value.toLowerCase())) {
violations.push(
`${name}: атрибут ${attribute}="${value}" задаёт цвет мимо currentColor`
);
}
}
}
// Инлайновый style бьёт и атрибут, и наследование, поэтому цвет в нём —
// такое же нарушение контракта, как литеральный атрибут.
for (const match of source.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) {
const declarations = match[1].toLowerCase();
for (const attribute of PAINT_ATTRIBUTES) {
const property = declarations.match(
new RegExp(`(?:^|;)\\s*${attribute}\\s*:\\s*([^;]+)`)
);
if (property && !NON_COLOR_PAINT.has(property[1].trim())) {
violations.push(
`${name}: инлайновый style задаёт ${attribute}: ${property[1].trim()}`
);
}
}
}
// Непустой <style> внутри ассета уезжает в документ вместе со спрайтом и
// способен покрасить что угодно, включая чужие иконки: селекторы там
// глобальные. Пустой блок остаётся от редакторов и безвреден.
for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
if (match[1].trim() !== "") {
violations.push(`${name}: непустой <style> внутри ассета`);
}
}
// Растр внутри иконки не наследует цвет ничем и никогда.
if (/<image\b/i.test(source)) {
violations.push(`${name}: растровое <image> не подчиняется currentColor`);
}
return violations;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Внутренние константы бренда.
*
* Единственное место, где живёт адрес атрибуции. Это не настройка: оператор
* HY2XS не должен иметь возможности переназначить, куда ведёт подпись
* разработчика, — ни через панель, ни через hy2xs.env, ни через таблицу
* `config`. Поэтому значение принадлежит приложению и попадает в бандл при
* сборке.
*
* По той же причине оно объявлено один раз, а не написано в шаблоне
* компонента: литерал, размазанный по нескольким Vue-файлам, невозможно ни
* проверить одним гейтом, ни изменить одной правкой.
*
* Отсутствие адреса в операторской конфигурации проверяется приёмкой сборки.
*/
export const FLAMY_NAME = "Flamy" as const;
export const FLAMY_URL = "https://flamy.studio" as const;
+61 -5
View File
@@ -112,7 +112,63 @@ export default {
invalid: "Invalid value",
switchLanguageSuccess: "Language switched successfully",
logoutConfirm: "Are you sure you want to log out?",
sessionExpired: "Current session has expired, please log in again",
sessionExpired: "Your session has expired. Sign in again to continue.",
signInRequired: "Signing in is required.",
signIn: "Sign in",
systemError: "System error",
networkError: "The server is not responding. Check the connection.",
},
error: {
field: {
name: "Peer name",
secret: "Secret",
remark: "Remark",
quotaBytes: "Quota",
expiresAt: "Expiry",
maxDevices: "Max devices",
disabled: "State",
bannedUntil: "Banned until",
file: "File",
id: "Identifier",
username: "Username",
pass: "Password",
oldPassword: "Old password",
newPassword: "New password",
key: "Setting key",
value: "Setting value",
numLine: "Line count",
pageNum: "Page number",
pageSize: "Page size",
},
code: {
required: "“{field}”: required",
min: "“{field}”: must not be less than {min}",
max: "“{field}”: must not be greater than {max}",
min_length: "“{field}”: at least {min} characters",
max_length: "“{field}”: at most {max} characters",
len: "“{field}”: length must be exactly {len}",
oneof: "“{field}”: allowed values are {values}",
gt: "“{field}”: must be greater than {gt}",
peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: contains characters that are not allowed",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid: "Request could not be parsed: check field formats and types",
peer_name_taken: "A peer with this name already exists",
peer_name_reserved: "This name is reserved for the installer peer",
peer_bootstrap_identity_locked:
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
invalid_credentials: "Wrong username or password",
import_file_extension: "Import accepts .json files only",
unauthorized: "Signing in is required",
session_expired: "Session expired",
token_invalid: "Session is not valid",
account_disabled: "Account is disabled",
},
},
sidebar: {
developedBy: "Made at {brand}",
},
info: {
expireTime: "y-M-d H:m:s",
@@ -128,12 +184,12 @@ export default {
remark: "Remark",
secret: "Secret",
form: {
namePlaceholder: "e.g. ivan-laptop",
namePlaceholder: "client-01",
nameHint:
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
"Peer identifier: 6 to 32 characters, latin letters, digits and hyphens. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically",
secretPlaceholder: "leave empty to generate one",
secretHint:
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
+69 -5
View File
@@ -109,7 +109,71 @@ export default {
invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён",
logoutConfirm: "Выйти из системы?",
sessionExpired: "Текущая сессия истекла, войдите снова",
sessionExpired: "Сессия истекла. Войдите снова, чтобы продолжить.",
signInRequired: "Требуется вход в панель.",
signIn: "Войти",
systemError: "Системная ошибка",
networkError: "Сервер не отвечает. Проверьте соединение с панелью.",
},
// Причины отказа API.
//
// Ключи строятся из КОДА ответа, а не из его текста: панель не разбирает
// человеческие сообщения сервера. Числа правил приходят в параметрах, поэтому
// второй копии границ длины здесь нет — она неизбежно разошлась бы с
// серверной.
error: {
field: {
name: "Имя пира",
secret: "Секрет",
remark: "Комментарий",
quotaBytes: "Квота",
expiresAt: "Срок действия",
maxDevices: "Лимит устройств",
disabled: "Состояние",
bannedUntil: "Блокировка до",
file: "Файл",
id: "Идентификатор",
username: "Логин",
pass: "Пароль",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
key: "Ключ настройки",
value: "Значение настройки",
numLine: "Число строк",
pageNum: "Номер страницы",
pageSize: "Размер страницы",
},
code: {
required: "«{field}»: поле обязательно",
min: "«{field}»: значение не может быть меньше {min}",
max: "«{field}»: значение не может быть больше {max}",
min_length: "«{field}»: не короче {min} символов",
max_length: "«{field}»: не длиннее {max} символов",
len: "«{field}»: длина должна быть ровно {len}",
oneof: "«{field}»: допустимые значения — {values}",
gt: "«{field}»: значение должно быть больше {gt}",
peer_name:
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
credential_format: "«{field}»: недопустимые символы",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
peer_name_taken: "Пир с таким именем уже существует",
peer_name_reserved: "Это имя зарезервировано за пиром установщика",
peer_bootstrap_identity_locked:
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
invalid_credentials: "Неверный логин или пароль",
import_file_extension: "Импорт принимает только файлы .json",
unauthorized: "Требуется вход в панель",
session_expired: "Сессия истекла",
token_invalid: "Сессия недействительна",
account_disabled: "Учётная запись отключена",
},
},
sidebar: {
// {brand} подставляется ссылкой, поэтому фраза обязана остаться одной
// строкой с одним подстановочным местом.
developedBy: "Разработано во {brand}",
},
info: {
expireTime: "г-М-д Ч:м:с",
@@ -124,12 +188,12 @@ export default {
remark: "Комментарий",
secret: "Секрет",
form: {
namePlaceholder: "например, ivan-laptop",
namePlaceholder: "client-01",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: апример, Ноутбук Ивана, отдел продаж",
"Идентификатор пира: от 6 до 32 символов, латиница, цифры и дефис. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: оутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
secretPlaceholder: "оставьте пустым — сгенерируем",
secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { FLAMY_NAME, FLAMY_URL } from "@/constants/branding";
defineProps({
collapse: {
type: Boolean,
required: true,
},
});
</script>
<template>
<div class="sidebar-footer" :class="{ 'is-collapsed': collapse }">
<!--
Свёрнутое меню шириной 54px не вмещает фразу целиком, поэтому в нём
остаётся только имя-ссылка. Прятать подпись совсем нельзя: атрибуция
обязана быть видна в обоих состояниях.
-->
<a
v-if="collapse"
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
<i18n-t v-else keypath="sidebar.developedBy" tag="span" scope="global">
<template #brand>
<a
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
</template>
</i18n-t>
</div>
</template>
<style lang="scss" scoped>
.sidebar-footer {
display: flex;
align-items: center;
justify-content: center;
height: $sidebarFooterHeight;
padding: 0 12px;
overflow: hidden;
font-size: 12px;
line-height: 1.2;
color: rgb(255 255 255 / 45%);
text-align: center;
white-space: nowrap;
background-color: var(--menuBg);
border-top: 1px solid rgb(255 255 255 / 6%);
}
.sidebar-footer.is-collapsed {
padding: 0 4px;
}
.sidebar-footer-brand {
color: var(--el-color-primary);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
</style>
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
import SidebarItem from "./SidebarItem.vue";
import Logo from "./Logo.vue";
import Footer from "./Footer.vue";
import { usePermissionStore } from "@/store/modules/permission";
import { useAppStore } from "@/store/modules/app";
@@ -36,5 +37,6 @@ const route = useRoute();
/>
</el-menu>
</el-scrollbar>
<Footer :collapse="!appStore.sidebar.opened" />
</div>
</template>
+11 -1
View File
@@ -38,12 +38,22 @@
height: 100%;
}
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
// длинном списке: им физически некуда.
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
height: calc(100% - 50px - #{$sidebarFooterHeight});
}
}
.sidebar-footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
.is-horizontal {
display: none;
}
+7
View File
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
$sideBarWidth: 210px;
$sideBarCollapsedWidth: 54px;
// Высота подписи разработчика внизу бокового меню.
//
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
$sidebarFooterHeight: 34px;
+111
View File
@@ -0,0 +1,111 @@
/**
* Разбор структурированного отказа API.
*
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
* не имел ни одного способа узнать причину.
*
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
*/
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
export const API_CODE = {
success: 20000,
systemError: 50000,
validationFailed: 50001,
unauthorized: 50401,
forbidden: 50403,
} as const;
/**
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
*
* Перечислены только те, на которые панель реагирует по-разному. Остальные
* доезжают до оператора сообщением сервера.
*/
export const ERR_CODE = {
bodyInvalid: "body_invalid",
validationFailed: "validation_failed",
required: "required",
min: "min",
max: "max",
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
// разные фразы для оператора.
minLength: "min_length",
maxLength: "max_length",
len: "len",
oneOf: "oneof",
greaterThan: "gt",
ruleViolated: "rule_violated",
peerName: "peer_name",
credentialFormat: "credential_format",
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
invalidCredentials: "invalid_credentials",
importFileExtension: "import_file_extension",
unauthorized: "unauthorized",
sessionExpired: "session_expired",
tokenInvalid: "token_invalid",
accountDisabled: "account_disabled",
} as const;
export interface ApiFieldError {
code: string;
field?: string;
message: string;
params?: Record<string, string>;
}
export interface ApiErrorPayload {
code: number;
message?: string;
errors?: ApiFieldError[];
}
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
export class ApiError extends Error {
readonly code: number;
readonly errors: ApiFieldError[];
constructor(payload: ApiErrorPayload) {
super(payload.message || "Error");
this.name = "ApiError";
this.code = payload.code;
this.errors = payload.errors ?? [];
}
/** Причины, привязанные к полям формы. */
fieldErrors(): ApiFieldError[] {
return this.errors.filter((item) => !!item.field);
}
/** Первая причина без привязки к полю — отказ уровня операции. */
operationError(): ApiFieldError | undefined {
return this.errors.find((item) => !item.field);
}
hasCode(code: string): boolean {
return this.errors.some((item) => item.code === code);
}
get requiresSignIn(): boolean {
return this.code === API_CODE.unauthorized;
}
get sessionExpired(): boolean {
return (
this.hasCode(ERR_CODE.sessionExpired) ||
this.hasCode(ERR_CODE.accountDisabled)
);
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
+73
View File
@@ -0,0 +1,73 @@
import i18n from "@/lang/index";
import { ApiError, ApiFieldError } from "@/utils/api-error";
/**
* Локализация причины отказа.
*
* Ключ строится ИЗ КОДА, а не из текста ответа. Сервер присылает и своё
* человекочитаемое сообщение — оно остаётся ответом для клиента без панели и
* запасным вариантом здесь: код, которого панель ещё не знает, обязан доехать
* до оператора хоть в каком-то виде, а не превратиться в пустую строку.
*
* Числа правил (границы длины, допустимые значения) приходят в `params`.
* Второй копии этих чисел в панели нет намеренно: копия неизбежно разошлась бы
* с серверной, и оператор читал бы «от 6 до 128», получая отказ по другим
* границам.
*/
const t = i18n.global.t;
const te = i18n.global.te;
/** Локализованное название поля формы; при отсутствии — имя из ответа. */
function fieldLabel(field: string): string {
const key = `error.field.${field}`;
return te(key) ? t(key) : field;
}
/** Сообщение по одной причине отказа. */
export function describeFieldError(error: ApiFieldError): string {
const key = `error.code.${error.code}`;
if (te(key)) {
return t(key, {
field: error.field ? fieldLabel(error.field) : "",
...(error.params ?? {}),
});
}
return error.message;
}
/** Причины по именам полей формы — для подстановки в el-form. */
export function fieldErrorMap(error: ApiError): Record<string, string> {
const result: Record<string, string> = {};
for (const item of error.fieldErrors()) {
// Первая причина по полю выигрывает: показывать в одном поле две строки
// некуда, а порядок ответа отражает порядок правил.
if (item.field && !(item.field in result)) {
result[item.field] = describeFieldError(item);
}
}
return result;
}
/**
* Одна строка, пригодная для тоста.
*
* Отказ уровня операции показывается как есть. Отказ по полям сворачивается в
* перечисление «поле: причина» — тост при этом остаётся вторым сигналом, а
* первым служит подсветка самих полей.
*/
export function describeApiError(error: ApiError): string {
const operation = error.operationError();
if (operation) {
return describeFieldError(operation);
}
const fields = error.fieldErrors();
if (fields.length > 0) {
return fields
.map((item) => `${fieldLabel(item.field!)}: ${describeFieldError(item)}`)
.join("; ");
}
return error.message || t("common.systemError");
}
+105 -26
View File
@@ -1,6 +1,12 @@
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index";
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
import { describeApiError } from "@/utils/api-message";
const dynamicBase = (window as any).__dynamic_base__ || "";
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
@@ -10,6 +16,19 @@ const dynamicBase = (window as any).__dynamic_base__ || "";
// ADMIN_API_BASE в оркестраторе.
const API_BASE = "/api";
const t = i18n.global.t;
/**
* Запрос может отказаться от общего тоста, если показывает причину сам.
*
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
* второй сигнал тостом там только шумит.
*/
declare module "axios" {
export interface AxiosRequestConfig {
skipErrorToast?: boolean;
}
}
// Создание axios instance
const service = axios.create({
baseURL: `${dynamicBase}${API_BASE}`,
@@ -31,38 +50,98 @@ service.interceptors.request.use(
}
);
/**
* Сессия кончилась под руками у оператора.
*
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
* аргумент interceptors.response.use) для отказов API не вызывался вовсе — а
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
* Ключ common.sessionExpired существовал и был мёртвым.
*
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
* параллельных запросов страницы, и без этого оператор получил бы стопку
* одинаковых окон.
*/
let sessionPromptOpen = false;
function promptSignIn(expired: boolean): void {
if (sessionPromptOpen) {
return;
}
sessionPromptOpen = true;
const finish = () => {
sessionPromptOpen = false;
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
// есть заодно стирал выбранный оператором язык панели: при следующем входе
// интерфейс возвращался к значению по умолчанию без всякой причины.
useAdminStoreHook().resetToken();
const redirect = encodeURIComponent(
window.location.pathname + window.location.search
);
window.location.href = `/login?redirect=${redirect}`;
};
ElMessageBox.confirm(
expired ? t("common.sessionExpired") : t("common.signInRequired"),
t("common.warning"),
{
confirmButtonText: t("common.signIn"),
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
type: "warning",
}
)
.then(finish)
.catch(finish);
}
// Response interceptor
service.interceptors.response.use(
(response: AxiosResponse) => {
const { code, message } = response.data;
if (code === 20000) {
return response.data;
}
// Обработка бинарного ответа при экспорте файлов
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) {
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
// проверяться ДО обращения к его полям: у Blob их нет.
if (
response.data instanceof ArrayBuffer ||
response.data instanceof Blob
) {
return response;
}
ElMessage.error(message || "Системная ошибка");
return Promise.reject(new Error(message || "Error"));
},
(error: any) => {
if (error.response.data) {
const { code, msg } = error.response.data;
// Token истёк, нужен повторный вход
if (code === "A0230") {
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
confirmButtonText: t("common.confirm"),
type: "warning",
}).then(() => {
localStorage.clear();
window.location.href = "/";
});
} else {
ElMessage.error(msg || "Системная ошибка");
}
const payload = response.data as ApiErrorPayload;
if (payload?.code === API_CODE.success) {
return response.data;
}
return Promise.reject(error.message);
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
if (apiError.requiresSignIn) {
promptSignIn(apiError.sessionExpired);
return Promise.reject(apiError);
}
if (!response.config?.skipErrorToast) {
ElMessage.error(describeApiError(apiError));
}
return Promise.reject(apiError);
},
(error: AxiosError) => {
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
// самого error.response — то есть при обрыве соединения падал с
// TypeError и подменял настоящую причину отказом внутри обработчика.
const message = error.response
? t("common.systemError")
: t("common.networkError");
if (!error.config?.skipErrorToast) {
ElMessage.error(message);
}
return Promise.reject(error);
}
);
+175 -23
View File
@@ -184,21 +184,29 @@
:rules="rules"
label-width="140px"
>
<el-form-item :label="$t('peer.name')" prop="name">
<el-form-item
:label="$t('peer.name')"
prop="name"
:error="serverErrors.name"
>
<el-input
v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.remark')">
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
<el-input
v-model="dataForm.remark"
:placeholder="$t('peer.form.remarkPlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret">
<el-form-item
:label="$t('peer.secret')"
prop="secret"
:error="serverErrors.secret"
>
<el-input
v-model="dataForm.secret"
show-password
@@ -206,20 +214,24 @@
/>
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.quota')">
<el-form-item :label="$t('peer.quota')" :error="serverErrors.quotaBytes">
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.expireTime')"
<el-form-item
:label="$t('peer.expireTime')"
:error="serverErrors.expiresAt"
><el-date-picker
v-model="dataForm.expiresAt"
type="datetime"
value-format="x"
/></el-form-item>
<el-form-item :label="$t('peer.maxDevices')"
<el-form-item
:label="$t('peer.maxDevices')"
:error="serverErrors.maxDevices"
><el-input-number v-model="dataForm.maxDevices" :min="1"
/></el-form-item>
<el-form-item :label="$t('peer.disabled')"
<el-form-item :label="$t('peer.disabled')" :error="serverErrors.disabled"
><el-switch v-model="disabledBool"
/></el-form-item>
</el-form>
@@ -342,7 +354,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import QrcodeVue from "qrcode.vue";
import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard";
@@ -369,6 +381,8 @@ import {
PeerVo,
} from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { isApiError } from "@/utils/api-error";
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
/**
* Единственный переход от строки слота таблицы к модели пира.
@@ -430,6 +444,53 @@ const disabledBool = computed({
set: (v: boolean) => (dataForm.disabled = v ? 1 : 0),
});
/**
* Причины отказа, присланные сервером, — по именам полей формы.
*
* Сервер остаётся ЕДИНСТВЕННЫМ авторитетом: правила ниже лишь избавляют
* оператора от лишнего похода на сервер за очевидной ошибкой, а окончательный
* ответ всегда даёт он. Поэтому его причины подставляются прямо под поля, а не
* показываются тостом «Invalid», как было раньше.
*/
const serverErrors = reactive<Record<string, string>>({});
function clearServerErrors() {
for (const key of Object.keys(serverErrors)) {
delete serverErrors[key];
}
}
/**
* Правка поля снимает серверную причину с НЕГО.
*
* Проп `error` у `el-form-item` перекрывает внутреннее состояние проверки:
* оставленная под полем серверная причина висела бы там, пока оператор
* исправляет значение, и не исчезала бы даже когда локальные правила уже
* довольны. Снимается причина только с изменённого поля — остальные отказы
* той же отправки всё ещё в силе, и убирать их означало бы скрыть работу,
* которую оператору ещё предстоит сделать.
*/
watch(
() => ({ ...dataForm }),
(next, previous) => {
if (!previous) {
return;
}
for (const key of Object.keys(serverErrors)) {
if (next[key as keyof typeof next] !== previous[key as keyof typeof previous]) {
delete serverErrors[key];
}
}
}
);
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
const SECRET_MIN_LENGTH = 6;
const SECRET_MAX_LENGTH = 128;
const rules = {
name: [
{
@@ -437,6 +498,57 @@ const rules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: PEER_NAME_PATTERN,
message: t("error.code.peer_name", {
field: t("error.field.name"),
min: 6,
max: 32,
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
}),
trigger: ["change", "blur"],
},
],
secret: [
{
// Пустое поле — законный ввод: секрет сгенерирует сервер. Проверяется
// только НЕПУСТОЕ значение.
validator: (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
const manual = (value ?? "").trim();
if (manual === "") {
callback();
return;
}
if (manual.length < SECRET_MIN_LENGTH) {
callback(
new Error(
t("error.code.min_length", {
field: t("error.field.secret"),
min: SECRET_MIN_LENGTH,
})
)
);
return;
}
if (manual.length > SECRET_MAX_LENGTH) {
callback(
new Error(
t("error.code.max_length", {
field: t("error.field.secret"),
max: SECRET_MAX_LENGTH,
})
)
);
return;
}
callback();
},
trigger: ["change", "blur"],
},
],
};
@@ -481,6 +593,7 @@ async function handleQuery() {
}
function handleAdd() {
clearServerErrors();
Object.assign(dataForm, {
id: undefined,
name: "",
@@ -497,6 +610,7 @@ function handleAdd() {
}
async function handleUpdate(row: PeerVo) {
clearServerErrors();
const { data } = await getPeerApi({ id: row.id });
Object.assign(dataForm, data, { secret: "" });
dialog.title = t("common.update");
@@ -505,6 +619,8 @@ async function handleUpdate(row: PeerVo) {
}
async function submitForm() {
clearServerErrors();
if (formRef.value) {
const ok = await formRef.value.validate().catch(() => false);
if (!ok) return;
@@ -516,25 +632,59 @@ async function submitForm() {
{ type: "warning" }
);
}
if (dialog.editId > 0) {
const payload: PeerUpdateDto = {
id: dialog.editId,
name: dataForm.name,
secret: dataForm.secret || undefined,
quotaBytes: dataForm.quotaBytes,
expiresAt: dataForm.expiresAt,
maxDevices: dataForm.maxDevices,
disabled: dataForm.disabled,
remark: dataForm.remark,
};
await updatePeerApi(payload);
} else {
await savePeerApi(dataForm);
try {
if (dialog.editId > 0) {
const payload: PeerUpdateDto = {
id: dialog.editId,
name: dataForm.name,
// Пустой секрет при изменении означает «не менять», и сервер читает
// его именно так. Отправлять undefined больше не требуется, но и вреда
// в этом нет: оба состояния для него теперь одинаковы.
secret: dataForm.secret || undefined,
quotaBytes: dataForm.quotaBytes,
expiresAt: dataForm.expiresAt,
maxDevices: dataForm.maxDevices,
disabled: dataForm.disabled,
remark: dataForm.remark,
};
await updatePeerApi(payload);
} else {
// Секрет отправляется как есть, включая пустую строку: автогенерация —
// обязанность сервера, а не подстановка значения здесь.
await savePeerApi(dataForm);
}
} catch (error) {
applyServerErrors(error);
return;
}
dialog.visible = false;
await handleQuery();
}
/**
* Раскладывает отказ сервера по полям формы.
*
* Если причина не относится ни к одному полю — это отказ уровня операции
* (например, имя уже занято другим пиром при переименовании), и он
* показывается тостом. Диалог при этом остаётся открытым: закрывать форму,
* потерявшую введённое, из-за исправимой ошибки нельзя.
*/
function applyServerErrors(error: unknown) {
if (!isApiError(error)) {
// Транспортный отказ уже показан общим перехватчиком.
return;
}
const byField = fieldErrorMap(error);
Object.assign(serverErrors, byField);
if (Object.keys(byField).length === 0) {
ElMessage.error(describeApiError(error));
}
}
async function handleDelete(row: PeerVo) {
await ElMessageBox.confirm(
t("common.deleteConfirm", { username: row.name }),
@@ -615,7 +765,9 @@ async function downloadExport(includeSecrets: boolean) {
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.downloadSuccess"));
} catch {
ElMessage.error(t("common.invalid"));
// Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
// общий разбор конверта: у Blob нет полей code и errors.
ElMessage.error(t("common.systemError"));
}
}
+3 -3
View File
@@ -12,18 +12,18 @@ func AdminHandler() gin.HandlerFunc {
return func(c *gin.Context) {
claimsRaw, ok := c.Get("adminClaims")
if !ok {
vo.Fail(constant.UnauthorizedError, c)
vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
c.Abort()
return
}
claims, castOK := claimsRaw.(bo.AccountBo)
if !castOK {
vo.Fail(constant.IllegalTokenError, c)
vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
c.Abort()
return
}
if !util.ArrContain(claims.Roles, "admin") {
vo.Fail(constant.ForbiddenError, c)
vo.FailForbidden(constant.ForbiddenError, c)
c.Abort()
return
}
+47 -6
View File
@@ -1,41 +1,62 @@
package middleware
import (
"errors"
"strings"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"strings"
)
// Отказ аутентификации несёт КОД состояния сессии.
//
// Раньше все ветки здесь звали vo.Fail с человеческой строкой, а код ответа
// выводился в vo сравнением этой строки с тремя известными литералами. Под
// условия подходил только `unauthorized`; `token expired` и `authentication
// failed` уезжали к панели как обычная системная ошибка с кодом 50000.
//
// Следствие было видимым для оператора: истёкшая сессия на открытой странице
// давала голый тост «token expired», ветка «войдите заново» не срабатывала
// никогда, а перебросить на форму входа мог только переход по маршруту,
// которому потребовался бы getAdminInfo. Ключ локализации `common.sessionExpired`
// при этом существовал и был мёртвым.
func JWTHandler() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" {
vo.Fail(constant.UnauthorizedError, c)
vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
vo.Fail(constant.IllegalTokenError, c)
vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
c.Abort()
return
}
myClaims, err := service.ParseToken(parts[1])
if err != nil {
vo.Fail(err.Error(), c)
vo.FailUnauthorized(tokenErrorCode(err), err.Error(), c)
c.Abort()
return
}
admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id)
if err != nil {
// Это уже не состояние сессии, а отказ чтения учётной записи:
// сворачивать его в «войдите заново» значило бы отправлять
// оператора на форму входа при недоступной базе.
vo.Fail(err.Error(), c)
c.Abort()
return
}
if admin.Status != nil && *admin.Status != 1 {
vo.Fail("this account has been disabled", c)
vo.FailUnauthorized(
constant.ErrCodeAccountDisabled,
"this account has been disabled",
c,
)
c.Abort()
return
}
@@ -44,7 +65,9 @@ func JWTHandler() gin.HandlerFunc {
tokenVersion = *admin.TokenVersion
}
if myClaims.Admin.TokenVersion != tokenVersion {
vo.Fail(constant.IllegalTokenError, c)
// Версия токена сменилась: пароль изменён или доступ отозван.
// Для оператора это неотличимо от истёкшей сессии — вход заново.
vo.FailUnauthorized(constant.ErrCodeSessionExpired, constant.IllegalTokenError, c)
c.Abort()
return
}
@@ -52,3 +75,21 @@ func JWTHandler() gin.HandlerFunc {
c.Next()
}
}
// tokenErrorCode различает истёкший токен и недействительный.
//
// Вопрос задаётся ЗНАЧЕНИЮ ошибки, а не её тексту: service.ParseToken
// возвращает объявленные значения, поэтому правка формулировки сообщения не
// может молча превратить истёкшую сессию в неизвестную ошибку.
//
// Отказ прочитать ключ подписи (недоступная база) сюда тоже приходит, и это
// НЕ состояние сессии. Отдельного кода он не получает намеренно: снаружи
// панели такой отказ неотличим от недействительного токена, и предлагать
// оператору войти заново — единственное осмысленное действие, которое ему
// доступно.
func tokenErrorCode(err error) string {
if errors.Is(err, service.ErrTokenExpired) {
return constant.ErrCodeSessionExpired
}
return constant.ErrCodeTokenInvalid
}
+90
View File
@@ -0,0 +1,90 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// Состояние сессии сообщается КОДОМ, а не текстом.
//
// Регрессия. Все отказы аутентификации звали vo.Fail с человеческой строкой, а
// код ответа выводился сравнением этой строки с тремя известными литералами.
// Под условия подходил только `unauthorized`; истёкший токен уезжал с кодом
// системной ошибки 50000, панель показывала оператору голый тост
// «token expired» и не понимала, что сессия кончилась. Ключ локализации
// common.sessionExpired существовал и был мёртвым, а вернуть оператора на
// форму входа мог только переход по маршруту, которому потребовался бы
// getAdminInfo.
type authResponse struct {
Code int `json:"code"`
Type string `json:"type"`
Errors []vo.FieldError `json:"errors"`
}
func callJWTHandler(t *testing.T, header string) authResponse {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/guarded", JWTHandler(), func(c *gin.Context) {
vo.Success(nil, c)
})
request := httptest.NewRequest(http.MethodGet, "/guarded", nil)
if header != "" {
request.Header.Set("Authorization", header)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var parsed authResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &parsed); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return parsed
}
func TestJWTHandlerReportsMissingCredentials(t *testing.T) {
response := callJWTHandler(t, "")
if response.Code != constant.CodeUnauthorizedError {
t.Fatalf("код ответа %d, ожидался %d", response.Code, constant.CodeUnauthorizedError)
}
if len(response.Errors) != 1 || response.Errors[0].Code != constant.ErrCodeUnauthorized {
t.Fatalf("неожиданное описание отказа: %+v", response.Errors)
}
}
func TestJWTHandlerReportsMalformedAuthorizationHeader(t *testing.T) {
for _, header := range []string{"token-without-scheme", "Basic dXNlcjpwYXNz"} {
response := callJWTHandler(t, header)
if response.Code != constant.CodeUnauthorizedError {
t.Errorf("заголовок %q: код ответа %d, ожидался %d",
header, response.Code, constant.CodeUnauthorizedError)
continue
}
if len(response.Errors) != 1 || response.Errors[0].Code != constant.ErrCodeTokenInvalid {
t.Errorf("заголовок %q: неожиданное описание отказа: %+v", header, response.Errors)
}
}
}
// Истёкшая сессия обязана быть отличима от недействительного токена: панель
// показывает оператору разные вещи и по-разному его возвращает на вход.
func TestTokenErrorCodeSeparatesExpiryFromInvalidity(t *testing.T) {
if code := tokenErrorCode(service.ErrTokenExpired); code != constant.ErrCodeSessionExpired {
t.Errorf("истёкший токен получил код %q, ожидался %q", code, constant.ErrCodeSessionExpired)
}
if code := tokenErrorCode(service.ErrTokenInvalid); code != constant.ErrCodeTokenInvalid {
t.Errorf("недействительный токен получил код %q, ожидался %q", code, constant.ErrCodeTokenInvalid)
}
}
+57
View File
@@ -12,3 +12,60 @@ const (
WrongPassword string = "wrong password"
ConfigNotExist string = "config not exist"
)
// Коды структурированных ошибок.
//
// Зачем они есть. Раньше единственным машиночитаемым признаком ошибки был
// числовой `code` ответа, а всё остальное жило в человеческом тексте: слой vo
// выбирал HTTP-семантику СРАВНЕНИЕМ строки сообщения, а панель показывала
// оператору голое «invalid» на любую ошибку любого поля формы. Оба места
// разбирали прозу — то есть договор между сервером и панелью держался на
// совпадении литералов, которое ничто не проверяло.
//
// Теперь у ошибки есть код и — там, где ошибка относится к полю, — имя поля.
// Панель выбирает по коду свою локализованную строку и не разбирает текст;
// `message` остаётся человекочитаемым ответом для клиента без UI и запасным
// вариантом для кода, которого панель ещё не знает.
//
// Коды — часть публичного контракта API: их значения не меняются вместе с
// формулировками сообщений.
const (
// ErrCodeBodyInvalid — тело запроса не разобралось: не JSON, не тот тип
// поля, сломанная query-строка. Это отказ ДО проверки правил.
ErrCodeBodyInvalid string = "body_invalid"
// ErrCodeValidationFailed — общий код ответа, у которого есть errors[].
ErrCodeValidationFailed string = "validation_failed"
// Коды правил. Совпадают с именами тегов валидатора: одно правило — один
// код, и никакого второго словаря соответствий.
ErrCodeRequired string = "required"
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один. Оператору это разные фразы: «не меньше 1
// устройства» и «не короче 6 символов», — и панель обязана уметь их
// различить, не заводя у себя таблицу «какое поле какого рода».
ErrCodeMin string = "min"
ErrCodeMax string = "max"
ErrCodeMinLength string = "min_length"
ErrCodeMaxLength string = "max_length"
ErrCodeLen string = "len"
ErrCodeOneOf string = "oneof"
ErrCodeGreaterThan string = "gt"
ErrCodePeerName string = "peer_name"
ErrCodeCredentialStr string = "credential_format"
ErrCodeRuleUnknown string = "rule_violated"
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
ErrCodePeerNameTaken string = "peer_name_taken"
ErrCodePeerNameReserved string = "peer_name_reserved"
ErrCodePeerBootstrapLocked string = "peer_bootstrap_identity_locked"
ErrCodeInvalidCredentials string = "invalid_credentials"
ErrCodeImportFileExtension string = "import_file_extension"
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
// во втором случае оператор находится на рабочей странице, и молча
// выбрасывать его на форму входа без объяснения нельзя.
ErrCodeUnauthorized string = "unauthorized"
ErrCodeSessionExpired string = "session_expired"
ErrCodeTokenInvalid string = "token_invalid"
ErrCodeAccountDisabled string = "account_disabled"
)
+10
View File
@@ -7,6 +7,16 @@ type BaseDto struct {
EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // Время окончания
}
// Normalize: нулевая отметка времени — это отсутствие фильтра.
//
// Правило `omitempty,gt=0` на указателе не пропускается (см. normalize.go),
// поэтому пришедший `startTime=0` отказывал бы вместо того, чтобы означать
// «без ограничения снизу».
func (d *BaseDto) Normalize() {
zeroToNil(&d.StartTime)
zeroToNil(&d.EndTime)
}
type IdDto struct {
Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ
}
+5
View File
@@ -4,6 +4,11 @@ type LogDto struct {
NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"`
}
// Normalize: «показать 0 строк» — это не запрос, а пропущенный параметр.
func (d *LogDto) Normalize() {
zeroToNil(&d.NumLine)
}
type LogExportDto struct {
Option *int `json:"option" form:"option" validate:"required,oneof=0 1"`
}
+81
View File
@@ -0,0 +1,81 @@
package dto
import "strings"
// Приведение входа к каноничному виду ДО проверки правил.
//
// Зачем это нужно. В go-playground/validator тег `omitempty` НЕ пропускает
// правило, если поле объявлено указателем, а указатель не nil. Помощник
// `hasValue` (baked_in.go) устроен так:
//
// if fl.(*validate).fldIsPointer && getValue(field) != nil {
// return true
// }
//
// Для `*string`, указывающего на пустую строку, это возвращает true, то есть
// «значение есть». В результате `omitempty,min=6` на поле `Secret` срабатывало
// именно тогда, когда оператор НИЧЕГО не ввёл: панель отправляла `secret: ""`,
// правило `min=6` применялось к пустой строке и отказывало. Панель при этом
// писала под полем «оставьте пустым — сгенерируем автоматически», а сервер
// умел это сделать: генерация в CreatePeer существовала и была недостижима.
//
// Чинить это тегом на одном поле бессмысленно: ловушка одинаково стоит на
// фильтре списка пиров (очищенный `el-input` шлёт `?name=`, правило `min=1`
// отказывает поиску), на необязательных отметках времени и на всяком будущем
// необязательном поле-указателе. Поэтому нормализация — общий шаг конвейера, а
// не особый случай «если пусто, подставь строку».
//
// Правило формулируется ПОФАКТИЧЕСКИ, для каждого поля отдельно, и это
// сознательно. Пустая строка не везде означает «не задано»: у `remark` она
// означает «очистить пометку», и общее «пусто → nil» молча лишило бы оператора
// возможности её убрать. Ноль у `disabled` и `quotaBytes` — законное значение,
// а не пропуск.
// Normalizable — DTO, приводящее свой вход к каноничному виду.
//
// Вызывается слоем контроллеров между разбором тела и проверкой правил, то
// есть ровно один раз и для всех дверей одинаково.
type Normalizable interface {
Normalize()
}
// blankToNil: «пусто или одни пробелы» становится «не задано».
//
// Применяется к полям, у которых отсутствие значения — законный вход.
func blankToNil(field **string) {
if *field == nil {
return
}
trimmed := strings.TrimSpace(**field)
if trimmed == "" {
*field = nil
return
}
*field = &trimmed
}
// trimValue убирает окружающие пробелы, сохраняя само поле заданным.
//
// Применяется к обязательным полям и к тем, у которых пустая строка — это
// значение, а не пропуск. Пустой ввод после тримминга остаётся пустым и
// получит внятный отказ от `required`, а не молча превратится в «не задано».
func trimValue(field *string) {
if field == nil {
return
}
*field = strings.TrimSpace(*field)
}
// zeroToNil: ноль у необязательного числового поля означает «не задано».
//
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
// «показать 0 строк журнала» и «время начала — 1 января 1970 года» — это
// пропуск фильтра, а не запрос.
func zeroToNil[T int | int64](field **T) {
if *field == nil {
return
}
if **field == 0 {
*field = nil
}
}
+122
View File
@@ -0,0 +1,122 @@
package dto
import "testing"
func strPtr(v string) *string { return &v }
func i64Ptr(v int64) *int64 { return &v }
func intPtr(v int) *int { return &v }
// Граница проходит по КАЖДОМУ полю отдельно, и это главное свойство
// нормализации.
//
// Общее правило «пусто → не задано» выглядит соблазнительно и молча ломает
// смысл: у комментария пустая строка означает «убрать пометку», у флага
// disabled ноль — «включён», у квоты ноль — «нулевая квота». Тест закрепляет,
// что эти три случая не попали под общий гребень.
func TestPeerSaveNormalizeTreatsBlankSecretAsAbsent(t *testing.T) {
for _, blank := range []string{"", " ", "\t", "\n", " \t\n "} {
d := PeerSaveDto{Name: strPtr("client-01"), Secret: strPtr(blank)}
d.Normalize()
if d.Secret != nil {
t.Errorf("секрет %q не приведён к «не задано»: %q", blank, *d.Secret)
}
}
}
func TestPeerSaveNormalizeKeepsManualSecretTrimmed(t *testing.T) {
d := PeerSaveDto{Name: strPtr("client-01"), Secret: strPtr(" s3cret-value ")}
d.Normalize()
if d.Secret == nil {
t.Fatal("заданный секрет потерян")
}
if *d.Secret != "s3cret-value" {
t.Fatalf("секрет не обрезан по краям: %q", *d.Secret)
}
}
func TestPeerSaveNormalizeKeepsBlankRemarkAsValue(t *testing.T) {
d := PeerSaveDto{Name: strPtr("client-01"), Remark: strPtr(" ")}
d.Normalize()
if d.Remark == nil {
t.Fatal("пустая пометка превращена в «не задано»: очистить комментарий станет нечем")
}
if *d.Remark != "" {
t.Fatalf("пометка не обрезана: %q", *d.Remark)
}
}
func TestPeerUpdateNormalizeTreatsBlankIdentityFieldsAsAbsent(t *testing.T) {
d := PeerUpdateDto{Name: strPtr(" "), Secret: strPtr("")}
d.Normalize()
if d.Name != nil {
t.Error("пустое имя при изменении обязано означать «не менять»")
}
if d.Secret != nil {
t.Error("пустой секрет при изменении обязан означать «не менять»")
}
}
func TestPeerUpdateNormalizeKeepsZeroValuedFlags(t *testing.T) {
d := PeerUpdateDto{
Disabled: i64Ptr(0),
QuotaBytes: i64Ptr(0),
MaxDevices: i64Ptr(1),
}
d.Normalize()
if d.Disabled == nil || *d.Disabled != 0 {
t.Error("disabled=0 означает «включён», а не «не задано»")
}
if d.QuotaBytes == nil || *d.QuotaBytes != 0 {
t.Error("quotaBytes=0 означает нулевую квоту, а не «не задано»")
}
}
// Регрессия: очищенный крестиком фильтр отправлялся как `?name=` и отказывал
// правилом длины, то есть список пиров ломался в один клик.
func TestPeerPageNormalizeDropsClearedFilters(t *testing.T) {
d := PeerPageDto{Name: strPtr(""), Remark: strPtr(" ")}
d.Normalize()
if d.Name != nil || d.Remark != nil {
t.Fatalf("очищенный фильтр не снят: name=%v remark=%v", d.Name, d.Remark)
}
}
func TestBaseNormalizeDropsZeroTimestamps(t *testing.T) {
d := BaseDto{StartTime: i64Ptr(0), EndTime: i64Ptr(0)}
d.Normalize()
if d.StartTime != nil || d.EndTime != nil {
t.Fatal("нулевая отметка времени означает отсутствие фильтра")
}
kept := BaseDto{StartTime: i64Ptr(1), EndTime: i64Ptr(2)}
kept.Normalize()
if kept.StartTime == nil || kept.EndTime == nil {
t.Fatal("заданные отметки времени потеряны")
}
}
func TestLogNormalizeDropsZeroLineCount(t *testing.T) {
d := LogDto{NumLine: intPtr(0)}
d.Normalize()
if d.NumLine != nil {
t.Fatal("«показать 0 строк» — это пропущенный параметр, а не запрос")
}
}
// Все нормализуемые DTO обязаны реализовывать интерфейс: слой контроллеров
// вызывает Normalize через него, и забытая реализация означала бы молча
// пропущенный шаг.
func TestNormalizableIsImplemented(t *testing.T) {
var _ Normalizable = (*PeerSaveDto)(nil)
var _ Normalizable = (*PeerUpdateDto)(nil)
var _ Normalizable = (*PeerPageDto)(nil)
var _ Normalizable = (*BaseDto)(nil)
var _ Normalizable = (*LogDto)(nil)
}
+61 -7
View File
@@ -1,31 +1,85 @@
package dto
// Имя пира проверяется правилом `peerName`, которое несёт и набор символов, и
// длину.
//
// Раньше здесь стояло `min=1,max=32,validateStr`, где `validateStr` требовал
// 6-32 символа. Два правила на одном поле противоречили друг другу: имя из
// трёх символов проходило `min=1` и отказывалось на `validateStr`, а оператор
// видел «invalid» и подсказку «короткий идентификатор пира». Длина живёт
// внутри одного правила, чтобы такого расхождения больше не было.
type PeerPageDto struct {
BaseDto
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32"`
Name *string `json:"name" form:"name" validate:"omitempty,max=32"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: очищенный фильтр — это отсутствие фильтра.
//
// Регрессия, которую это закрывает: `el-input` с крестиком очистки ставит
// пустую строку, axios сериализует её как `?name=`, и поиск пиров отказывал с
// «invalid» после нажатия на крестик.
func (d *PeerPageDto) Normalize() {
d.BaseDto.Normalize()
blankToNil(&d.Name)
blankToNil(&d.Remark)
}
type PeerSaveDto struct {
Name *string `json:"name" form:"name" validate:"required,min=1,max=32,validateStr"`
Name *string `json:"name" form:"name" validate:"required,peerName"`
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: пустой секрет означает «сгенерируй сам».
//
// Именно это обещает подпись под полем, и именно это умеет CreatePeer. Пустая
// пометка при этом остаётся пустой пометкой — «нет комментария» и «не менять
// комментарий» не одно и то же.
func (d *PeerSaveDto) Normalize() {
trimValue(d.Name)
blankToNil(&d.Secret)
trimValue(d.Remark)
}
type PeerUpdateDto struct {
IdDto
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32,validateStr"`
// Id приходит из пути `/peers/:id`, а не из тела, поэтому здесь он
// НЕОБЯЗАТЕЛЕН.
//
// Раньше сюда встраивался IdDto с правилом `required,gt=0`, и тело запроса
// обязано было повторять идентификатор, уже указанный в адресе. Панель его
// повторяла, поэтому расхождение не проявлялось; любой другой клиент,
// сделавший PATCH /peers/7 без `"id": 7` в теле, получал отказ «поле id
// обязательно» — при том, что значение из тела всё равно затирается
// значением из пути.
Id *int64 `json:"id" form:"id" validate:"omitempty,gt=0"`
Name *string `json:"name" form:"name" validate:"omitempty,peerName"`
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: при изменении пустое имя и пустой секрет означают «не менять».
//
// Ровно так их и читает service.UpdatePeer (`!= nil && != ""`), поэтому
// приведение здесь не добавляет поведения, а убирает расхождение: без него
// правила отказывали на входе, который сервис считает законным.
//
// `remark` и `disabled` намеренно не трогаются: пустая пометка и ноль — это
// значения, которые оператор устанавливает осознанно.
func (d *PeerUpdateDto) Normalize() {
blankToNil(&d.Name)
blankToNil(&d.Secret)
trimValue(d.Remark)
}
type PeerKickDto struct {
+78 -16
View File
@@ -1,16 +1,31 @@
package vo
import (
"net/http"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"net/http"
)
// FieldError — одна причина отказа.
//
// `Field` заполняется, когда причина относится к конкретному полю формы, и
// пуст для отказов уровня операции. `Params` несёт числа правила (границы
// длины, допустимые значения), чтобы панель могла составить точную фразу, не
// заводя у себя вторую копию этих чисел.
type FieldError struct {
Code string `json:"code"`
Field string `json:"field,omitempty"`
Message string `json:"message"`
Params map[string]string `json:"params,omitempty"`
}
type result struct {
Code int `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
Data interface{} `json:"data"`
Code int `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
Errors []FieldError `json:"errors,omitempty"`
Data interface{} `json:"data"`
}
const (
@@ -26,21 +41,68 @@ func Success(data interface{}, c *gin.Context) {
})
}
func Fail(message string, c *gin.Context) {
var code int
if constant.UnauthorizedError == message {
code = constant.CodeUnauthorizedError
} else if constant.ForbiddenError == message {
code = constant.CodeForbiddenError
} else if constant.InvalidError == message {
code = constant.CodeInvalidError
} else {
code = constant.CodeSysError
}
// FailWith — единственное место, где формируется ответ об ошибке.
//
// Код передаётся аргументом. Раньше он ВЫВОДИЛСЯ здесь сравнением текста
// сообщения с тремя известными строками:
//
// if constant.UnauthorizedError == message { code = ... }
//
// Это тот же антипаттерн, который запрещён панели, только на сервере: смысл
// ответа определялся совпадением литерала. Следствие было не теоретическим —
// истёкший токен возвращал `token expired`, под условия не подходил и уезжал
// как обычная системная ошибка с кодом 50000. Панель показывала оператору
// голый тост и не понимала, что сессия кончилась: ветка входа заново не
// срабатывала никогда.
func FailWith(code int, message string, fieldErrors []FieldError, c *gin.Context) {
c.JSON(http.StatusOK, result{
Code: code,
Type: TypeError,
Message: message,
Errors: fieldErrors,
Data: nil,
})
}
// Fail — отказ уровня операции: правила соблюдены, выполнить нельзя.
func Fail(message string, c *gin.Context) {
FailWith(constant.CodeSysError, message, nil, c)
}
// FailDomain — тот же отказ, но с машиночитаемым кодом причины.
func FailDomain(code string, message string, c *gin.Context) {
FailWith(constant.CodeSysError, message, []FieldError{{
Code: code,
Message: message,
}}, c)
}
// FailField — отказ уровня операции, привязанный к полю формы.
func FailField(code string, field string, message string, c *gin.Context) {
FailWith(constant.CodeSysError, message, []FieldError{{
Code: code,
Field: field,
Message: message,
}}, c)
}
// FailValidation — вход не прошёл проверку правил.
func FailValidation(message string, fieldErrors []FieldError, c *gin.Context) {
FailWith(constant.CodeInvalidError, message, fieldErrors, c)
}
// FailUnauthorized — вход требуется или сессия больше не действует.
//
// Причина передаётся кодом: панель по-разному ведёт себя, когда токена нет
// вовсе и когда он только что истёк под руками у оператора.
func FailUnauthorized(code string, message string, c *gin.Context) {
FailWith(constant.CodeUnauthorizedError, message, []FieldError{{
Code: code,
Message: message,
}}, c)
}
// FailForbidden — вход выполнен, но прав недостаточно.
func FailForbidden(message string, c *gin.Context) {
FailWith(constant.CodeForbiddenError, message, nil, c)
}
+17 -1
View File
@@ -25,13 +25,29 @@ func adminClaimsFromContext(c *gin.Context) (bo.AccountBo, bool) {
return claims, castOK
}
// ErrInvalidCredentials — логин или пароль не подошли.
//
// ОДНО значение на оба случая, и это не упрощение. «Такого администратора
// нет» и «пароль не тот» обязаны быть неразличимы снаружи: иначе форма входа
// превращается в способ проверять существование имён администраторов, а
// панель слушает только localhost именно потому, что вход — самая ценная
// дверь продукта.
//
// Отказ хранилища при этом сюда НЕ сворачивается: слой данных уже умеет
// отличать «записи нет» от «база не ответила» (dao.IsNotFound), и недоступная
// SQLite обязана выглядеть как системная ошибка, а не как неверный пароль.
var ErrInvalidCredentials = errors.New(constant.WrongPassword)
func Login(username string, plainPassword string) (string, bool, error) {
admin, err := dao.GetAdminUser("username = ? and status = 1", username)
if err != nil {
if dao.IsNotFound(err) {
return "", false, ErrInvalidCredentials
}
return "", false, err
}
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
return "", false, errors.New(constant.WrongPassword)
return "", false, ErrInvalidCredentials
}
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
+21 -3
View File
@@ -70,6 +70,24 @@ func GenToken(accountBo bo.AccountBo) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
// Отказ разбора токена — это ЗНАЧЕНИЕ, а не свежая ошибка с текстом внутри.
//
// Различие существенно для вызывающего: middleware обязан отличить истёкшую
// сессию от недействительного токена, потому что оператору это показывается
// по-разному — «сессия истекла, войдите заново» против «войдите». Раньше
// единственным способом задать этот вопрос было сравнение err.Error() с
// константой, то есть разбор человеческого текста; такое сравнение молча
// перестаёт работать при первой же правке формулировки.
//
// Тексты сохранены прежними: они уезжают в ответ панели.
var (
// ErrTokenExpired — токен разобран, но его срок истёк.
ErrTokenExpired = errors.New(constant.TokenExpiredError)
// ErrTokenInvalid — токен не разобран, подписан не тем ключом или не
// содержит ожидаемых утверждений.
ErrTokenInvalid = errors.New(constant.IllegalTokenError)
)
func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret()
if err != nil {
@@ -89,14 +107,14 @@ func ParseToken(tokenString string) (*MyClaims, error) {
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New(constant.TokenExpiredError)
return nil, ErrTokenExpired
}
return nil, errors.New(constant.IllegalTokenError)
return nil, ErrTokenInvalid
}
claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid {
return nil, errors.New(constant.IllegalTokenError)
return nil, ErrTokenInvalid
}
return claims, nil
}
+9 -2
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"strings"
"testing"
"time"
@@ -119,8 +120,14 @@ func TestParseTokenRejectsExpiredToken(t *testing.T) {
if err == nil {
t.Fatal("истёкший токен принят")
}
if err.Error() != constant.TokenExpiredError {
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err)
// Вопрос задаётся значению, а не тексту: middleware различает истёкшую
// сессию и недействительный токен именно через errors.Is, и проверка здесь
// обязана закреплять тот же способ.
if !errors.Is(err, ErrTokenExpired) {
t.Errorf("истечение срока должно сообщаться отдельным значением, получено: %v", err)
}
if errors.Is(err, ErrTokenInvalid) {
t.Error("истёкший токен не должен выглядеть как недействительный")
}
}
+20 -22
View File
@@ -63,39 +63,36 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
// результат которых виден в списке пиров. Bootstrap-пир после установки
// остаётся действующим доступом, и запрет его убрать означал бы вечный
// неотзываемый доступ.
var errBootstrapPeerIdentity = fmt.Errorf(
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName,
)
var errBootstrapPeerIdentity = &PeerError{
Code: constant.ErrCodePeerBootstrapLocked,
Message: fmt.Sprintf(
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName,
),
}
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if peerDto.Name == nil || *peerDto.Name == "" {
return vo.PeerVo{}, errors.New(constant.InvalidError)
return vo.PeerVo{}, ErrPeerNameRequired
}
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
return vo.PeerVo{}, ErrPeerNameReserved
}
taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil {
return vo.PeerVo{}, err
}
if taken {
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name)
}
secret := ""
if peerDto.Secret != nil && *peerDto.Secret != "" {
secret = *peerDto.Secret
} else {
generated, err := util.RandomString(24)
if err != nil {
return vo.PeerVo{}, err
}
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated)
secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret)
if err != nil {
return vo.PeerVo{}, err
}
authId, err := util.RandomString(18)
if err != nil {
@@ -178,7 +175,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
return ErrPeerNameReserved
}
}
@@ -218,7 +215,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
//
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/04-admin-panel.md).
// недействующее значение (см. docs/admin/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) {
@@ -428,11 +425,12 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
entry.createDigest = digest
entry.createCipher = cipher
} else {
generated, err := util.RandomString(24)
// Генерация одна на весь продукт: импорт без секрета обязан давать
// пира, неотличимого от созданного через форму.
createSecret, err := GeneratePeerSecret(name)
if err != nil {
return nil, err
}
createSecret := fmt.Sprintf("%s.%s", name, generated)
digest, err := PeerSecretDigest(createSecret)
if err != nil {
return nil, err
+64
View File
@@ -0,0 +1,64 @@
package service
import (
"fmt"
"hy2xs-admin/model/constant"
)
// Доменные отказы пиров несут КОД, а не только текст.
//
// Раньше причина отказа существовала исключительно в виде человеческой фразы:
// `fmt.Errorf("name %s already exists", …)`. Слой контроллеров отдавал её
// панели как есть, панель показывала её тостом, и всё, что могло бы захотеть
// отреагировать на причину — подсветить нужное поле формы, перевести фразу на
// язык оператора, — было вынуждено сравнивать текст. Такое сравнение молча
// ломается при первой же правке формулировки.
//
// Код и имя поля объявляются здесь, рядом с местом, которое отказ порождает.
// PeerError — отказ операции над пиром с машиночитаемой причиной.
type PeerError struct {
// Code — причина из constant.ErrCode*.
Code string
// Field — поле формы, к которому относится отказ; пусто, если отказ
// относится к операции целиком.
Field string
// Message — человекочитаемое объяснение для клиента без UI.
Message string
}
func (e *PeerError) Error() string { return e.Message }
// ErrPeerNameRequired — имя пира не передано.
//
// Правило `required` в DTO ловит это раньше, но CreatePeer вызывается и из
// тестов, и потенциально из будущих внутренних путей, поэтому проверка
// остаётся, а не превращается в предположение.
var ErrPeerNameRequired = &PeerError{
Code: constant.ErrCodeRequired,
Field: "name",
Message: "имя пира обязательно",
}
// ErrPeerNameReserved — имя принадлежит пиру установщика.
//
// Имя зарезервировано и когда сам пир уже удалён: иначе обычный пир занял бы
// его и оказался под защитой, предназначенной не ему.
var ErrPeerNameReserved = &PeerError{
Code: constant.ErrCodePeerNameReserved,
Field: "name",
Message: fmt.Sprintf(
"имя %q зарезервировано за пиром установщика",
ReservedBootstrapPeerName,
),
}
// PeerNameTakenError — имя уже занято другим пиром.
func PeerNameTakenError(name string) *PeerError {
return &PeerError{
Code: constant.ErrCodePeerNameTaken,
Field: "name",
Message: fmt.Sprintf("пир с именем %q уже существует", name),
}
}
+36 -3
View File
@@ -37,9 +37,39 @@ const MaxPeerImportItems = 5000
// константы рано или поздно разошлись бы.
const ReservedBootstrapPeerName = dao.BootstrapPeerName
// Тот же набор символов, что и у validateStr в слое контроллеров.
// Правило имени пира — ОДНО на весь продукт.
//
// В таблицу пиров ведут две двери: обычное создание через PeerSaveDto и импорт
// выгрузки. Правило у них обязано быть одним, и оно уже расходилось: слой
// контроллеров нёс собственную копию
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// с комментарием «тот же набор символов». Набор был другим — дефис внутри
// класса не экранирован, поэтому `+-=` образует диапазон и впускает
// `, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое
// импорт того же пира отклонял, хотя имя уезжает во fragment клиентской ссылки
// и в автогенерируемый секрет.
//
// Теперь правило объявлено здесь один раз, а controller.validatePeerName зовёт
// IsValidPeerName. Границы длины и человекочитаемый набор экспортируются, чтобы
// сообщение об отказе не заводило собственную копию тех же чисел.
const (
PeerNameMinLength = 6
PeerNameMaxLength = 32
// PeerNameCharset — набор в том виде, в каком его показывают оператору.
PeerNameCharset = `a-z A-Z 0-9 !@#$%^&*()_+-=`
)
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
// IsValidPeerName сообщает, пригодно ли имя пира. Пробелы по краям к этому
// моменту уже сняты нормализацией DTO; здесь они снимаются повторно, потому
// что импорт приходит не через DTO.
func IsValidPeerName(name string) bool {
return peerNamePattern.MatchString(strings.TrimSpace(name))
}
// authId генерируется через util.RandomString и участвует в HTTP-обмене с
// Hysteria, поэтому здесь набор ещё уже.
var peerAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._\-]{1,64}$`)
@@ -70,8 +100,11 @@ func ValidatePeerImportBatch(items []bo.PeerExport) error {
if name == "" {
return peerImportError(i, "пустое имя")
}
if !peerNamePattern.MatchString(name) {
return peerImportError(i, fmt.Sprintf("недопустимое имя %q: 6-32 символа из [a-zA-Z0-9!@#$%%^&*()_+-=]", name))
if !IsValidPeerName(name) {
return peerImportError(i, fmt.Sprintf(
"недопустимое имя %q: от %d до %d символов из набора %s",
name, PeerNameMinLength, PeerNameMaxLength, PeerNameCharset,
))
}
if name == ReservedBootstrapPeerName {
return peerImportError(i, fmt.Sprintf("имя %q зарезервировано установщиком и не может быть импортировано", name))
+49
View File
@@ -8,6 +8,8 @@ import (
"hy2xs-admin/util"
)
// Секреты пиров: генерация, отпечаток, шифрование.
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
//
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
@@ -22,6 +24,53 @@ import (
// из-за которого каждая первая загрузка печатала в журнал
// `duplicated key not allowed` уровня error на здоровом старте.
// generatedSecretRandomLength — длина случайной части автогенерируемого
// секрета.
//
// 24 символа из алфавита util.RandomString (62 символа) дают примерно 143 бита
// энтропии. Источник — crypto/rand с отбрасыванием смещённых байтов, то есть
// тот же генератор, которым создаются JWT_SECRET и ключи шифрования секретов.
const generatedSecretRandomLength = 24
// GeneratePeerSecret создаёт секрет подключения пира.
//
// Владелец автогенерации — сервисный слой, и это существенно. Панель обещает
// оператору «оставьте пустым — сгенерируем автоматически», и то же обещание
// обязано действовать для прямого вызова API, для импорта и для будущих
// клиентов. Генерация во frontend означала бы, что обещание выполняется ровно
// для одной двери из четырёх, а остальные тихо получают пустое значение.
//
// Имя пира входит в секрет префиксом: в клиенте секрет виден оператору, и
// узнать по нему, какому пиру он принадлежит, полезнее, чем скрыть эту связь.
// Стойкость от этого не страдает — она обеспечивается случайной частью, а имя
// пира и так публично известно из ссылки.
func GeneratePeerSecret(peerName string) (string, error) {
generated, err := util.RandomString(generatedSecretRandomLength)
if err != nil {
return "", err
}
if peerName == "" {
return generated, nil
}
return fmt.Sprintf("%s.%s", peerName, generated), nil
}
// resolvePeerSecret возвращает секрет, который следует сохранить: заданный
// оператором либо сгенерированный.
//
// К этому моменту нормализация DTO уже привела «поле отсутствует», «пустая
// строка» и «одни пробелы» к одному состоянию — nil. Повторный TrimSpace здесь
// нужен для вызовов мимо слоя DTO (тесты, внутренние пути): «сгенерировать»
// обязано означать одно и то же на всех входах.
func resolvePeerSecret(peerName string, provided *string) (string, error) {
if provided != nil {
if manual := strings.TrimSpace(*provided); manual != "" {
return manual, nil
}
}
return GeneratePeerSecret(peerName)
}
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
func GetPeerSecretKey() (string, error) {
return dao.GetOrCreatePeerSecretDigestKey()