хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+102
View File
@@ -0,0 +1,102 @@
package entity
import (
"net/mail"
"strings"
"unicode"
"unicode/utf8"
)
// MaxProviderLoginLength — предел длины логина у провайдера.
//
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
// же, что у имени: длиннее имени логин не бывает, а два разных предела на
// соседних колонках одной записи разошлись бы молча.
const MaxProviderLoginLength = 255
// MaxDisplayNameLength — предел длины имени, пригодного к показу.
const MaxDisplayNameLength = 255
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
// отвергает его.
//
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
//
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, а две записи о нём разошлись бы молча. Сравнение при
// поиске остаётся точным, знак в знак: приведение регистра завело бы правило,
// которого у провайдера нет.
//
// Живёт в домене, а не в транспорте и не в хранилище: правило одно на все
// способы представиться, а уложенное куском в слой оно разошлось бы двумя
// копиями при первом же втором способе.
func AcceptProviderLogin(value string) (string, bool) {
login := strings.TrimSpace(value)
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и два
// предела в разных единицах разошлись бы вдвое на любой кириллице.
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
return "", false
}
for _, r := range login {
if unicode.IsControl(r) {
return "", false
}
}
return login, true
}
// AcceptDisplayName приводит имя к годному для колонки значению.
//
// Обрезается по пределу и чистится от управляющих знаков — тем же приёмом, каким
// приём записи чистит имя файла отправителя. Пустое значение законно: имени у
// человека может не быть вовсе, и негодное значение необязательного поля не
// вправе отменять заведение учётной записи.
func AcceptDisplayName(value string) string {
name := strings.TrimSpace(stripControls(value))
runes := []rune(name)
if len(runes) > MaxDisplayNameLength {
return string(runes[:MaxDisplayNameLength])
}
return name
}
// AcceptEmail отдаёт адрес почты, если он вообще похож на адрес.
//
// Негодный отбрасывается здесь, а не отказом сохранения: иначе опечатка в
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью «почта
// занята», и владелец искал бы общий ящик там, где сломан контур.
func AcceptEmail(value string) (string, bool) {
email := strings.TrimSpace(value)
if email == "" {
return "", false
}
// Разбор адреса принимает и форму «Имя <адрес>»: такую строку заголовок
// приносить не должен, и адресом она не считается.
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Name != "" || parsed.Address != email {
return "", false
}
return email, true
}
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
// им делать нечего.
func stripControls(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}