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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 |
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
|
||||
|
||||
$sideBarWidth: 210px;
|
||||
$sideBarCollapsedWidth: 54px;
|
||||
|
||||
// Высота подписи разработчика внизу бокового меню.
|
||||
//
|
||||
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
|
||||
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
|
||||
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
|
||||
$sidebarFooterHeight: 34px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"` // Первичный ключ
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("истёкший токен не должен выглядеть как недействительный")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||