generated from VLADIMIR/template
add load applications
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 улики."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user