- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
275 lines
13 KiB
Go
275 lines
13 KiB
Go
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
|
||
}
|
||
}
|