добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой архив, учёт доставки в SQLite - код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200, данные уже сохранены и доразберутся позже - сохраняется полный набор заголовков запроса с вычисткой секретов по имени и по совпадению значения с токеном
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
)
|
||||
|
||||
// ingestResponse — что видит Health Auto Export в ответ на принятый пакет.
|
||||
type ingestResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleIngest принимает пакет от Health Auto Export.
|
||||
//
|
||||
// Код ответа отражает ДОСТАВКУ, а не разбор: 200 означает «тело сохранено в
|
||||
// архив», и этого достаточно, потому что разобрать сохранённое можно всегда.
|
||||
func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := readBody(w, r, a.maxBody)
|
||||
if err != nil {
|
||||
writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.ingest.Accept(r.Context(), body, a.metaFromHeaders(r))
|
||||
if err != nil {
|
||||
// Ошибку уже залогировал use-case; транспорт только переводит её в ответ.
|
||||
if errors.Is(err, ingest.ErrMalformed) {
|
||||
writeError(w, http.StatusBadRequest, "некорректный формат пакета")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "внутренняя ошибка")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, ingestResponse{
|
||||
DeliveryID: res.DeliveryID,
|
||||
Bytes: res.Bytes,
|
||||
SHA256: res.SHA256,
|
||||
})
|
||||
}
|
||||
|
||||
// metaFromHeaders достаёт то, что автоматизация Health Auto Export
|
||||
// рассказывает о себе своими заголовками. Именованные поля — те, по которым
|
||||
// ходят запросы; полный набор кладётся рядом, потому что документация HAE
|
||||
// заведомо неполна.
|
||||
func (a *api) metaFromHeaders(r *http.Request) ingest.Meta {
|
||||
return ingest.Meta{
|
||||
AutomationName: r.Header.Get("automation-name"),
|
||||
AutomationID: r.Header.Get("automation-id"),
|
||||
Aggregation: r.Header.Get("automation-aggregation"),
|
||||
Period: r.Header.Get("automation-period"),
|
||||
SessionID: r.Header.Get("session-id"),
|
||||
Headers: a.safeHeaders(r),
|
||||
}
|
||||
}
|
||||
|
||||
// secretHeaders — заголовки, которые не сохраняем никогда, как бы ни был
|
||||
// настроен отправитель.
|
||||
var secretHeaders = map[string]bool{
|
||||
"authorization": true,
|
||||
"proxy-authorization": true,
|
||||
"cookie": true,
|
||||
"x-api-key": true,
|
||||
"api-key": true,
|
||||
"x-auth-token": true,
|
||||
}
|
||||
|
||||
// redacted подменяет значение, которое оказалось секретом.
|
||||
const redacted = "[redacted]"
|
||||
|
||||
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
|
||||
//
|
||||
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
|
||||
// совпавшее с настроенным токеном, подменяется — токен можно положить в
|
||||
// заголовок с произвольным именем, и угадать его мы не можем.
|
||||
func (a *api) safeHeaders(r *http.Request) map[string][]string {
|
||||
out := make(map[string][]string, len(r.Header)+1)
|
||||
for name, values := range r.Header {
|
||||
if secretHeaders[strings.ToLower(name)] {
|
||||
out[name] = []string{redacted}
|
||||
continue
|
||||
}
|
||||
safe := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) {
|
||||
safe[i] = redacted
|
||||
continue
|
||||
}
|
||||
safe[i] = v
|
||||
}
|
||||
out[name] = safe
|
||||
}
|
||||
// Host в r.Header не попадает — а знать, на какое имя пришёл запрос,
|
||||
// полезно, когда перед сервисом появится прокси.
|
||||
if r.Host != "" {
|
||||
out["Host"] = []string{r.Host}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readBody(w http.ResponseWriter, r *http.Request, maxBody int64) ([]byte, error) {
|
||||
var src io.Reader = http.MaxBytesReader(w, r.Body, maxBody)
|
||||
|
||||
if strings.Contains(strings.ToLower(r.Header.Get("Content-Encoding")), "gzip") {
|
||||
gz, err := gzip.NewReader(src)
|
||||
if err != nil {
|
||||
return nil, errBadGzip
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
src = gz
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck // разбирается writeReadError по типу
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var errBadGzip = errors.New("тело не распаковывается как gzip")
|
||||
|
||||
func writeReadError(w http.ResponseWriter, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &tooLarge):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "пакет больше допустимого размера")
|
||||
case errors.Is(err, errBadGzip):
|
||||
writeError(w, http.StatusBadRequest, "тело не распаковывается как gzip")
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "не удалось прочитать тело запроса")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user