хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user