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 } }