хранилище переехало с 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
+274
View File
@@ -0,0 +1,274 @@
package http
import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/metrics"
)
// contentTypes — тип содержимого по **известному** формату.
//
// Ключи здесь — значения той же единой точки, что и метка метрики: формат копии
// сперва приводится `metrics.FormatLabel`, и только приведённое ищется в этой
// карте. Справочник системы не спрашивается вовсе — он читается из файла хоста,
// которого в рабочем слое образа нет, и тип содержимого стал бы функцией того,
// где сервис собран.
//
// Перечень закрыт, и это главное его свойство. Расширение приходит из имени,
// данного отправителем, и потому может быть чем угодно: `запись.html` без
// приведения ушёл бы ответом `text/html`, который браузер показывает как
// страницу. Всё, чего в перечне нет, отдаётся `application/octet-stream` с
// расположением `attachment` — тип, который браузер не исполняет.
//
// Полноту перечня держит проверка: всякий формат, который сервис объявляет
// диалогу выбора файла, обязан иметь здесь тип содержимого.
var contentTypes = map[string]string{
"aac": "audio/aac",
"avi": "video/x-msvideo",
"flac": "audio/flac",
"m4a": "audio/mp4",
"mkv": "video/x-matroska",
"mov": "video/quicktime",
"mp3": "audio/mpeg",
"mp4": "video/mp4",
"oga": "audio/ogg",
"ogg": "audio/ogg",
"opus": "audio/ogg",
"wav": "audio/wav",
"webm": "video/webm",
"wma": "audio/x-ms-wma",
}
// Расположение ответа. Известному формату — показ на месте: проигрыватель в
// браузере иначе не откроет запись. Всему прочему — сохранение: содержимым и
// расширением распоряжается отправитель, а `attachment` браузер не исполняет.
const (
dispositionInline = "inline"
dispositionAttachment = "attachment"
)
// unknownContentType — тип содержимого всего, чего нет в закрытом перечне.
const unknownContentType = "application/octet-stream"
// GetRecordFile отдаёт копию записи её владельцу.
//
// **Порядок проверок один: владение записью судится до разбора значения копии.**
// Неизвестное либо незаданное значение копии у чужой и у несуществующей записи
// даёт тот же ответ, что и неизвестный идентификатор. Неотличимость чужой записи
// от несуществующей главнее формы ответа на негодный ввод: разбор параметра,
// выполненный раньше, отвечал бы одинаково на чужую и на неизвестную только
// случайно, а стоило бы ответам разойтись — по этой разнице перебирался бы
// список заведённых записей одним негодным параметром.
func (h *AppHandler) GetRecordFile(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
record, err := h.readOwn(r, account.ID)
if err != nil {
fail(w, err)
return
}
fileID, known := copyOf(record, r.URL.Query().Get(CopyParam))
if !known {
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестная копия записи"))
return
}
// Копии, которой у записи ещё нет, отвечает отказ состояния: пустой ответ
// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и
// неизвестную запись — человек увидел бы его на своей записи, загруженной
// минуту назад.
if fileID == nil {
fail(w, contract.ErrCopyNotReady)
return
}
file, err := h.fileRepo.GetByID(*fileID)
if err != nil {
h.logger.Error("Failed to read the file row of a record", "error", err, "record_id", record.Id)
fail(w, err)
return
}
content, err := h.fileRepo.Open(*fileID)
if err != nil {
h.logger.Error("Failed to open the file of a record", "error", err, "record_id", record.Id)
fail(w, err)
return
}
defer func() { _ = content.Close() }()
h.serveCopy(w, r, record, file, content)
}
// copyOf выбирает копию по значению параметра. Второе значение ложно у копии,
// которой сервис не знает, и у незаданной: умолчание сделало бы ответ функцией
// того, что успел записать конвейер, а не состояния записи.
func copyOf(record *entity.AudioRecord, name string) (*string, bool) {
switch name {
case CopyOriginal:
return record.OriginalFileID, true
case CopyNormalized:
return record.NormalizedFileID, true
}
return nil, false
}
// serveCopy отдаёт содержимое целиком либо запрошенным куском.
//
// Выдача по частям обязательна: запись расчётного потолка — шесть часов, и
// проигрыватель в браузере перематывает её запросом диапазона, а не повторной
// загрузкой целиком.
//
// **Негодный диапазон приводится к обычному отказу сервиса** — телом той же
// формы и кодом из закрытого перечня, — а не отвечает `416` телом библиотеки.
// Негодных диапазонов два вида, и оба ведут себя одинаково: неудовлетворимый
// (начало за концом файла) и множественный (в запросе назван больше чем один
// диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких частей — это
// отдельный тип содержимого со своими границами, а просит его один только
// самодельный запрос.
func (h *AppHandler) serveCopy(
w http.ResponseWriter,
r *http.Request,
record *entity.AudioRecord,
file *entity.File,
content io.ReadSeeker,
) {
contentType, disposition := presentationOf(file.Format)
header := w.Header()
header.Set("Content-Type", contentType)
header.Set("Accept-Ranges", "bytes")
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при
// сохранении, строится из имени, данного отправителем, и лежит оно колонкой
// записи.
header.Set("Content-Disposition", dispositionOf(record, disposition))
raw := r.Header.Get("Range")
if raw == "" {
header.Set("Content-Length", strconv.FormatInt(file.Size, 10))
w.WriteHeader(http.StatusOK)
h.copyBody(w, r, content, file.Size)
return
}
start, length, ok := parseSingleRange(raw, file.Size)
if !ok {
fail(w, errWithMessage(contract.ErrBadRequest, "Запрошенный диапазон записи не читается"))
return
}
if _, err := content.Seek(start, io.SeekStart); err != nil {
h.logger.Error("Failed to seek the file of a record", "error", err, "record_id", record.Id)
fail(w, err)
return
}
header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, file.Size))
header.Set("Content-Length", strconv.FormatInt(length, 10))
w.WriteHeader(http.StatusPartialContent)
h.copyBody(w, r, content, length)
}
// copyBody переливает содержимое в ответ. Запрос `HEAD` тела не получает: у него
// те же заголовки и пустое тело.
//
// Отказ переливания идёт **отладочной** строкой: он значит оборванное
// соединение — человек закрыл вкладку или перемотал запись, — и владельцу
// сервиса разбирать здесь нечего. Проглотить его молча всё же нельзя: тогда
// оборванная отдача не отличалась бы от полной ничем.
func (h *AppHandler) copyBody(w http.ResponseWriter, r *http.Request, content io.Reader, length int64) {
if r.Method == http.MethodHead {
return
}
if _, err := io.CopyN(w, content, length); err != nil {
h.logger.Debug("Failed to send the file of a record", "error", err, "transport", "http")
}
}
// presentationOf называет тип содержимого копии и её расположение.
//
// Оба значения выводятся из одного приведения, и порознь их выводить нельзя:
// известный формат, показанный на месте, и незнакомый, отданный на сохранение, —
// это одно решение, а два независимых дали бы `text/html` с `inline` у первого
// же расширения, которого сервис не знает.
func presentationOf(format string) (contentType, disposition string) {
if known, ok := contentTypes[metrics.FormatLabel(format)]; ok {
return known, dispositionInline
}
return unknownContentType, dispositionAttachment
}
// dispositionOf строит расположение ответа вместе с именем, предлагаемым
// браузеру при сохранении.
//
// Имя берётся у записи — то, что дал отправитель, — и кодируется по правилам
// заголовка: оно приходит извне и содержимым своим сервису не подконтрольно.
// Записи без имени получают одно расположение, без имени файла.
func dispositionOf(record *entity.AudioRecord, disposition string) string {
if record.OriginalFilename == nil || *record.OriginalFilename == "" {
return disposition
}
return disposition + "; filename*=UTF-8''" + url.PathEscape(*record.OriginalFilename)
}
// parseSingleRange разбирает заголовок диапазона.
//
// Второе значение ложно у всего, что сервис не отдаёт: у нечитаемого заголовка,
// у неудовлетворимого диапазона и у запроса, называющего больше одного
// диапазона.
func parseSingleRange(raw string, size int64) (start, length int64, ok bool) {
const prefix = "bytes="
spec, found := strings.CutPrefix(strings.TrimSpace(raw), prefix)
if !found || strings.Contains(spec, ",") {
return 0, 0, false
}
first, last, found := strings.Cut(strings.TrimSpace(spec), "-")
if !found {
return 0, 0, false
}
first, last = strings.TrimSpace(first), strings.TrimSpace(last)
switch {
case first == "":
// Хвост записи: `bytes=-N` просит последние N байтов.
suffix, err := strconv.ParseInt(last, 10, 64)
if err != nil || suffix <= 0 || size == 0 {
return 0, 0, false
}
if suffix > size {
suffix = size
}
return size - suffix, suffix, true
case last == "":
start, err := strconv.ParseInt(first, 10, 64)
if err != nil || start < 0 || start >= size {
return 0, 0, false
}
return start, size - start, true
default:
start, err := strconv.ParseInt(first, 10, 64)
if err != nil || start < 0 || start >= size {
return 0, 0, false
}
end, err := strconv.ParseInt(last, 10, 64)
if err != nil || end < start {
return 0, 0, false
}
if end >= size {
end = size - 1
}
return start, end - start + 1, true
}
}