Files
transcriber/internal/controller/http/file.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

275 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}