add load applications

This commit is contained in:
2026-08-30 21:17:32 +07:00
parent 3b15fb3a2b
commit c0b5ceb65f
28 changed files with 1536 additions and 139 deletions
+292 -46
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
@@ -17,26 +18,30 @@ import (
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// archiveStub — минимальный gRPC-сервер, реализующий только архивные RPC.
type archiveStub struct {
// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC.
type testStub struct {
proto.UnimplementedEveningDetectiveServerServer
mu sync.Mutex
uploaded []byte
mu sync.Mutex
uploaded []byte
uploadErr error
downloadErr error
}
func (s *archiveStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
func (s *testStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.uploaded = append([]byte(nil), req.Data...)
return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
}
func (s *archiveStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
func (s *testStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
return &httpbody.HttpBody{
Data: []byte("zip-data"),
@@ -44,27 +49,62 @@ func (s *archiveStub) DownloadScenarioArchive(ctx context.Context, _ *proto.Down
}, nil
}
func (s *archiveStub) getUploaded() []byte {
func (s *testStub) UploadFile(_ context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.uploadErr != nil {
return nil, s.uploadErr
}
s.uploaded = append([]byte(nil), req.Data...)
return &proto.UploadFileRsp{Filename: "stored.bin", FileType: "pdf"}, nil
}
func (s *testStub) DownloadFile(ctx context.Context, _ *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.downloadErr != nil {
return nil, s.downloadErr
}
_ = grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff"))
return &httpbody.HttpBody{
Data: []byte("file-data"),
ContentType: "application/pdf",
}, nil
}
func (s *testStub) getUploaded() []byte {
s.mu.Lock()
defer s.mu.Unlock()
return append([]byte(nil), s.uploaded...)
}
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
// опциями, что и в main.go (outgoing matcher + rawBodyMarshaler).
// опциями, что и в main.go (outgoing matcher, error handler, лимиты,
// rawBodyMarshaler).
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
return newTestGatewayWithLimit(t, stub, maxFileUploadBody)
}
func newTestGatewayWithLimit(t *testing.T, stub proto.EveningDetectiveServerServer, uploadLimit int64) *httptest.Server {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
gs := grpc.NewServer()
gs := grpc.NewServer(grpc.MaxRecvMsgSize(grpcMsgLimit))
proto.RegisterEveningDetectiveServerServer(gs, stub)
go func() { _ = gs.Serve(lis) }()
t.Cleanup(gs.Stop)
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
conn, err := grpc.NewClient(
lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallSendMsgSize(grpcMsgLimit),
grpc.MaxCallRecvMsgSize(grpcMsgLimit),
),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
@@ -72,11 +112,15 @@ func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *http
mux := runtime.NewServeMux(
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
if strings.EqualFold(key, "Content-Disposition") {
switch {
case strings.EqualFold(key, "Content-Disposition"):
return "Content-Disposition", true
case strings.EqualFold(key, "X-Content-Type-Options"):
return "X-Content-Type-Options", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithErrorHandler(customErrorHandler),
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
}),
@@ -84,13 +128,17 @@ func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *http
if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
t.Fatalf("register gateway: %v", err)
}
ts := httptest.NewServer(mux)
ts := httptest.NewServer(limitUploadBody(
"/api/files/upload",
uploadLimit,
limitUploadBody("/api/scenarios/archive", int64(scenario_archive.MaxArchiveSize()), mux),
))
t.Cleanup(ts.Close)
return ts
}
func TestGatewayUploadRawZip(t *testing.T) {
stub := &archiveStub{}
stub := &testStub{}
ts := newTestGateway(t, stub)
raw := []byte("PK\x03\x04raw-zip-bytes")
@@ -113,7 +161,7 @@ func TestGatewayUploadRawZip(t *testing.T) {
}
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
stub := &archiveStub{}
stub := &testStub{}
ts := newTestGateway(t, stub)
resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
@@ -137,6 +185,236 @@ func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
}
}
// TestGatewayDownloadFileNosniff — скачивание файла отдаёт содержимое,
// Content-Type и X-Content-Type-Options: nosniff.
func TestGatewayDownloadFileNosniff(t *testing.T) {
stub := &testStub{}
ts := newTestGateway(t, stub)
resp, err := http.Get(ts.URL + "/api/files/clue.pdf")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/pdf" {
t.Errorf("Content-Type = %q, want application/pdf", ct)
}
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
if string(body) != "file-data" {
t.Errorf("body = %q, want file-data", body)
}
}
// uploadJSONBody собирает JSON-тело загрузки файла (base64 data).
func uploadJSONBody(filename string, data []byte) []byte {
body, _ := json.Marshal(proto.UploadFileReq{
Filename: filename,
Data: data,
})
return body
}
// TestGatewayUploadArchiveOverLimit — тело архива больше HTTP-лимита
// (MaxBytesReader) → 413 с понятным текстом (связка middleware → декодер →
// customErrorHandler).
func TestGatewayUploadArchiveOverLimit(t *testing.T) {
stub := &testStub{}
ts := newTestGateway(t, stub)
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(big))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
}
if !strings.Contains(string(body), "слишком большой") {
t.Errorf("body = %s, want понятный текст об ограничении размера", body)
}
}
// TestGatewayUploadFileOverHTTPLimit — тело загрузки больше HTTP-лимита
// (MaxBytesReader) → 413 с понятным текстом.
func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
stub := &testStub{}
const limit = 1 << 20 // 1 MiB
ts := newTestGatewayWithLimit(t, stub, limit)
// 1 MiB данных в base64 ~1.33 MiB > лимит 1 MiB.
data := bytes.Repeat([]byte("x"), limit)
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
}
if !strings.Contains(string(body), "слишком большой") {
t.Errorf("body = %s, want понятный текст об ограничении размера", body)
}
}
// TestGatewayUploadFileExactLimit — тело ровно в пределах лимита проходит
// через HTTP-слой и gRPC-транспорт → 200.
func TestGatewayUploadFileExactLimit(t *testing.T) {
stub := &testStub{}
const limit = 2 << 20 // 2 MiB — лимит HTTP-тела
ts := newTestGatewayWithLimit(t, stub, limit)
data := bytes.Repeat([]byte("x"), 1<<20) // 1 MiB данных → тело ~1.33 MiB < 2 MiB
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", resp.StatusCode, body)
}
if got := stub.getUploaded(); !bytes.Equal(got, data) {
t.Errorf("сервер получил %d байт, want %d", len(got), len(data))
}
}
// TestGatewayUploadFileResourceExhausted — бизнес-ветка: сервис отклоняет
// файл больше лимита (ResourceExhausted) → 413.
func TestGatewayUploadFileResourceExhausted(t *testing.T) {
stub := &testStub{}
stub.uploadErr = status.Error(codes.ResourceExhausted, "файл слишком большой (лимит 64 МБ)")
ts := newTestGateway(t, stub)
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", []byte("small"))))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
}
if !strings.Contains(string(body), "слишком большой") {
t.Errorf("body = %s, want понятный текст об ограничении размера", body)
}
}
// TestCustomErrorHandler — маппинг ошибок превышения размера в 413.
func TestCustomErrorHandler(t *testing.T) {
mux := runtime.NewServeMux()
marshaler := &runtime.JSONPb{}
do := func(t *testing.T, req *http.Request, err error) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
customErrorHandler(context.Background(), mux, marshaler, rec, req, err)
return rec
}
t.Run("MaxBytesError raw", func(t *testing.T) {
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil), &http.MaxBytesError{Limit: 10})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413", rec.Code)
}
})
t.Run("ResourceExhausted on upload route", func(t *testing.T) {
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil),
status.Error(codes.ResourceExhausted, "файл слишком большой"))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413", rec.Code)
}
})
t.Run("InvalidArgument with MaxBytesError text", func(t *testing.T) {
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil),
status.Error(codes.InvalidArgument, "http: request body too large"))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413", rec.Code)
}
})
t.Run("ResourceExhausted on other route delegates", func(t *testing.T) {
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/test/echo", nil),
status.Error(codes.ResourceExhausted, "rate limit"))
// DefaultHTTPErrorHandler мапит ResourceExhausted в 429.
if rec.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429 (дефолтный маппинг)", rec.Code)
}
})
}
// TestLimitUploadBody — тело загрузки ограничено на HTTP-слое
// (защита от OOM), прочие маршруты не затронуты.
func TestLimitUploadBody(t *testing.T) {
limited := func(route string, limit int64) http.Handler {
return limitUploadBody(route, limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}))
}
t.Run("files upload rejected when over limit", func(t *testing.T) {
h := limited("/api/files/upload", 10)
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader(bytes.Repeat([]byte("x"), 11)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("archive rejected when over limit", func(t *testing.T) {
h := limited("/api/scenarios/archive", int64(scenario_archive.MaxArchiveSize()))
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("small body passes on limited routes", func(t *testing.T) {
for _, route := range []string{"/api/files/upload", "/api/scenarios/archive"} {
h := limited(route, 1024)
req := httptest.NewRequest(http.MethodPost, route, bytes.NewReader([]byte("small")))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("%s: status = %d, want %d", route, rec.Code, http.StatusOK)
}
}
})
t.Run("other routes not limited", func(t *testing.T) {
h := limited("/api/files/upload", 10)
req := httptest.NewRequest(http.MethodPost, "/api/test/echo", bytes.NewReader(bytes.Repeat([]byte("x"), 100)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
})
}
// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
@@ -156,35 +434,3 @@ func TestCORSHeaders(t *testing.T) {
}
}
}
// TestLimitArchiveUploadBody — тело загрузки архива ограничено на HTTP-слое
// (защита от OOM), прочие маршруты не затронуты.
func TestLimitArchiveUploadBody(t *testing.T) {
h := limitArchiveUploadBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Run("upload route rejected when over limit", func(t *testing.T) {
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(big))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("other routes not limited", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader([]byte("small")))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
})
}
+90 -13
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
_ "embed"
"errors"
"evening_detective_server/internal/app"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/email_sender"
@@ -26,6 +27,7 @@ import (
"evening_detective_server/internal/services/ui_service"
"evening_detective_server/internal/services/users_service"
proto "evening_detective_server/proto"
"fmt"
"log"
"net"
"net/http"
@@ -38,7 +40,9 @@ import (
"github.com/joho/godotenv"
"github.com/mark3labs/mcp-go/server"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"github.com/swaggest/swgui/v5emb"
@@ -50,6 +54,16 @@ var swaggerJSON []byte
// maxFileSize — максимальный размер файла, передаваемого через gRPC
const maxFileSize = 64 << 20 // 64 МБ
// maxFileUploadBody — лимит HTTP-тела POST /api/files/upload (JSON с base64):
// ceil(maxFileSize·4/3) — точный размер base64, +2 КБ запаса на JSON-обёртку
// {"filename":...,"data":...} с коротким именем файла.
const maxFileUploadBody = (int64(maxFileSize)+2)/3*4 + 2*1024
// grpcMsgLimit — лимит размера gRPC-сообщения (сервер и клиент gateway):
// maxFileSize + 1 MiB на прото-оверхед (tag+varint) поверх ровно 64 MiB
// данных; авторитетная проверка размера — в file_service.
const grpcMsgLimit = maxFileSize + (1 << 20)
func main() {
_ = godotenv.Load()
@@ -96,7 +110,7 @@ func main() {
if err != nil {
log.Fatalf("Unable to create connection rustfs: %v\n", err)
}
fileService := file_service.NewFileService(fileStorage, string_tools.NewStringTools())
fileService := file_service.NewFileService(fileStorage, string_tools.NewStringTools(), maxFileSize)
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
cleaner := cleaner.NewCleaner()
scenarioService := scenarios_service.NewScenarioService(
@@ -132,7 +146,7 @@ func main() {
// Create a gRPC server object
s := grpc.NewServer(
grpc.MaxRecvMsgSize(maxFileSize),
grpc.MaxRecvMsgSize(grpcMsgLimit),
grpc.UnaryInterceptor(
processor_jwt.NewAuthorizationInterceptor(
map[string]bool{
@@ -179,8 +193,8 @@ func main() {
"0.0.0.0:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallSendMsgSize(maxFileSize),
grpc.MaxCallRecvMsgSize(maxFileSize),
grpc.MaxCallSendMsgSize(grpcMsgLimit),
grpc.MaxCallRecvMsgSize(grpcMsgLimit),
),
)
if err != nil {
@@ -212,13 +226,18 @@ func main() {
}
return runtime.DefaultHeaderMatcher(key)
}),
// Проброс Content-Disposition от сервиса в HTTP-ответ.
// Проброс Content-Disposition и X-Content-Type-Options (nosniff)
// от сервиса в HTTP-ответ.
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
if strings.EqualFold(key, "Content-Disposition") {
switch {
case strings.EqualFold(key, "Content-Disposition"):
return "Content-Disposition", true
case strings.EqualFold(key, "X-Content-Type-Options"):
return "X-Content-Type-Options", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithErrorHandler(customErrorHandler),
runtime.WithMarshalerOption("application/zip", rawBody),
runtime.WithMarshalerOption("application/octet-stream", rawBody),
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
@@ -238,8 +257,17 @@ func main() {
w.Write(swaggerJSON)
})
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
// проверки в хендлере не спасут от OOM.
mainMux.Handle("/api/", limitArchiveUploadBody(gwmux))
// проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы —
// maxFileUploadBody, архивы — MaxArchiveSize().
mainMux.Handle("/api/", limitUploadBody(
"/api/files/upload",
maxFileUploadBody,
limitUploadBody(
"/api/scenarios/archive",
int64(scenario_archive.MaxArchiveSize()),
gwmux,
),
))
// MCP-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP).
// Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service).
@@ -284,13 +312,62 @@ func cors(h http.Handler) http.Handler {
})
}
// limitArchiveUploadBody ограничивает тело запроса загрузки архива
// (см. rawBodyDecoder: gateway читает тело в память целиком).
func limitArchiveUploadBody(next http.Handler) http.Handler {
// limitUploadBody ограничивает тело POST-запроса на заданном маршруте
// (gateway читает тело в память целиком).
func limitUploadBody(route string, limit int64, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/api/scenarios/archive" {
r.Body = http.MaxBytesReader(w, r.Body, int64(scenario_archive.MaxArchiveSize()))
if r.Method == http.MethodPost && r.URL.Path == route {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
// customErrorHandler мапит ошибки превышения размера тела в HTTP 413
// с понятным текстом:
// - *http.MaxBytesError (raw) — только для юнит-тестов: сгенерированный
// gateway-код строкифицирует ошибку декодера через %v и тип теряется;
// - gRPC ResourceExhausted на путях загрузки (файл больше лимита);
// - InvalidArgument с текстом MaxBytesError ("http: request body too
// large") — единственная реальная защита 413 на HTTP-слое, ветку нельзя
// удалять при «упрощении» обработчика.
//
// Остальные ошибки обрабатываются как обычно (DefaultHTTPErrorHandler).
func customErrorHandler(
ctx context.Context,
mux *runtime.ServeMux,
marshaler runtime.Marshaler,
w http.ResponseWriter,
r *http.Request,
err error,
) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeTooLargeError(w, marshaler)
return
}
if status.Code(err) == codes.ResourceExhausted {
switch r.URL.Path {
case "/api/files/upload", "/api/scenarios/archive":
writeTooLargeError(w, marshaler)
return
}
}
if status.Code(err) == codes.InvalidArgument &&
strings.Contains(status.Convert(err).Message(), "request body too large") {
writeTooLargeError(w, marshaler)
return
}
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
}
// writeTooLargeError — ответ 413 в том же формате {"error": ...}, что и
// DefaultHTTPErrorHandler. Текст лимита формируется из maxFileSize, чтобы
// не расходиться с реальным лимитом при его изменении.
func writeTooLargeError(w http.ResponseWriter, marshaler runtime.Marshaler) {
w.Header().Set("Content-Type", marshaler.ContentType(nil))
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = marshaler.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("файл слишком большой (лимит %d МБ)", maxFileSize>>20),
})
}
@@ -1962,6 +1962,10 @@
},
"image": {
"type": "string"
},
"file_type": {
"type": "string",
"description": "Тип файла улики: image | pdf | audio. Значение выводится сервером из\nрасширения файла (см. file_storage.FileType); пустое значение возможно\nтолько для legacy-строк без расширения. Явный json_name — осознанное\nотклонение от camelCase-конвенции REST (единый snake_case с внутренним\nJSON истории)."
}
}
},
@@ -2610,6 +2614,10 @@
},
"filename": {
"type": "string"
},
"file_type": {
"type": "string",
"description": "Тип загруженного файла: image | pdf | audio (определяется сервером по\nсодержимому). Удобен редактору для предзаполнения file_type улики."
}
}
},