package pocketbase import ( "errors" "fmt" "strings" "unicode" "unicode/utf8" validation "github.com/pocketbase/ozzo-validation/v4" "github.com/pocketbase/ozzo-validation/v4/is" "github.com/pocketbase/pocketbase/core" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // MaxProviderLoginLength — предел длины логина у провайдера. // // Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и // без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то // же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два // разных предела на соседних колонках одной записи разошлись бы молча. const MaxProviderLoginLength = 255 // MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и // по той же причине: столько держит колонка имени в умолчании библиотеки. const MaxDisplayNameLength = 255 // ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков, // длиннее предела или с управляющими знаками. Это не отказ хранилища, а // негодный ввод, и звать по нему учётную запись не надо. var ErrLoginNotAcceptable = errors.New("provider login is not acceptable") // Identity — то, чем доверенный источник называет пришедшего. // // Логин — ключ, остальное берётся только при заведении записи. type Identity struct { Login string Name string Email string } // EnsureUser находит учётную запись по логину у провайдера, а не найдя — // заводит её. // // **Дом правила один, и он здесь, а не в транспорте.** Второй способ // представиться — личные токены — придёт следующей задачей и возьмёт этот же // метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо // дублировать вторым куском, либо вытаскивать задним числом. // // Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью // в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди // его работы. // // Сравнение точное, знак в знак: приведение регистра завело бы правило, которого // у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису // неизвестно, а угаданное правило склеило бы двух разных людей. func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) { login, ok := AcceptProviderLogin(identity.Login) if !ok { return nil, false, ErrLoginNotAcceptable } record, err = findUserByLogin(app, login) if err != nil { return nil, false, err } if record != nil { return record, false, nil } users, err := findCollection(app, migrations.UsersCollection) if err != nil { return nil, false, err } record = core.NewRecord(users) record.Set(migrations.ProviderLoginField, login) // Имя и почта принимаются так же, как логин, а не кладутся как есть. // Значения приходят заголовками, то есть задаются тем, кто шлёт запрос; // имя длиннее предела колонки отвергается проверкой записи, и человек с // таким именем у провайдера не завёлся бы **никогда** — каждый его запрос // отвечал бы отказом сервиса. Негодное значение необязательного поля не // вправе отменять заведение записи. record.Set("name", acceptDisplayName(identity.Name)) if email, ok := acceptEmail(identity.Email); ok { record.SetEmail(email) } // Пароль записи обязателен при любом значении признака — это проверка самой // библиотеки, а не колонки. Ставится случайный: употребить его нельзя, // потому что вход по паролю у коллекции выключен шагом схемы. record.SetRandomPassword() if err := app.Save(record); err != nil { record, err = retryAfterConflict(app, login, record, err) return record, record != nil, err } return record, true, nil } // retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь // разные, и исход у них разный. // // По **ключевой** колонке — это гонка двух первых обращений одним логином: // запись успел завести соседний запрос, и надо просто взять его. Отказ, который // после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх. // // По **любой другой** — почта, пришедшая от провайдера, занята другой учётной // записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она // необязательна, а ключом не служит. Без этого разреза второй человек с общим // адресом не завёлся бы никогда — повторный поиск по логину снова ничего не // нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной // причины. func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) { if isUniqueViolation(saveErr, migrations.ProviderLoginField) { existing, err := findUserByLogin(app, login) if err != nil { return nil, err } if existing != nil { return existing, nil } return nil, fmt.Errorf("failed to create user account: %w", saveErr) } if !isUniqueViolation(saveErr, core.FieldNameEmail) { return nil, fmt.Errorf("failed to create user account: %w", saveErr) } record.SetEmail("") if err := app.Save(record); err != nil { return nil, fmt.Errorf("failed to create user account without email: %w", err) } return record, nil } // findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу // **параметром** запроса, а не подстановкой в текст фильтра: строка приходит // снаружи, и подставленная в текст она правила бы сам запрос, а не только его // аргумент. func findUserByLogin(app core.App, login string) (*core.Record, error) { records, err := app.FindRecordsByFilter( migrations.UsersCollection, migrations.ProviderLoginField+" = {:login}", "", 1, 0, map[string]any{"login": login}, ) if err != nil { return nil, fmt.Errorf("failed to look up user account: %w", err) } if len(records) == 0 { return nil, nil } return records[0], nil } // uniqueViolationCode — каким кодом библиотека называет отказ уникальности. // // Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст // у драйвера свой на каждую версию, а имя колонки не говорит о причине. const uniqueViolationCode = "validation_not_unique" // isUniqueViolation говорит, отказала ли по названной колонке проверка // **уникальности** — а не какая-нибудь другая. // // Разница не педантизм. Под ключом `email` библиотека складывает и отказ // уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала // бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без // почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче: // любой отказ проверки логина читался бы как гонка двух первых обращений. func isUniqueViolation(err error, field string) bool { var errs validation.Errors if !errors.As(err, &errs) { return false } fieldErr, ok := errs[field] if !ok { return false } var object validation.ErrorObject if !errors.As(fieldErr, &object) { return false } return object.Code() == uniqueViolationCode } // 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 } if err := is.EmailFormat.Validate(email); err != nil { 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) } // 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 }