- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
577 lines
27 KiB
Go
577 lines
27 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/router"
|
|
"github.com/pocketbase/pocketbase/tools/types"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
|
)
|
|
|
|
// AppRoot — корень адресов приложения.
|
|
//
|
|
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
|
|
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
|
|
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
|
|
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
|
|
// нашим, и разойдутся они молча.
|
|
const AppRoot = "/app"
|
|
|
|
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
|
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
|
|
// одним запросом и тем обойти постраничность её же параметром.
|
|
const (
|
|
DefaultPageLimit = 30
|
|
MaxPageLimit = 100
|
|
)
|
|
|
|
// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
|
|
//
|
|
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
|
|
// список, открывает карточку соседней записи и грузит новую, а бюджет
|
|
// ограничителя один на все адреса приложения и считается по адресу
|
|
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
|
|
// его пополам.
|
|
const pollBudgetShare = 8
|
|
|
|
// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
|
|
//
|
|
// Выводится из настройки ограничителя частоты под корнем приложения, а не
|
|
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
|
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
|
// которого сервис сам же ему обещал избежать.
|
|
//
|
|
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
|
|
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
|
|
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
|
|
// выполняется с запасом.
|
|
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
|
|
|
|
type AppHandler struct {
|
|
recordRepo contract.AudioRecordRepository
|
|
textRepo contract.TextRepository
|
|
structureRepo contract.StructureRepository
|
|
trsService *service.TranscribeService
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewAppHandler(
|
|
recordRepo contract.AudioRecordRepository,
|
|
textRepo contract.TextRepository,
|
|
structureRepo contract.StructureRepository,
|
|
trsService *service.TranscribeService,
|
|
logger *slog.Logger,
|
|
) *AppHandler {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &AppHandler{
|
|
recordRepo: recordRepo,
|
|
textRepo: textRepo,
|
|
structureRepo: structureRepo,
|
|
trsService: trsService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
|
|
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
|
|
// другую.
|
|
//
|
|
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
|
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
|
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
|
|
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
|
|
type RecordView struct {
|
|
ID string `json:"id"`
|
|
Title *string `json:"title"`
|
|
OriginalFilename *string `json:"original_filename"`
|
|
Brief *string `json:"brief"`
|
|
Topics []string `json:"topics"`
|
|
State string `json:"state"`
|
|
Halted bool `json:"halted"`
|
|
HaltReason *string `json:"halt_reason"`
|
|
DurationMs *int64 `json:"duration_ms"`
|
|
SizeBytes *int64 `json:"size_bytes"`
|
|
CreatedAt string `json:"created_at"`
|
|
// AvailableViews — перечень доступных видов текста, а не признак «текст
|
|
// есть». Видов больше одного, и шаг завершения пишет их несколькими
|
|
// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
|
|
// признак отправил бы приложение за репликами, которых нет, и исход стал бы
|
|
// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
|
|
//
|
|
// У элемента страницы поле опущено: страница видов не читает.
|
|
AvailableViews *[]string `json:"available_views,omitempty"`
|
|
}
|
|
|
|
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
|
//
|
|
// Место под признак заведено вперёд и заполняется другой задачей. Форма
|
|
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
|
|
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
|
|
// форму.
|
|
type IntakeItem struct {
|
|
RecordView
|
|
Duplicate bool `json:"duplicate"`
|
|
}
|
|
|
|
type PageView struct {
|
|
Items []RecordView `json:"items"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
TotalItems int `json:"total_items"`
|
|
}
|
|
|
|
type MeView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type ConfigView struct {
|
|
MaxRecordSizeBytes int64 `json:"max_record_size_bytes"`
|
|
MaxPageSize int `json:"max_page_size"`
|
|
PollIntervalMs int64 `json:"poll_interval_ms"`
|
|
KnownExtensions []string `json:"known_extensions"`
|
|
MaxTopicsPerRecord int `json:"max_topics_per_record"`
|
|
}
|
|
|
|
type TextView struct {
|
|
View string `json:"view"`
|
|
Contents string `json:"contents,omitempty"`
|
|
Replicas []ReplicaView `json:"replicas,omitempty"`
|
|
}
|
|
|
|
type ReplicaView struct {
|
|
StartMs int64 `json:"start_ms"`
|
|
EndMs int64 `json:"end_ms"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
|
|
// панели один, поэтому и роутер один.
|
|
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
|
app := r.Group(AppRoot)
|
|
|
|
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
|
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
|
// иначе ушли бы телом библиотеки, мимо единой формы.
|
|
//
|
|
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
|
|
// адрес выдачи файлового токена из пространства хранилища. Область его
|
|
// действия при этом выводится из **этого же** перечня адресного
|
|
// пространства — см. `underIdentifiedArea`.
|
|
app.Bind(OneErrorForm())
|
|
app.Bind(RequireUser(migrations.UsersCollection))
|
|
|
|
app.GET("/me", h.Me)
|
|
app.GET("/config", h.Config)
|
|
|
|
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
|
// заводит аудиозапись, а не кладёт файл.
|
|
//
|
|
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
|
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку
|
|
// самой записи, а отказ по нему уходит нашей формой.
|
|
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
|
app.GET("/audiorecords", h.ListRecords)
|
|
app.GET("/audiorecords/{id}", h.GetRecord)
|
|
app.GET("/audiorecords/{id}/text", h.GetRecordText)
|
|
|
|
// Перехват «под нашим корнем такого адреса нет».
|
|
//
|
|
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
|
|
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
|
|
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
|
|
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
|
|
// под корнем приложения было бы две.
|
|
//
|
|
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
|
|
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
|
|
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
|
|
app.Any("/{path...}", func(e *core.RequestEvent) error {
|
|
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
|
})
|
|
}
|
|
|
|
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
|
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
|
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
|
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
|
// наружу наравне с журналом.
|
|
return e.JSON(http.StatusOK, MeView{
|
|
ID: e.Auth.Id,
|
|
Name: e.Auth.GetString("name"),
|
|
})
|
|
}
|
|
|
|
func (h *AppHandler) Config(e *core.RequestEvent) error {
|
|
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
|
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
|
// до первого отказа на записи, которую человек уже успел отправить.
|
|
return e.JSON(http.StatusOK, ConfigView{
|
|
MaxRecordSizeBytes: entity.MaxRecordSize,
|
|
MaxPageSize: MaxPageLimit,
|
|
PollIntervalMs: PollIntervalMs,
|
|
KnownExtensions: metrics.PublicFormats(),
|
|
MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
|
|
})
|
|
}
|
|
|
|
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
|
file, header, err := e.Request.FormFile("audio")
|
|
if err != nil {
|
|
// Предел тела ловит объявленную длину заранее, слоем; необъявленную —
|
|
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы
|
|
// человеку «вы не приложили файл» о записи, которую он приложил и
|
|
// которая просто больше потолка.
|
|
if errors.Is(err, apis.ErrRequestEntityTooLarge) {
|
|
return fail(e, contract.ErrRecordTooLarge)
|
|
}
|
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
|
}
|
|
defer func() {
|
|
if err := file.Close(); err != nil {
|
|
h.logger.Error("Failed to close uploaded file", "error", err)
|
|
}
|
|
}()
|
|
|
|
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
|
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
|
// загруженную запись от одного обрыва соединения, а забрать результат он
|
|
// может и позже — карточкой записи. Значения контекста (журнал запроса,
|
|
// сессия) при этом сохраняются, теряется только отмена.
|
|
ctx := context.WithoutCancel(e.Request.Context())
|
|
|
|
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
|
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
|
// чужое имя.
|
|
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
|
if err != nil {
|
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
|
// делает это одним местом — по причине отказа, а не по месту.
|
|
return fail(e, err)
|
|
}
|
|
|
|
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
|
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
|
return e.JSON(http.StatusCreated, []IntakeItem{{
|
|
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
|
// отсутствие поля и пустой перечень приложение не различит.
|
|
RecordView: h.viewOf(record, nil, &[]string{}),
|
|
}})
|
|
}
|
|
|
|
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
|
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
|
|
|
|
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
|
|
limit, err := strconv.Atoi(raw)
|
|
if err != nil || limit <= 0 {
|
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
|
}
|
|
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
|
// сервис отдаёт, но просьба сама по себе не негодна.
|
|
q.Limit = min(limit, MaxPageLimit)
|
|
}
|
|
|
|
if raw := e.Request.URL.Query().Get("filter"); raw != "" {
|
|
filter, ok := entity.ParseListFilter(raw)
|
|
if !ok {
|
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
|
}
|
|
q.Filter = &filter
|
|
}
|
|
|
|
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
|
|
cursor, err := decodeCursor(raw)
|
|
if err != nil {
|
|
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
|
// архив, листающийся по кругу, и ни строки в журнале.
|
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
|
}
|
|
q.Cursor = cursor
|
|
}
|
|
|
|
page, err := h.recordRepo.List(q)
|
|
if err != nil {
|
|
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
|
|
return fail(e, err)
|
|
}
|
|
|
|
names, err := h.topicNames(e.Auth.Id, page.Items)
|
|
if err != nil {
|
|
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
|
|
return fail(e, err)
|
|
}
|
|
|
|
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
|
|
for _, record := range page.Items {
|
|
// Страница видов текста не читает: перечень доступных видов есть только у
|
|
// карточки, и опущенное поле честнее пустого — пустое читалось бы как
|
|
// «текста нет».
|
|
view.Items = append(view.Items, h.viewOf(record, names, nil))
|
|
}
|
|
if page.NextCursor != nil {
|
|
encoded := encodeCursor(page.NextCursor)
|
|
view.NextCursor = &encoded
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, view)
|
|
}
|
|
|
|
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
|
|
record, err := h.readOwn(e)
|
|
if err != nil {
|
|
return fail(e, err)
|
|
}
|
|
|
|
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record})
|
|
if err != nil {
|
|
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
|
|
return fail(e, err)
|
|
}
|
|
|
|
views := h.availableViews(record)
|
|
return e.JSON(http.StatusOK, h.viewOf(record, names, &views))
|
|
}
|
|
|
|
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error {
|
|
view := e.Request.URL.Query().Get("view")
|
|
if !entity.IsKnownTextView(view) {
|
|
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
|
}
|
|
|
|
record, err := h.readOwn(e)
|
|
if err != nil {
|
|
return fail(e, err)
|
|
}
|
|
|
|
if view == entity.TextViewReplicas {
|
|
return h.replicasOf(e, record)
|
|
}
|
|
return h.plainTextOf(e, record, view)
|
|
}
|
|
|
|
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
|
|
// одним и тем же: по разнице ответов иначе перебирается список заведённых
|
|
// записей.
|
|
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
|
|
recordID := e.Request.PathValue("id")
|
|
|
|
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
|
if err != nil {
|
|
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
|
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
|
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
|
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
|
// аварии не узнаёт ниоткуда.
|
|
var notFound *contract.JobNotFoundError
|
|
if !errors.As(err, ¬Found) {
|
|
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
|
}
|
|
return nil, err
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error {
|
|
textID := record.TranscriptTextID
|
|
if view == entity.TextViewLiterary {
|
|
textID = record.LiteraryTextID
|
|
}
|
|
|
|
if textID == nil {
|
|
return fail(e, contract.ErrTextNotReady)
|
|
}
|
|
|
|
text, err := h.textRepo.GetByID(*textID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
|
|
return fail(e, err)
|
|
}
|
|
if text.Contents == "" {
|
|
return fail(e, contract.ErrTextNotReady)
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents})
|
|
}
|
|
|
|
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error {
|
|
if record.StructureID == nil {
|
|
return fail(e, contract.ErrTextNotReady)
|
|
}
|
|
|
|
structure, err := h.structureRepo.GetByID(*record.StructureID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
|
|
return fail(e, err)
|
|
}
|
|
if len(structure.Replicas) == 0 {
|
|
return fail(e, contract.ErrTextNotReady)
|
|
}
|
|
|
|
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
|
for _, r := range structure.Replicas {
|
|
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
|
}
|
|
|
|
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
|
|
// сотню записей иначе стоила бы сотни обращений к хранилищу.
|
|
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
|
|
seen := map[string]bool{}
|
|
ids := []string{}
|
|
for _, record := range records {
|
|
for _, id := range record.TopicIDs {
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
}
|
|
return h.recordRepo.ResolveTopicNames(ownerID, ids)
|
|
}
|
|
|
|
func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
|
|
topics := make([]string, 0, len(record.TopicIDs))
|
|
for _, id := range record.TopicIDs {
|
|
if name, ok := names[id]; ok {
|
|
topics = append(topics, name)
|
|
}
|
|
}
|
|
|
|
return RecordView{
|
|
ID: record.Id,
|
|
Title: record.Title,
|
|
OriginalFilename: record.OriginalFilename,
|
|
Brief: record.Brief,
|
|
Topics: topics,
|
|
State: record.State,
|
|
Halted: record.IsHalted(),
|
|
HaltReason: record.HaltReason,
|
|
DurationMs: record.DurationMs,
|
|
SizeBytes: record.SizeBytes,
|
|
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
|
AvailableViews: views,
|
|
}
|
|
}
|
|
|
|
// availableViews — какие виды текста у записи есть **сейчас**.
|
|
//
|
|
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
|
|
// достижимо, потому что шаг завершения пишет их несколькими операциями.
|
|
//
|
|
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
|
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
|
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
|
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно:
|
|
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
|
|
// из которой текст «вот-вот появится».
|
|
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
|
views := []string{}
|
|
|
|
if h.hasText(record.TranscriptTextID) {
|
|
views = append(views, entity.TextViewTranscript)
|
|
}
|
|
if h.hasText(record.LiteraryTextID) {
|
|
views = append(views, entity.TextViewLiterary)
|
|
}
|
|
if h.hasReplicas(record.StructureID) {
|
|
views = append(views, entity.TextViewReplicas)
|
|
}
|
|
return views
|
|
}
|
|
|
|
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
|
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
|
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
|
|
// журналом, который пишет чтение текста.
|
|
func (h *AppHandler) hasText(textID *string) bool {
|
|
if textID == nil {
|
|
return false
|
|
}
|
|
text, err := h.textRepo.GetByID(*textID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to read text while listing views", "error", err)
|
|
return false
|
|
}
|
|
return text.Contents != ""
|
|
}
|
|
|
|
func (h *AppHandler) hasReplicas(structureID *string) bool {
|
|
if structureID == nil {
|
|
return false
|
|
}
|
|
structure, err := h.structureRepo.GetByID(*structureID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to read structure while listing views", "error", err)
|
|
return false
|
|
}
|
|
return len(structure.Replicas) > 0
|
|
}
|
|
|
|
// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
|
|
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
|
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
|
//
|
|
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
|
|
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
|
|
// ключ уезжает параметром, а не телом.
|
|
func encodeCursor(c *contract.RecordCursor) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID))
|
|
}
|
|
|
|
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cursor is not decodable: %w", err)
|
|
}
|
|
|
|
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
|
if !ok || createdAt == "" || id == "" {
|
|
return nil, errors.New("malformed cursor")
|
|
}
|
|
|
|
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
|
|
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
|
|
// обращает условие в постоянную истину или ложь — человек получает либо
|
|
// пустой архив при непустом счётчике, либо ленту с начала.
|
|
parsed, err := types.ParseDateTime(createdAt)
|
|
if err != nil || parsed.IsZero() {
|
|
return nil, errors.New("cursor carries no readable time")
|
|
}
|
|
|
|
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil
|
|
}
|
|
|
|
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
|
// всех одна, а сказать человеку надо разное.
|
|
func errWithMessage(base error, message string) error {
|
|
return &messagedError{base: base, message: message}
|
|
}
|
|
|
|
type messagedError struct {
|
|
base error
|
|
message string
|
|
}
|
|
|
|
func (e *messagedError) Error() string { return e.message }
|
|
func (e *messagedError) Unwrap() error { return e.base }
|